feat(t216): deliver recoverable device order commands
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.roubao.autopilot"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 17
|
||||
versionName = "1.4.12"
|
||||
versionCode = 18
|
||||
versionName = "1.4.13"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -106,6 +106,7 @@ import com.roubao.autopilot.procurement.ExecutionEvidenceKind
|
||||
import com.roubao.autopilot.procurement.ExecutionMode
|
||||
import com.roubao.autopilot.procurement.ExecutionProvenanceSnapshot
|
||||
import com.roubao.autopilot.procurement.ProcurementRepository
|
||||
import com.roubao.autopilot.procurement.ProcurementPhase
|
||||
import com.roubao.autopilot.procurement.RankedExecutionCandidateDraft
|
||||
import com.roubao.autopilot.procurement.CandidateHumanReviewDraft
|
||||
import com.roubao.autopilot.task.RequirementProbeFixture
|
||||
@@ -296,6 +297,16 @@ class MainActivity : ComponentActivity() {
|
||||
LaunchedEffect(procurementState.task?.id) {
|
||||
resetTaskBoundProbeState()
|
||||
}
|
||||
LaunchedEffect(procurementState.phase) {
|
||||
if (
|
||||
procurementState.phase ==
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION ||
|
||||
procurementState.phase == ProcurementPhase.ORDER_AUTHORIZED
|
||||
) {
|
||||
candidateEvaluationState.value =
|
||||
CandidateEvaluationState.AWAITING_ADMIN_CONFIRMATION
|
||||
}
|
||||
}
|
||||
|
||||
// 监听跳转事件
|
||||
LaunchedEffect(navigateToRecord, recordId) {
|
||||
@@ -436,7 +447,16 @@ class MainActivity : ComponentActivity() {
|
||||
.map { it.candidate.ordinal },
|
||||
hasBudget =
|
||||
procurementState.task?.maxBudget != null,
|
||||
candidateCollectionAllowed =
|
||||
procurementState.task == null ||
|
||||
procurementState.phase ==
|
||||
ProcurementPhase.RUNNING,
|
||||
canStartCandidateEvaluation =
|
||||
(
|
||||
procurementState.task == null ||
|
||||
procurementState.phase ==
|
||||
ProcurementPhase.RUNNING
|
||||
) &&
|
||||
extractionState == RequirementProbeState.READY &&
|
||||
extraction != null &&
|
||||
evidenceRequirement == extraction &&
|
||||
@@ -598,6 +618,10 @@ class MainActivity : ComponentActivity() {
|
||||
Toast.makeText(this, "设备检查存在阻塞项", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (!procurementRepository.canCollectCandidates) {
|
||||
Toast.makeText(this, "当前任务已进入后台确认阶段", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (searchProbeJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
@@ -773,6 +797,10 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun startRequirementProbe() {
|
||||
if (!procurementRepository.canCollectCandidates) {
|
||||
Toast.makeText(this, "当前任务已进入后台确认阶段", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (requirementProbeJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
@@ -900,6 +928,10 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun startCandidateEvaluation() {
|
||||
if (!procurementRepository.canCollectCandidates) {
|
||||
Toast.makeText(this, "当前任务已进入后台确认阶段", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (candidateEvaluationJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
@@ -1187,16 +1219,24 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
candidateReviewBatch.value = result.batch
|
||||
candidateEvaluationState.value = when (
|
||||
result.batch.conclusion
|
||||
) {
|
||||
CandidateBatchConclusion.SUGGESTED ->
|
||||
CandidateEvaluationState.AWAITING_CONFIRMATION
|
||||
CandidateBatchConclusion.NO_MATCH ->
|
||||
CandidateEvaluationState.NO_MATCH
|
||||
CandidateBatchConclusion.MANUAL_REQUIRED ->
|
||||
CandidateEvaluationState.MANUAL_REVIEW
|
||||
}
|
||||
candidateEvaluationState.value =
|
||||
if (
|
||||
procurementRepository.currentProbeTask() != null &&
|
||||
taskCandidateDrafts.value.isNotEmpty()
|
||||
) {
|
||||
CandidateEvaluationState
|
||||
.AWAITING_ADMIN_CONFIRMATION
|
||||
} else {
|
||||
when (result.batch.conclusion) {
|
||||
CandidateBatchConclusion.SUGGESTED ->
|
||||
CandidateEvaluationState
|
||||
.AWAITING_CONFIRMATION
|
||||
CandidateBatchConclusion.NO_MATCH ->
|
||||
CandidateEvaluationState.NO_MATCH
|
||||
CandidateBatchConclusion.MANUAL_REQUIRED ->
|
||||
CandidateEvaluationState.MANUAL_REVIEW
|
||||
}
|
||||
}
|
||||
}
|
||||
is CandidateEvaluationResult.Failed -> {
|
||||
setCandidateEvaluationFailure(result.code)
|
||||
@@ -1281,7 +1321,10 @@ class MainActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
if (taskCandidateDrafts.value.isNotEmpty()) {
|
||||
candidateEvaluationState.value = CandidateEvaluationState.MANUAL_REVIEW
|
||||
candidateEvaluationState.value =
|
||||
CandidateEvaluationState.AWAITING_ADMIN_CONFIRMATION
|
||||
} else {
|
||||
candidateEvaluationState.value = CandidateEvaluationState.NO_MATCH
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1337,7 +1380,8 @@ class MainActivity : ComponentActivity() {
|
||||
if (
|
||||
candidateEvaluationState.value ==
|
||||
CandidateEvaluationState.HUMAN_REJECTED &&
|
||||
procurementRepository.currentProbeTask() != null
|
||||
procurementRepository.currentProbeTask() != null &&
|
||||
taskCandidateDrafts.value.isEmpty()
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.completeExecution(review = review)
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
object OrderCommandIntegrity {
|
||||
const val SCHEMA_VERSION = 1
|
||||
const val TYPE = "CREATE_PENDING_ORDER"
|
||||
private val sha256Pattern = Regex("[0-9a-f]{64}")
|
||||
|
||||
fun validate(
|
||||
command: PendingOrderCommand,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution
|
||||
) {
|
||||
require(command.schemaVersion == SCHEMA_VERSION) {
|
||||
"后台订单命令版本不受支持"
|
||||
}
|
||||
require(command.type == TYPE) { "后台订单命令类型不受支持" }
|
||||
require(
|
||||
command.id.isNotBlank() &&
|
||||
command.authorizationVersion >= 1 &&
|
||||
command.taskId == task.id &&
|
||||
command.executionId == execution.id &&
|
||||
command.taskContentSha256 == ExecutionTaskHash.sha256(task) &&
|
||||
command.originalSku == task.sku &&
|
||||
command.quantity == task.quantity
|
||||
) { "后台订单命令与当前任务不一致" }
|
||||
require(command.candidate.observedOrdinal in 1..5) {
|
||||
"后台订单命令候选编号无效"
|
||||
}
|
||||
require(command.candidate.title.isNotBlank()) {
|
||||
"后台订单命令候选标题缺失"
|
||||
}
|
||||
require(
|
||||
listOf(
|
||||
command.candidate.candidateKey,
|
||||
command.candidate.cardSignature,
|
||||
command.candidate.detailSignature,
|
||||
command.candidate.detailEvidenceSha256,
|
||||
command.candidate.specificationEvidenceSha256,
|
||||
command.commandSha256
|
||||
).all(sha256Pattern::matches)
|
||||
) { "后台订单命令指纹无效" }
|
||||
require(
|
||||
command.authorizationStatus == "DELIVERED" ||
|
||||
command.authorizationStatus == "ACKNOWLEDGED"
|
||||
) { "后台订单命令状态无效" }
|
||||
require(sha256(command) == command.commandSha256) {
|
||||
"后台订单命令完整性校验失败"
|
||||
}
|
||||
}
|
||||
|
||||
fun sha256(command: PendingOrderCommand): String {
|
||||
val payload = buildString {
|
||||
append('{')
|
||||
property("id", command.id, first = true)
|
||||
property("schema_version", command.schemaVersion)
|
||||
property("type", command.type)
|
||||
property("authorization_version", command.authorizationVersion)
|
||||
property("task_id", command.taskId)
|
||||
property("execution_id", command.executionId)
|
||||
property("task_content_sha256", command.taskContentSha256)
|
||||
property("original_sku", command.originalSku)
|
||||
property("quantity", command.quantity)
|
||||
append(",\"candidate\":{")
|
||||
property("candidate_key", command.candidate.candidateKey, first = true)
|
||||
property("observed_ordinal", command.candidate.observedOrdinal)
|
||||
property("title", command.candidate.title)
|
||||
property("sku_text", command.candidate.skuText)
|
||||
property("price_text", command.candidate.priceText)
|
||||
property("card_signature", command.candidate.cardSignature)
|
||||
property("detail_signature", command.candidate.detailSignature)
|
||||
property(
|
||||
"detail_evidence_sha256",
|
||||
command.candidate.detailEvidenceSha256
|
||||
)
|
||||
property(
|
||||
"specification_evidence_sha256",
|
||||
command.candidate.specificationEvidenceSha256
|
||||
)
|
||||
append("}}")
|
||||
}
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(payload.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun StringBuilder.property(
|
||||
name: String,
|
||||
value: String,
|
||||
first: Boolean = false
|
||||
) {
|
||||
if (!first) append(',')
|
||||
append('"').append(name).append("\":")
|
||||
appendJsonString(value)
|
||||
}
|
||||
|
||||
private fun StringBuilder.property(
|
||||
name: String,
|
||||
value: Int,
|
||||
first: Boolean = false
|
||||
) {
|
||||
if (!first) append(',')
|
||||
append('"').append(name).append("\":").append(value)
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendJsonString(value: String) {
|
||||
append('"')
|
||||
value.forEach { character ->
|
||||
when (character) {
|
||||
'"' -> append("\\\"")
|
||||
'\\' -> append("\\\\")
|
||||
'\b' -> append("\\b")
|
||||
'\u000C' -> append("\\f")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
'<' -> append("\\u003c")
|
||||
'>' -> append("\\u003e")
|
||||
'&' -> append("\\u0026")
|
||||
'\u2028' -> append("\\u2028")
|
||||
'\u2029' -> append("\\u2029")
|
||||
else -> if (character.code < 0x20) {
|
||||
append("\\u")
|
||||
append(character.code.toString(16).padStart(4, '0'))
|
||||
} else {
|
||||
append(character)
|
||||
}
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
object OrderCommandSynchronization {
|
||||
suspend fun synchronize(
|
||||
existing: PendingOrderCommand?,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
pull: suspend () -> PendingOrderCommand?,
|
||||
persist: (PendingOrderCommand) -> Unit,
|
||||
acknowledge: suspend (
|
||||
PendingOrderCommand,
|
||||
String
|
||||
) -> OrderCommandAcknowledgement,
|
||||
newIdempotencyKey: () -> String
|
||||
): PendingOrderCommand? {
|
||||
var command = existing
|
||||
if (command == null) {
|
||||
val delivered = pull() ?: return null
|
||||
OrderCommandIntegrity.validate(delivered, task, execution)
|
||||
command = delivered.copy(
|
||||
acknowledgementIdempotencyKey = newIdempotencyKey()
|
||||
)
|
||||
persist(command)
|
||||
} else {
|
||||
OrderCommandIntegrity.validate(command, task, execution)
|
||||
}
|
||||
if (command.acknowledged) {
|
||||
return command
|
||||
}
|
||||
val idempotencyKey = requireNotNull(
|
||||
command.acknowledgementIdempotencyKey
|
||||
) { "后台订单命令确认键缺失" }
|
||||
val acknowledgement = acknowledge(command, idempotencyKey)
|
||||
require(
|
||||
acknowledgement.commandId == command.id &&
|
||||
acknowledgement.status == "ACKNOWLEDGED"
|
||||
) { "后台订单命令确认响应无效" }
|
||||
return command.copy(
|
||||
authorizationStatus = acknowledgement.status,
|
||||
acknowledged = true
|
||||
).also(persist)
|
||||
}
|
||||
}
|
||||
+109
@@ -84,6 +84,22 @@ interface ProcurementRemoteApi {
|
||||
idempotencyKey: String
|
||||
): RemotePurchaseTask
|
||||
|
||||
suspend fun pullOrderCommand(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String
|
||||
): PendingOrderCommand?
|
||||
|
||||
suspend fun acknowledgeOrderCommand(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
command: PendingOrderCommand,
|
||||
idempotencyKey: String
|
||||
): OrderCommandAcknowledgement
|
||||
|
||||
suspend fun uploadExecutionOutboxItem(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
@@ -395,6 +411,67 @@ class ProcurementApiClient(
|
||||
return parseTask(json.getJSONObject("task"))
|
||||
}
|
||||
|
||||
override suspend fun pullOrderCommand(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String
|
||||
): PendingOrderCommand? = withContext(Dispatchers.IO) {
|
||||
val payload = JSONObject()
|
||||
.put("device_id", session.deviceId)
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
client.newCall(
|
||||
authorizedRequest(
|
||||
session,
|
||||
"/api/v1/tasks/${task.id}/commands/next"
|
||||
)
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
).execute().use { response ->
|
||||
if (response.code == 204) {
|
||||
return@withContext null
|
||||
}
|
||||
parseOrderCommand(
|
||||
responseJsonOrThrow(
|
||||
response.code,
|
||||
response.body.readBoundedJson(response.code)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeOrderCommand(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
command: PendingOrderCommand,
|
||||
idempotencyKey: String
|
||||
): OrderCommandAcknowledgement {
|
||||
val payload = JSONObject()
|
||||
.put("device_id", session.deviceId)
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put("command_sha256", command.commandSha256)
|
||||
val json = executeJson(
|
||||
authorizedRequest(
|
||||
session,
|
||||
"/api/v1/tasks/${task.id}/commands/${command.id}/ack"
|
||||
)
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
)
|
||||
return OrderCommandAcknowledgement(
|
||||
commandId = json.getString("command_id"),
|
||||
status = json.getString("status"),
|
||||
replayed = json.getBoolean("replayed")
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun executeTransition(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
@@ -466,6 +543,8 @@ class ProcurementApiClient(
|
||||
"TASK_CLAIM_INVALID" -> "任务归属校验失败"
|
||||
"TASK_CLAIM_EXPIRED" -> "任务授权已过期"
|
||||
"TASK_VERSION_CONFLICT" -> "任务已被后台更新,请先同步"
|
||||
"ORDER_COMMAND_CONFLICT" -> "后台订单命令已变化,自动化保持停止"
|
||||
"ORDER_COMMAND_NOT_DELIVERABLE" -> "后台订单命令当前不可领取"
|
||||
else -> if (statusCode >= 500) "后台暂时不可用" else "后台拒绝了请求"
|
||||
}
|
||||
|
||||
@@ -521,6 +600,36 @@ class ProcurementApiClient(
|
||||
currency = json.optString("currency", "CNY")
|
||||
)
|
||||
|
||||
private fun parseOrderCommand(json: JSONObject): PendingOrderCommand {
|
||||
val candidate = json.getJSONObject("candidate")
|
||||
return PendingOrderCommand(
|
||||
id = json.getString("id"),
|
||||
schemaVersion = json.getInt("schema_version"),
|
||||
type = json.getString("type"),
|
||||
authorizationVersion = json.getInt("authorization_version"),
|
||||
taskId = json.getString("task_id"),
|
||||
executionId = json.getString("execution_id"),
|
||||
taskContentSha256 = json.getString("task_content_sha256"),
|
||||
originalSku = json.getString("original_sku"),
|
||||
quantity = json.getInt("quantity"),
|
||||
candidate = OrderCommandCandidate(
|
||||
candidateKey = candidate.getString("candidate_key"),
|
||||
observedOrdinal = candidate.getInt("observed_ordinal"),
|
||||
title = candidate.getString("title"),
|
||||
skuText = candidate.getString("sku_text"),
|
||||
priceText = candidate.getString("price_text"),
|
||||
cardSignature = candidate.getString("card_signature"),
|
||||
detailSignature = candidate.getString("detail_signature"),
|
||||
detailEvidenceSha256 =
|
||||
candidate.getString("detail_evidence_sha256"),
|
||||
specificationEvidenceSha256 =
|
||||
candidate.getString("specification_evidence_sha256")
|
||||
),
|
||||
commandSha256 = json.getString("command_sha256"),
|
||||
authorizationStatus = json.getString("authorization_status")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.jsonBody() =
|
||||
toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
|
||||
+40
-1
@@ -9,6 +9,8 @@ enum class ProcurementPhase {
|
||||
IDLE,
|
||||
CLAIMED,
|
||||
RUNNING,
|
||||
WAITING_ADMIN_CONFIRMATION,
|
||||
ORDER_AUTHORIZED,
|
||||
AUTHORIZATION_EXPIRED
|
||||
}
|
||||
|
||||
@@ -84,7 +86,43 @@ data class PersistedProcurementState(
|
||||
val deviceToken: String? = null,
|
||||
val claim: ClaimContext? = null,
|
||||
val execution: RunningExecution? = null,
|
||||
val outbox: List<ExecutionOutboxItem> = emptyList()
|
||||
val outbox: List<ExecutionOutboxItem> = emptyList(),
|
||||
val orderCommand: PendingOrderCommand? = null
|
||||
)
|
||||
|
||||
data class OrderCommandCandidate(
|
||||
val candidateKey: String,
|
||||
val observedOrdinal: Int,
|
||||
val title: String,
|
||||
val skuText: String,
|
||||
val priceText: String,
|
||||
val cardSignature: String,
|
||||
val detailSignature: String,
|
||||
val detailEvidenceSha256: String,
|
||||
val specificationEvidenceSha256: String
|
||||
)
|
||||
|
||||
data class PendingOrderCommand(
|
||||
val id: String,
|
||||
val schemaVersion: Int,
|
||||
val type: String,
|
||||
val authorizationVersion: Int,
|
||||
val taskId: String,
|
||||
val executionId: String,
|
||||
val taskContentSha256: String,
|
||||
val originalSku: String,
|
||||
val quantity: Int,
|
||||
val candidate: OrderCommandCandidate,
|
||||
val commandSha256: String,
|
||||
val authorizationStatus: String,
|
||||
val acknowledgementIdempotencyKey: String? = null,
|
||||
val acknowledged: Boolean = false
|
||||
)
|
||||
|
||||
data class OrderCommandAcknowledgement(
|
||||
val commandId: String,
|
||||
val status: String,
|
||||
val replayed: Boolean
|
||||
)
|
||||
|
||||
enum class ExecutionMode {
|
||||
@@ -125,6 +163,7 @@ data class ProcurementUiState(
|
||||
val task: RemotePurchaseTask? = null,
|
||||
val referenceImagePath: String? = null,
|
||||
val execution: RunningExecution? = null,
|
||||
val orderCommand: PendingOrderCommand? = null,
|
||||
val authenticationRequired: Boolean = false,
|
||||
val busy: Boolean = false,
|
||||
val backendOnline: Boolean? = null,
|
||||
|
||||
+105
-8
@@ -44,6 +44,19 @@ class ProcurementRepository(
|
||||
get() = storageFailure == null &&
|
||||
persisted.execution?.safetyStopped == false
|
||||
|
||||
val canCollectCandidates: Boolean
|
||||
get() {
|
||||
if (persisted.claim?.task == null) {
|
||||
return true
|
||||
}
|
||||
val execution = persisted.execution ?: return false
|
||||
return !execution.safetyStopped &&
|
||||
!execution.isExpired() &&
|
||||
execution.currentStep != WAITING_ADMIN_CONFIRMATION_STEP &&
|
||||
execution.currentStep != ORDER_AUTHORIZED_STEP &&
|
||||
persisted.orderCommand == null
|
||||
}
|
||||
|
||||
suspend fun login(input: LoginInput): Boolean = operation {
|
||||
require(input.password.isNotEmpty()) { "请输入采购员密码" }
|
||||
val normalizedUrl = BackendEndpointPolicy.normalize(
|
||||
@@ -342,12 +355,29 @@ class ProcurementRepository(
|
||||
acknowledgeCancellation(session)
|
||||
ExecutionSyncDecision.STOP
|
||||
} else {
|
||||
val orderCommand = if (
|
||||
result.task.status == "WAITING_CONFIRMATION" ||
|
||||
persisted.orderCommand != null
|
||||
) {
|
||||
synchronizeOrderCommand(
|
||||
session = session,
|
||||
task = result.task,
|
||||
execution = updatedExecution,
|
||||
claimToken = claim.token
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
publish(
|
||||
backendOnline = true,
|
||||
message = if (updatedExecution.safetyStopped) {
|
||||
"后台状态已同步,任务仍保持安全停止"
|
||||
} else {
|
||||
"进度已同步"
|
||||
message = when {
|
||||
updatedExecution.safetyStopped ->
|
||||
"后台状态已同步,任务仍保持安全停止"
|
||||
orderCommand?.acknowledged == true ->
|
||||
"后台确认商品已安全同步,等待执行下单"
|
||||
result.task.status == "WAITING_CONFIRMATION" ->
|
||||
"候选已回传,等待后台采购员确认"
|
||||
else -> "进度已同步"
|
||||
}
|
||||
)
|
||||
if (updatedExecution.safetyStopped) {
|
||||
@@ -356,10 +386,18 @@ class ProcurementRepository(
|
||||
ExecutionSyncDecision.CONTINUE
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (error: Exception) {
|
||||
val localFailure =
|
||||
error is IllegalArgumentException ||
|
||||
error is IllegalStateException
|
||||
publish(
|
||||
backendOnline = false,
|
||||
message = "后台暂时离线,将在授权截止前重试"
|
||||
backendOnline = if (localFailure) true else false,
|
||||
message = if (localFailure) {
|
||||
null
|
||||
} else {
|
||||
"后台暂时离线,将在授权截止前重试"
|
||||
},
|
||||
error = if (localFailure) userMessage(error) else null
|
||||
)
|
||||
if (execution.safetyStopped || execution.isExpired()) {
|
||||
ExecutionSyncDecision.STOP
|
||||
@@ -477,6 +515,13 @@ class ProcurementRepository(
|
||||
payload = payload.toString()
|
||||
)
|
||||
)
|
||||
if (candidates.isNotEmpty()) {
|
||||
persisted = persisted.copy(
|
||||
execution = execution.copy(
|
||||
currentStep = WAITING_ADMIN_CONFIRMATION_STEP
|
||||
)
|
||||
)
|
||||
}
|
||||
saveAndPublish(
|
||||
backendOnline = _uiState.value.backendOnline,
|
||||
message = "候选和截图已加入加密回传队列"
|
||||
@@ -635,6 +680,49 @@ class ProcurementRepository(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun synchronizeOrderCommand(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String
|
||||
): PendingOrderCommand? {
|
||||
val command = OrderCommandSynchronization.synchronize(
|
||||
existing = persisted.orderCommand,
|
||||
task = task,
|
||||
execution = execution,
|
||||
pull = {
|
||||
api.pullOrderCommand(
|
||||
session = session,
|
||||
task = task,
|
||||
execution = execution,
|
||||
claimToken = claimToken
|
||||
)
|
||||
},
|
||||
persist = { storedCommand ->
|
||||
persisted = persisted.copy(orderCommand = storedCommand)
|
||||
store.save(persisted)
|
||||
},
|
||||
acknowledge = { storedCommand, idempotencyKey ->
|
||||
api.acknowledgeOrderCommand(
|
||||
session = session,
|
||||
task = task,
|
||||
execution = execution,
|
||||
claimToken = claimToken,
|
||||
command = storedCommand,
|
||||
idempotencyKey = idempotencyKey
|
||||
)
|
||||
},
|
||||
newIdempotencyKey = ::newOpaqueSecret
|
||||
) ?: return null
|
||||
if (command.acknowledged) {
|
||||
persisted = persisted.copy(
|
||||
execution = execution.copy(currentStep = ORDER_AUTHORIZED_STEP)
|
||||
)
|
||||
store.save(persisted)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
private suspend fun downloadAndPersistReference(
|
||||
session: ProcurementSession,
|
||||
claim: ClaimContext,
|
||||
@@ -687,7 +775,8 @@ class ProcurementRepository(
|
||||
persisted = persisted.copy(
|
||||
claim = null,
|
||||
execution = null,
|
||||
outbox = emptyList()
|
||||
outbox = emptyList(),
|
||||
orderCommand = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1010,6 +1099,10 @@ class ProcurementRepository(
|
||||
execution != null &&
|
||||
(execution.safetyStopped || execution.isExpired()) ->
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED
|
||||
persisted.orderCommand?.acknowledged == true ->
|
||||
ProcurementPhase.ORDER_AUTHORIZED
|
||||
execution?.currentStep == WAITING_ADMIN_CONFIRMATION_STEP ->
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION
|
||||
execution != null -> ProcurementPhase.RUNNING
|
||||
claim?.task != null -> ProcurementPhase.CLAIMED
|
||||
session?.isValid() == true -> ProcurementPhase.IDLE
|
||||
@@ -1031,6 +1124,7 @@ class ProcurementRepository(
|
||||
task = claim?.task,
|
||||
referenceImagePath = imagePath,
|
||||
execution = execution,
|
||||
orderCommand = persisted.orderCommand,
|
||||
authenticationRequired = session?.isValid() != true
|
||||
)
|
||||
}
|
||||
@@ -1068,6 +1162,9 @@ class ProcurementRepository(
|
||||
private const val REFERENCE_DIRECTORY = "procurement"
|
||||
private const val OUTBOX_DIRECTORY = "procurement-outbox"
|
||||
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
|
||||
private const val WAITING_ADMIN_CONFIRMATION_STEP =
|
||||
"WAITING_ADMIN_CONFIRMATION"
|
||||
private const val ORDER_AUTHORIZED_STEP = "ORDER_AUTHORIZED"
|
||||
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
|
||||
private const val MAX_REFERENCE_DIMENSION = 4_096
|
||||
private const val MAX_REFERENCE_PIXELS = 20_000_000L
|
||||
|
||||
+80
-1
@@ -134,6 +134,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
}
|
||||
}
|
||||
)
|
||||
state.orderCommand?.let { command ->
|
||||
put("order_command", encodeOrderCommand(command))
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeState(json: JSONObject): PersistedProcurementState =
|
||||
@@ -215,9 +218,85 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
} ?: emptyList(),
|
||||
orderCommand = json.optionalObject("order_command")?.let {
|
||||
decodeOrderCommand(it)
|
||||
}
|
||||
)
|
||||
|
||||
private fun encodeOrderCommand(command: PendingOrderCommand): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("id", command.id)
|
||||
put("schema_version", command.schemaVersion)
|
||||
put("type", command.type)
|
||||
put("authorization_version", command.authorizationVersion)
|
||||
put("task_id", command.taskId)
|
||||
put("execution_id", command.executionId)
|
||||
put("task_content_sha256", command.taskContentSha256)
|
||||
put("original_sku", command.originalSku)
|
||||
put("quantity", command.quantity)
|
||||
put(
|
||||
"candidate",
|
||||
JSONObject().apply {
|
||||
put("candidate_key", command.candidate.candidateKey)
|
||||
put("observed_ordinal", command.candidate.observedOrdinal)
|
||||
put("title", command.candidate.title)
|
||||
put("sku_text", command.candidate.skuText)
|
||||
put("price_text", command.candidate.priceText)
|
||||
put("card_signature", command.candidate.cardSignature)
|
||||
put("detail_signature", command.candidate.detailSignature)
|
||||
put(
|
||||
"detail_evidence_sha256",
|
||||
command.candidate.detailEvidenceSha256
|
||||
)
|
||||
put(
|
||||
"specification_evidence_sha256",
|
||||
command.candidate.specificationEvidenceSha256
|
||||
)
|
||||
}
|
||||
)
|
||||
put("command_sha256", command.commandSha256)
|
||||
put("authorization_status", command.authorizationStatus)
|
||||
putNullable(
|
||||
"acknowledgement_idempotency_key",
|
||||
command.acknowledgementIdempotencyKey
|
||||
)
|
||||
put("acknowledged", command.acknowledged)
|
||||
}
|
||||
|
||||
private fun decodeOrderCommand(json: JSONObject): PendingOrderCommand {
|
||||
val candidate = json.getJSONObject("candidate")
|
||||
return PendingOrderCommand(
|
||||
id = json.getString("id"),
|
||||
schemaVersion = json.getInt("schema_version"),
|
||||
type = json.getString("type"),
|
||||
authorizationVersion = json.getInt("authorization_version"),
|
||||
taskId = json.getString("task_id"),
|
||||
executionId = json.getString("execution_id"),
|
||||
taskContentSha256 = json.getString("task_content_sha256"),
|
||||
originalSku = json.getString("original_sku"),
|
||||
quantity = json.getInt("quantity"),
|
||||
candidate = OrderCommandCandidate(
|
||||
candidateKey = candidate.getString("candidate_key"),
|
||||
observedOrdinal = candidate.getInt("observed_ordinal"),
|
||||
title = candidate.getString("title"),
|
||||
skuText = candidate.getString("sku_text"),
|
||||
priceText = candidate.getString("price_text"),
|
||||
cardSignature = candidate.getString("card_signature"),
|
||||
detailSignature = candidate.getString("detail_signature"),
|
||||
detailEvidenceSha256 =
|
||||
candidate.getString("detail_evidence_sha256"),
|
||||
specificationEvidenceSha256 =
|
||||
candidate.getString("specification_evidence_sha256")
|
||||
),
|
||||
commandSha256 = json.getString("command_sha256"),
|
||||
authorizationStatus = json.getString("authorization_status"),
|
||||
acknowledgementIdempotencyKey =
|
||||
json.optionalString("acknowledgement_idempotency_key"),
|
||||
acknowledged = json.optBoolean("acknowledged", false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun encodeTask(task: RemotePurchaseTask): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("id", task.id)
|
||||
|
||||
+27
-3
@@ -196,6 +196,8 @@ fun ProcurementScreen(
|
||||
}
|
||||
}
|
||||
ProcurementPhase.RUNNING,
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
|
||||
ProcurementPhase.ORDER_AUTHORIZED,
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
|
||||
if (state.authenticationRequired) {
|
||||
item(key = "procurement-login") {
|
||||
@@ -452,6 +454,12 @@ private fun ExecutionDetails(state: ProcurementUiState) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Divider(color = colors.surfaceVariant)
|
||||
DetailRow("当前步骤", execution.currentStep)
|
||||
state.orderCommand?.let { command ->
|
||||
DetailRow("后台已选商品", command.candidate.title)
|
||||
DetailRow("目标规格", command.candidate.skuText)
|
||||
DetailRow("目标数量", command.quantity.toString())
|
||||
DetailRow("候选指纹", command.candidate.candidateKey)
|
||||
}
|
||||
DetailRow(
|
||||
"后台连接",
|
||||
when (state.backendOnline) {
|
||||
@@ -472,8 +480,20 @@ private fun ExecutionDetails(state: ProcurementUiState) {
|
||||
}
|
||||
)
|
||||
Text(
|
||||
"订单提交保持禁用",
|
||||
color = colors.warning,
|
||||
when (state.phase) {
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION ->
|
||||
"等待后台采购员确认商品"
|
||||
ProcurementPhase.ORDER_AUTHORIZED ->
|
||||
"商品授权已安全保存;本版本尚不执行下单"
|
||||
else -> "订单提交保持禁用"
|
||||
},
|
||||
color = if (
|
||||
state.phase == ProcurementPhase.ORDER_AUTHORIZED
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
colors.warning
|
||||
},
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
@@ -506,7 +526,9 @@ private fun DetailRow(label: String, value: String) {
|
||||
private fun phaseColor(state: ProcurementUiState) =
|
||||
when (state.phase) {
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> BaoziTheme.colors.error
|
||||
ProcurementPhase.RUNNING -> BaoziTheme.colors.success
|
||||
ProcurementPhase.RUNNING,
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
|
||||
ProcurementPhase.ORDER_AUTHORIZED -> BaoziTheme.colors.success
|
||||
else -> BaoziTheme.colors.textSecondary
|
||||
}
|
||||
|
||||
@@ -516,5 +538,7 @@ private fun phaseLabel(state: ProcurementUiState): String =
|
||||
ProcurementPhase.IDLE -> "等待领取"
|
||||
ProcurementPhase.CLAIMED -> "已领取,等待开始"
|
||||
ProcurementPhase.RUNNING -> "受控流程运行中"
|
||||
ProcurementPhase.WAITING_ADMIN_CONFIRMATION -> "等待后台确认商品"
|
||||
ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步"
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ fun SearchProbeScreen(
|
||||
candidateEvaluationFailureCode: CandidateEvaluationFailureCode?,
|
||||
candidateOrdinals: List<Int>,
|
||||
hasBudget: Boolean,
|
||||
candidateCollectionAllowed: Boolean,
|
||||
canStartCandidateEvaluation: Boolean,
|
||||
onStartRequirement: () -> Unit,
|
||||
onStopRequirement: () -> Unit,
|
||||
@@ -157,6 +158,7 @@ fun SearchProbeScreen(
|
||||
requirement = requirement,
|
||||
failureCode = requirementFailureCode,
|
||||
canStart = !active &&
|
||||
candidateCollectionAllowed &&
|
||||
candidateEvaluationState != CandidateEvaluationState.RUNNING,
|
||||
onStart = onStartRequirement,
|
||||
onStop = onStopRequirement
|
||||
@@ -277,6 +279,7 @@ fun SearchProbeScreen(
|
||||
Button(
|
||||
onClick = onStart,
|
||||
enabled = readiness.canStartProbe &&
|
||||
candidateCollectionAllowed &&
|
||||
requirementState != RequirementProbeState.RUNNING &&
|
||||
candidateEvaluationState != CandidateEvaluationState.RUNNING,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -338,6 +341,7 @@ private fun CandidateEvaluationSection(
|
||||
var rejectNote by remember(state) { mutableStateOf("") }
|
||||
val statusColor = when (state) {
|
||||
CandidateEvaluationState.AWAITING_CONFIRMATION,
|
||||
CandidateEvaluationState.AWAITING_ADMIN_CONFIRMATION,
|
||||
CandidateEvaluationState.HUMAN_ACCEPTED -> colors.success
|
||||
CandidateEvaluationState.MANUAL_REVIEW,
|
||||
CandidateEvaluationState.NO_MATCH -> colors.warning
|
||||
@@ -575,6 +579,13 @@ private fun CandidateEvaluationSection(
|
||||
Text("拒绝本次候选")
|
||||
}
|
||||
}
|
||||
CandidateEvaluationState.AWAITING_ADMIN_CONFIRMATION -> {
|
||||
Text(
|
||||
text = "候选已安全回传,等待后台采购员选择商品",
|
||||
fontSize = 14.sp,
|
||||
color = colors.success
|
||||
)
|
||||
}
|
||||
CandidateEvaluationState.MANUAL_REVIEW,
|
||||
CandidateEvaluationState.NO_MATCH -> {
|
||||
if (candidateOrdinals.isNotEmpty()) {
|
||||
@@ -784,6 +795,7 @@ private fun candidateEvaluationStateLabel(
|
||||
CandidateEvaluationState.IDLE -> "等待"
|
||||
CandidateEvaluationState.RUNNING -> "评估中"
|
||||
CandidateEvaluationState.AWAITING_CONFIRMATION -> "等待人工确认"
|
||||
CandidateEvaluationState.AWAITING_ADMIN_CONFIRMATION -> "等待后台确认"
|
||||
CandidateEvaluationState.MANUAL_REVIEW -> "需人工判断"
|
||||
CandidateEvaluationState.NO_MATCH -> "无建议候选"
|
||||
CandidateEvaluationState.HUMAN_ACCEPTED -> "人员已标记可用"
|
||||
|
||||
@@ -142,6 +142,7 @@ enum class CandidateEvaluationState {
|
||||
IDLE,
|
||||
RUNNING,
|
||||
AWAITING_CONFIRMATION,
|
||||
AWAITING_ADMIN_CONFIRMATION,
|
||||
MANUAL_REVIEW,
|
||||
NO_MATCH,
|
||||
HUMAN_ACCEPTED,
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class OrderCommandIntegrityTest {
|
||||
@Test
|
||||
fun hashMatchesGoContractVector() {
|
||||
val command = PendingOrderCommand(
|
||||
id = "00000000-0000-4000-8000-000000000009",
|
||||
schemaVersion = 1,
|
||||
type = "CREATE_PENDING_ORDER",
|
||||
authorizationVersion = 3,
|
||||
taskId = "00000000-0000-4000-8000-000000000001",
|
||||
executionId = "00000000-0000-4000-8000-000000000002",
|
||||
taskContentSha256 = "a".repeat(64),
|
||||
originalSku = "红色 M",
|
||||
quantity = 2,
|
||||
candidate = OrderCommandCandidate(
|
||||
candidateKey = "candidate-key-1",
|
||||
observedOrdinal = 1,
|
||||
title = "红色连衣裙",
|
||||
skuText = "红色 / M",
|
||||
priceText = "19.90 CNY",
|
||||
cardSignature = "b".repeat(64),
|
||||
detailSignature = "c".repeat(64),
|
||||
detailEvidenceSha256 = "d".repeat(64),
|
||||
specificationEvidenceSha256 = "e".repeat(64)
|
||||
),
|
||||
commandSha256 =
|
||||
"cf3cd802366d4de49d51940551385f7e5bd3283ef18cbf245a227c679d1058f3",
|
||||
authorizationStatus = "DELIVERED"
|
||||
)
|
||||
|
||||
assertEquals(command.commandSha256, OrderCommandIntegrity.sha256(command))
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class OrderCommandSynchronizationTest {
|
||||
@Test
|
||||
fun commandIsPersistedBeforeAcknowledgementAndReplayUsesSameKey() = runBlocking {
|
||||
val task = task()
|
||||
val execution = execution()
|
||||
val delivered = command(task, execution)
|
||||
val calls = mutableListOf<String>()
|
||||
var persisted: PendingOrderCommand? = null
|
||||
|
||||
val result = OrderCommandSynchronization.synchronize(
|
||||
existing = null,
|
||||
task = task,
|
||||
execution = execution,
|
||||
pull = {
|
||||
calls += "pull"
|
||||
delivered
|
||||
},
|
||||
persist = {
|
||||
calls += if (it.acknowledged) "persist-ack" else "persist-command"
|
||||
persisted = it
|
||||
},
|
||||
acknowledge = { command, key ->
|
||||
calls += "ack:$key"
|
||||
assertEquals(command, persisted)
|
||||
OrderCommandAcknowledgement(
|
||||
commandId = command.id,
|
||||
status = "ACKNOWLEDGED",
|
||||
replayed = false
|
||||
)
|
||||
},
|
||||
newIdempotencyKey = { "stable-ack-key" }
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"pull",
|
||||
"persist-command",
|
||||
"ack:stable-ack-key",
|
||||
"persist-ack"
|
||||
),
|
||||
calls
|
||||
)
|
||||
assertTrue(requireNotNull(result).acknowledged)
|
||||
assertEquals("stable-ack-key", result.acknowledgementIdempotencyKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistenceFailurePreventsAcknowledgement() = runBlocking {
|
||||
val task = task()
|
||||
val execution = execution()
|
||||
var acknowledged = false
|
||||
|
||||
val failure = runCatching {
|
||||
OrderCommandSynchronization.synchronize(
|
||||
existing = null,
|
||||
task = task,
|
||||
execution = execution,
|
||||
pull = { command(task, execution) },
|
||||
persist = { error("storage unavailable") },
|
||||
acknowledge = { _, _ ->
|
||||
acknowledged = true
|
||||
error("must not acknowledge")
|
||||
},
|
||||
newIdempotencyKey = { "stable-ack-key" }
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertEquals("storage unavailable", failure?.message)
|
||||
assertEquals(false, acknowledged)
|
||||
}
|
||||
|
||||
private fun task() = RemotePurchaseTask(
|
||||
id = "00000000-0000-4000-8000-000000000001",
|
||||
status = "WAITING_CONFIRMATION",
|
||||
version = 4,
|
||||
claimGeneration = 1,
|
||||
claimExpiresAt = "2099-01-01T00:00:00Z",
|
||||
title = "测试商品",
|
||||
description = "",
|
||||
sku = "红色 M",
|
||||
imageAssetId = "00000000-0000-4000-8000-000000000003",
|
||||
referenceImageUrl = "/api/v1/tasks/task/reference-image",
|
||||
quantity = 2,
|
||||
maxBudget = "20.00",
|
||||
currency = "CNY"
|
||||
)
|
||||
|
||||
private fun execution() = RunningExecution(
|
||||
id = "00000000-0000-4000-8000-000000000002",
|
||||
currentStep = "WAITING_ADMIN_CONFIRMATION",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
)
|
||||
|
||||
private fun command(
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution
|
||||
): PendingOrderCommand {
|
||||
val unsigned = PendingOrderCommand(
|
||||
id = "00000000-0000-4000-8000-000000000009",
|
||||
schemaVersion = 1,
|
||||
type = "CREATE_PENDING_ORDER",
|
||||
authorizationVersion = 1,
|
||||
taskId = task.id,
|
||||
executionId = execution.id,
|
||||
taskContentSha256 = ExecutionTaskHash.sha256(task),
|
||||
originalSku = task.sku,
|
||||
quantity = task.quantity,
|
||||
candidate = OrderCommandCandidate(
|
||||
candidateKey = "f".repeat(64),
|
||||
observedOrdinal = 1,
|
||||
title = "测试候选",
|
||||
skuText = "红色 / M",
|
||||
priceText = "19.90",
|
||||
cardSignature = "b".repeat(64),
|
||||
detailSignature = "c".repeat(64),
|
||||
detailEvidenceSha256 = "d".repeat(64),
|
||||
specificationEvidenceSha256 = "e".repeat(64)
|
||||
),
|
||||
commandSha256 = "0".repeat(64),
|
||||
authorizationStatus = "DELIVERED"
|
||||
)
|
||||
return unsigned.copy(
|
||||
commandSha256 = OrderCommandIntegrity.sha256(unsigned)
|
||||
)
|
||||
}
|
||||
}
|
||||
+103
@@ -250,6 +250,109 @@ class ProcurementApiClientTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderCommandPullAndAcknowledgementFollowDeviceContract() = runBlocking {
|
||||
val execution = RunningExecution(
|
||||
id = "execution-id",
|
||||
currentStep = "WAITING_ADMIN_CONFIRMATION",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
)
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"id":"command-id",
|
||||
"schema_version":1,
|
||||
"type":"CREATE_PENDING_ORDER",
|
||||
"authorization_version":1,
|
||||
"task_id":"task-id",
|
||||
"execution_id":"execution-id",
|
||||
"task_content_sha256":"${"a".repeat(64)}",
|
||||
"original_sku":"SKU-01",
|
||||
"quantity":2,
|
||||
"candidate":{
|
||||
"candidate_key":"candidate-key-1",
|
||||
"observed_ordinal":1,
|
||||
"title":"候选商品",
|
||||
"sku_text":"红色 / M",
|
||||
"price_text":"20.00",
|
||||
"card_signature":"${"b".repeat(64)}",
|
||||
"detail_signature":"${"c".repeat(64)}",
|
||||
"detail_evidence_sha256":"${"d".repeat(64)}",
|
||||
"specification_evidence_sha256":"${"e".repeat(64)}"
|
||||
},
|
||||
"command_sha256":"${"f".repeat(64)}",
|
||||
"authorization_status":"DELIVERED"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
val command = requireNotNull(
|
||||
api.pullOrderCommand(
|
||||
session(),
|
||||
task("/api/v1/tasks/task-id/reference-image?claim_generation=1"),
|
||||
execution,
|
||||
"claim-token-value"
|
||||
)
|
||||
)
|
||||
val pullRequest = server.takeRequest()
|
||||
assertEquals(
|
||||
"/api/v1/tasks/task-id/commands/next",
|
||||
pullRequest.path
|
||||
)
|
||||
assertEquals(
|
||||
"claim-token-value",
|
||||
pullRequest.getHeader("X-Claim-Token")
|
||||
)
|
||||
assertTrue(pullRequest.body.readUtf8().contains("\"execution_id\":\"execution-id\""))
|
||||
assertEquals("candidate-key-1", command.candidate.candidateKey)
|
||||
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""{"command_id":"command-id","status":"ACKNOWLEDGED","replayed":false}"""
|
||||
)
|
||||
)
|
||||
val acknowledgement = api.acknowledgeOrderCommand(
|
||||
session(),
|
||||
task("/api/v1/tasks/task-id/reference-image?claim_generation=1"),
|
||||
execution,
|
||||
"claim-token-value",
|
||||
command,
|
||||
"command-ack-idempotency-key"
|
||||
)
|
||||
val ackRequest = server.takeRequest()
|
||||
assertEquals(
|
||||
"/api/v1/tasks/task-id/commands/command-id/ack",
|
||||
ackRequest.path
|
||||
)
|
||||
assertEquals(
|
||||
"command-ack-idempotency-key",
|
||||
ackRequest.getHeader("Idempotency-Key")
|
||||
)
|
||||
assertTrue(ackRequest.body.readUtf8().contains(command.commandSha256))
|
||||
assertEquals("ACKNOWLEDGED", acknowledgement.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noOrderCommandIsAStableEmptyResult() = runBlocking {
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
|
||||
val command = api.pullOrderCommand(
|
||||
session(),
|
||||
task("/api/v1/tasks/task-id/reference-image?claim_generation=1"),
|
||||
RunningExecution(
|
||||
id = "execution-id",
|
||||
currentStep = "WAITING_ADMIN_CONFIRMATION",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
),
|
||||
"claim-token-value"
|
||||
)
|
||||
|
||||
assertEquals(null, command)
|
||||
}
|
||||
|
||||
private fun session() = ProcurementSession(
|
||||
backendUrl = server.url("/").toString().trimEnd('/'),
|
||||
username = "buyer01",
|
||||
|
||||
@@ -197,6 +197,14 @@ func buildRouter(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commands, err := usecase.NewDeviceOrderCommandService(
|
||||
store,
|
||||
clock,
|
||||
ids,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passwords, err := password.NewBcrypt(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -206,6 +214,7 @@ func buildRouter(
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -300,6 +300,32 @@ type OrderAuthorization struct {
|
||||
RevokedAt *time.Time
|
||||
FailureCode *string
|
||||
FailureMessage *string
|
||||
CommandSHA256 *string
|
||||
DeliveryAttemptCount int
|
||||
LastDeliveredAt *time.Time
|
||||
}
|
||||
|
||||
type DeviceOrderCommand struct {
|
||||
ID string
|
||||
SchemaVersion int
|
||||
Type string
|
||||
AuthorizationVersion int
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
TaskContentSHA256 string
|
||||
OriginalSKU string
|
||||
Quantity int
|
||||
CandidateKey string
|
||||
ObservedOrdinal int
|
||||
CandidateTitle string
|
||||
CandidateSKUText string
|
||||
CandidatePriceText string
|
||||
CardSignature string
|
||||
DetailSignature string
|
||||
DetailEvidenceSHA256 string
|
||||
SpecificationEvidenceSHA256 string
|
||||
CommandSHA256 string
|
||||
AuthorizationStatus OrderAuthorizationStatus
|
||||
}
|
||||
|
||||
type ExecutionReport struct {
|
||||
|
||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("initial Up() error = %v", err)
|
||||
} else if applied != 8 {
|
||||
t.Fatalf("initial Up() applied = %d, want 8", applied)
|
||||
} else if applied != 9 {
|
||||
t.Fatalf("initial Up() applied = %d, want 9", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v9) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v8) error = %v", err)
|
||||
@@ -53,9 +56,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v8) over historical data error = %v", err)
|
||||
} else if applied != 4 {
|
||||
t.Fatalf("Up(v5-v8) applied = %d, want 4", applied)
|
||||
t.Fatalf("Up(v5-v9) over historical data error = %v", err)
|
||||
} else if applied != 5 {
|
||||
t.Fatalf("Up(v5-v9) applied = %d, want 5", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v9) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -85,9 +93,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v8) error = %v", err)
|
||||
} else if applied != 5 {
|
||||
t.Fatalf("final Up(v4-v8) applied = %d, want 5", applied)
|
||||
t.Fatalf("final Up(v4-v9) error = %v", err)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("final Up(v4-v9) applied = %d, want 6", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -323,6 +331,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v9) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v8) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 8 {
|
||||
t.Fatalf("Up() applied = %d, want 8", applied)
|
||||
if applied != 9 {
|
||||
t.Fatalf("Up() applied = %d, want 9", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
@@ -39,6 +39,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -60,7 +61,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: false,
|
||||
8: true,
|
||||
9: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -79,6 +81,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(v9) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v8) error = %v", err)
|
||||
}
|
||||
@@ -414,9 +417,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-v8) error = %v", err)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("Up(v3-v8) applied = %d, want 6", applied)
|
||||
t.Fatalf("Up(v3-v9) error = %v", err)
|
||||
} else if applied != 7 {
|
||||
t.Fatalf("Up(v3-v9) applied = %d, want 7", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func (s *Store) PullDeviceOrderCommand(
|
||||
ctx context.Context,
|
||||
write usecase.PullDeviceOrderCommandWrite,
|
||||
) (*domain.DeviceOrderCommand, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := validateDeviceOrderCommandClaim(
|
||||
ctx,
|
||||
tx,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizationID, observedOrdinal, candidateTitle, err :=
|
||||
findActiveDeviceOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
authorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authorization.UserID != write.UserID ||
|
||||
authorization.DeviceID != write.DeviceID ||
|
||||
authorization.ClaimGeneration != write.ClaimGeneration {
|
||||
return nil, usecase.ErrExecutionMismatch
|
||||
}
|
||||
command := usecase.NewDeviceOrderCommand(
|
||||
authorization,
|
||||
observedOrdinal,
|
||||
candidateTitle,
|
||||
)
|
||||
commandHash, err := usecase.DeviceOrderCommandSHA256(command)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if authorization.CommandSHA256 != nil &&
|
||||
*authorization.CommandSHA256 != commandHash {
|
||||
return nil, usecase.ErrRepositoryInvariant
|
||||
}
|
||||
switch authorization.Status {
|
||||
case domain.OrderAuthorizationPendingDelivery:
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_authorizations
|
||||
SET status = 'DELIVERED',
|
||||
command_sha256 = ?,
|
||||
delivered_at = ?,
|
||||
last_delivered_at = ?,
|
||||
delivery_attempt_count = delivery_attempt_count + 1
|
||||
WHERE id = ?
|
||||
AND status = 'PENDING_DELIVERY'
|
||||
AND command_sha256 IS NULL`,
|
||||
commandHash,
|
||||
formatTimestamp(write.Now),
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := insertTaskEvent(ctx, tx, write.DeliveredEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command.AuthorizationStatus = domain.OrderAuthorizationDelivered
|
||||
case domain.OrderAuthorizationDelivered,
|
||||
domain.OrderAuthorizationAcknowledged:
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_authorizations
|
||||
SET last_delivered_at = ?,
|
||||
delivery_attempt_count = delivery_attempt_count + 1
|
||||
WHERE id = ?
|
||||
AND status IN ('DELIVERED', 'ACKNOWLEDGED')
|
||||
AND command_sha256 = ?`,
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
commandHash,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, usecase.ErrTaskStateConflict
|
||||
}
|
||||
default:
|
||||
return nil, usecase.ErrTaskStateConflict
|
||||
}
|
||||
command.CommandSHA256 = commandHash
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
return &command, nil
|
||||
}
|
||||
|
||||
func (s *Store) AcknowledgeDeviceOrderCommand(
|
||||
ctx context.Context,
|
||||
write usecase.AcknowledgeDeviceOrderCommandWrite,
|
||||
) (domain.OrderAuthorization, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
record, found, err := getDeviceOrderCommandAckRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
write.IdempotencyKey,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if found {
|
||||
if record.RequestSHA256 != write.RequestSHA256 ||
|
||||
record.TaskID != write.TaskID ||
|
||||
record.AuthorizationID != write.AuthorizationID {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrIdempotencyConflict
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
record.AuthorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
return authorization, true, nil
|
||||
}
|
||||
if err := validateDeviceOrderCommandClaim(
|
||||
ctx,
|
||||
tx,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
write.AuthorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if authorization.TaskID != write.TaskID ||
|
||||
authorization.ExecutionID != write.ExecutionID ||
|
||||
authorization.UserID != write.UserID ||
|
||||
authorization.DeviceID != write.DeviceID ||
|
||||
authorization.ClaimGeneration != write.ClaimGeneration {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrExecutionMismatch
|
||||
}
|
||||
if authorization.CommandSHA256 == nil ||
|
||||
*authorization.CommandSHA256 != write.CommandSHA256 {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrTaskStateConflict
|
||||
}
|
||||
switch authorization.Status {
|
||||
case domain.OrderAuthorizationDelivered:
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_authorizations
|
||||
SET status = 'ACKNOWLEDGED',
|
||||
acknowledged_at = ?
|
||||
WHERE id = ?
|
||||
AND status = 'DELIVERED'
|
||||
AND command_sha256 = ?`,
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
write.CommandSHA256,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := insertTaskEvent(
|
||||
ctx,
|
||||
tx,
|
||||
write.AcknowledgedEvent,
|
||||
); err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
case domain.OrderAuthorizationAcknowledged:
|
||||
default:
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrTaskStateConflict
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO device_order_command_ack_requests (
|
||||
device_id, idempotency_key, request_sha256,
|
||||
task_id, authorization_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
write.DeviceID,
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
write.AuthorizationID,
|
||||
formatTimestamp(write.Now),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
authorization, err = getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
authorization.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||
}
|
||||
return authorization, false, nil
|
||||
}
|
||||
|
||||
func validateDeviceOrderCommandClaim(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
userID string,
|
||||
deviceID string,
|
||||
taskID string,
|
||||
executionID string,
|
||||
claimGeneration int64,
|
||||
claimTokenHash string,
|
||||
now time.Time,
|
||||
) error {
|
||||
task, err := getClaimProtectedTask(ctx, tx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateClaim(
|
||||
task,
|
||||
userID,
|
||||
deviceID,
|
||||
claimGeneration,
|
||||
claimTokenHash,
|
||||
now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != domain.TaskStatusWaitingConfirmation ||
|
||||
task.CancelRequestedAt != nil {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
execution, err := getExecutionByID(ctx, tx, executionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if execution.TaskID != taskID ||
|
||||
execution.UserID != userID ||
|
||||
execution.DeviceID != deviceID ||
|
||||
execution.ClaimGeneration != claimGeneration ||
|
||||
execution.FinishedAt != nil {
|
||||
return usecase.ErrExecutionMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type deviceOrderCommandAckRequest struct {
|
||||
RequestSHA256 string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
}
|
||||
|
||||
func getDeviceOrderCommandAckRequest(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
deviceID string,
|
||||
idempotencyKey string,
|
||||
) (deviceOrderCommandAckRequest, bool, error) {
|
||||
var record deviceOrderCommandAckRequest
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT request_sha256, task_id, authorization_id
|
||||
FROM device_order_command_ack_requests
|
||||
WHERE device_id = ? AND idempotency_key = ?`,
|
||||
deviceID,
|
||||
idempotencyKey,
|
||||
).Scan(
|
||||
&record.RequestSHA256,
|
||||
&record.TaskID,
|
||||
&record.AuthorizationID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return deviceOrderCommandAckRequest{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return deviceOrderCommandAckRequest{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func findActiveDeviceOrderAuthorization(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
taskID string,
|
||||
executionID string,
|
||||
) (string, int, string, error) {
|
||||
var authorizationID, candidateTitle string
|
||||
var observedOrdinal int
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
oa.id,
|
||||
coi.candidate_ordinal,
|
||||
co.title
|
||||
FROM order_authorizations oa
|
||||
JOIN candidate_observation_identities coi
|
||||
ON coi.candidate_key = oa.candidate_key
|
||||
AND coi.execution_id = oa.execution_id
|
||||
JOIN candidate_observations co
|
||||
ON co.execution_id = coi.execution_id
|
||||
AND co.ordinal = coi.candidate_ordinal
|
||||
WHERE oa.task_id = ?
|
||||
AND oa.execution_id = ?
|
||||
AND oa.status IN (
|
||||
'PENDING_DELIVERY',
|
||||
'DELIVERED',
|
||||
'ACKNOWLEDGED'
|
||||
)
|
||||
ORDER BY oa.authorization_version DESC
|
||||
LIMIT 1`,
|
||||
taskID,
|
||||
executionID,
|
||||
).Scan(
|
||||
&authorizationID,
|
||||
&observedOrdinal,
|
||||
&candidateTitle,
|
||||
)
|
||||
return authorizationID, observedOrdinal, candidateTitle, err
|
||||
}
|
||||
@@ -595,6 +595,7 @@ func scanOrderAuthorization(
|
||||
var supersedes, deliveredAt, acknowledgedAt sql.NullString
|
||||
var executionStartedAt, consumedAt, failedAt, revokedAt sql.NullString
|
||||
var failureCode, failureMessage sql.NullString
|
||||
var commandSHA256, lastDeliveredAt sql.NullString
|
||||
var createdAt string
|
||||
err := scanner.Scan(
|
||||
&authorization.ID,
|
||||
@@ -629,6 +630,9 @@ func scanOrderAuthorization(
|
||||
&revokedAt,
|
||||
&failureCode,
|
||||
&failureMessage,
|
||||
&commandSHA256,
|
||||
&authorization.DeliveryAttemptCount,
|
||||
&lastDeliveredAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, err
|
||||
@@ -664,6 +668,13 @@ func scanOrderAuthorization(
|
||||
if failureMessage.Valid {
|
||||
authorization.FailureMessage = &failureMessage.String
|
||||
}
|
||||
if commandSHA256.Valid {
|
||||
authorization.CommandSHA256 = &commandSHA256.String
|
||||
}
|
||||
if authorization.LastDeliveredAt, err =
|
||||
parseNullableTimestamp(lastDeliveredAt); err != nil {
|
||||
return domain.OrderAuthorization{}, err
|
||||
}
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
@@ -676,7 +687,8 @@ const orderAuthorizationSelect = `SELECT
|
||||
specification_evidence_sha256, status,
|
||||
supersedes_authorization_id, created_by_user_id, created_at,
|
||||
delivered_at, acknowledged_at, execution_started_at, consumed_at,
|
||||
failed_at, revoked_at, failure_code, failure_message
|
||||
failed_at, revoked_at, failure_code, failure_message,
|
||||
command_sha256, delivery_attempt_count, last_delivered_at
|
||||
FROM order_authorizations`
|
||||
|
||||
const localAdminSubject = "local-admin"
|
||||
|
||||
@@ -484,6 +484,9 @@ func orderAuthorizationResponse(
|
||||
"revoked_at": formatOptionalTime(authorization.RevokedAt),
|
||||
"failure_code": authorization.FailureCode,
|
||||
"failure_message": authorization.FailureMessage,
|
||||
"command_sha256": authorization.CommandSHA256,
|
||||
"delivery_attempt_count": authorization.DeliveryAttemptCount,
|
||||
"last_delivered_at": formatOptionalTime(authorization.LastDeliveredAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -410,6 +410,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("device command migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order authorization migration down succeeded with retained data")
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ type DeviceServices struct {
|
||||
Lifecycle *usecase.LifecycleService
|
||||
Assets *usecase.AssetService
|
||||
Results *usecase.ExecutionResultService
|
||||
Commands *usecase.DeviceOrderCommandService
|
||||
}
|
||||
|
||||
func (services DeviceServices) validate() error {
|
||||
if services.Lifecycle == nil || services.Assets == nil || services.Results == nil {
|
||||
if services.Lifecycle == nil || services.Assets == nil ||
|
||||
services.Results == nil || services.Commands == nil {
|
||||
return errors.New("device services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -73,12 +75,125 @@ func NewDeviceRouteRegistrar(
|
||||
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
|
||||
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
|
||||
routes.POST("/api/v1/tasks/:id/human-reviews", handler.storeHumanReview)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/commands/next",
|
||||
handler.pullOrderCommand,
|
||||
)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/commands/:command_id/ack",
|
||||
handler.acknowledgeOrderCommand,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
||||
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) pullOrderCommand(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"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
command, err := handler.services.Commands.Pull(
|
||||
ctx.Request.Context(),
|
||||
usecase.PullDeviceOrderCommand{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: request.ExecutionID,
|
||||
ClaimGeneration: request.ClaimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
if command == nil {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, deviceOrderCommandResponse(*command))
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) acknowledgeOrderCommand(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"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
result, err := handler.services.Commands.Acknowledge(
|
||||
ctx.Request.Context(),
|
||||
usecase.AcknowledgeDeviceOrderCommand{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: request.ExecutionID,
|
||||
AuthorizationID: ctx.Param("command_id"),
|
||||
ClaimGeneration: request.ClaimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
CommandSHA256: request.CommandSHA256,
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"command_id": result.Authorization.ID,
|
||||
"status": result.Authorization.Status,
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func deviceOrderCommandResponse(command domain.DeviceOrderCommand) gin.H {
|
||||
return gin.H{
|
||||
"id": command.ID,
|
||||
"schema_version": command.SchemaVersion,
|
||||
"type": command.Type,
|
||||
"authorization_version": command.AuthorizationVersion,
|
||||
"task_id": command.TaskID,
|
||||
"execution_id": command.ExecutionID,
|
||||
"task_content_sha256": command.TaskContentSHA256,
|
||||
"original_sku": command.OriginalSKU,
|
||||
"quantity": command.Quantity,
|
||||
"candidate": gin.H{
|
||||
"candidate_key": command.CandidateKey,
|
||||
"observed_ordinal": command.ObservedOrdinal,
|
||||
"title": command.CandidateTitle,
|
||||
"sku_text": command.CandidateSKUText,
|
||||
"price_text": command.CandidatePriceText,
|
||||
"card_signature": command.CardSignature,
|
||||
"detail_signature": command.DetailSignature,
|
||||
"detail_evidence_sha256": command.DetailEvidenceSHA256,
|
||||
"specification_evidence_sha256": command.SpecificationEvidenceSHA256,
|
||||
},
|
||||
"command_sha256": command.CommandSHA256,
|
||||
"authorization_status": command.AuthorizationStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) referenceImage(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -713,9 +713,17 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("device command migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order workflow migration down succeeded with retained data")
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 1 {
|
||||
t.Fatalf("restored migrations = %d, want 1", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null},"order_submitted":false}`,
|
||||
@@ -784,6 +792,326 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t *testing.T,
|
||||
) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
taskID := fixture.createPendingTask(t)
|
||||
claim := fixture.claimNext(t, "order-command-claim", testOpaqueToken)
|
||||
requireDeviceStatus(t, claim, http.StatusOK)
|
||||
var claimed deviceLifecycleResponse
|
||||
decodeResponse(t, claim, &claimed)
|
||||
startPayload := fmt.Sprintf(
|
||||
`{"claim_generation":%d,"expected_version":%d}`,
|
||||
claimed.Task.ClaimGeneration,
|
||||
claimed.Task.Version,
|
||||
)
|
||||
start := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-start",
|
||||
})
|
||||
requireDeviceStatus(t, start, http.StatusOK)
|
||||
var started deviceLifecycleResponse
|
||||
decodeResponse(t, start, &started)
|
||||
candidateKey := seedDeviceOrderCommandCandidate(
|
||||
t,
|
||||
fixture,
|
||||
taskID,
|
||||
started.Execution.ID,
|
||||
)
|
||||
detail, err := fixture.tasks.Get(
|
||||
context.Background(),
|
||||
localAdminSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get waiting task detail: %v", err)
|
||||
}
|
||||
authorizations, err := usecase.NewOrderAuthorizationService(
|
||||
fixture.store,
|
||||
usecase.SystemClock{},
|
||||
usecase.UUIDGenerator{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOrderAuthorizationService() error = %v", err)
|
||||
}
|
||||
created, err := authorizations.Create(
|
||||
context.Background(),
|
||||
usecase.CreateOrderAuthorizationCommand{
|
||||
ActorUserID: deviceTestAdminID,
|
||||
TaskID: taskID,
|
||||
IdempotencyKey: "order-command-authorization",
|
||||
ExecutionID: started.Execution.ID,
|
||||
TaskContentSHA256: usecase.TaskContentSHA256(detail.Task),
|
||||
ExpectedTaskVersion: detail.Task.Version,
|
||||
CandidateKey: candidateKey,
|
||||
ReasonSchemaVersion: 1,
|
||||
PrimaryReasonCode: "SELECTED_BEST_MATCH",
|
||||
Items: []usecase.OrderAuthorizationItemInput{{
|
||||
CandidateKey: candidateKey,
|
||||
Label: "ACCEPT",
|
||||
PrimaryReasonCode: "SKU_MATCH",
|
||||
ReasonCodes: []string{"SKU_MATCH", "IMAGE_MATCH"},
|
||||
}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create order authorization: %v", err)
|
||||
}
|
||||
pullPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
)
|
||||
pull := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
})
|
||||
requireDeviceStatus(t, pull, http.StatusOK)
|
||||
var command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
AuthorizationStatus string `json:"authorization_status"`
|
||||
Candidate struct {
|
||||
Key string `json:"candidate_key"`
|
||||
ObservedOrdinal int `json:"observed_ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
DetailEvidence string `json:"detail_evidence_sha256"`
|
||||
SpecificationSHA string `json:"specification_evidence_sha256"`
|
||||
} `json:"candidate"`
|
||||
}
|
||||
decodeResponse(t, pull, &command)
|
||||
if command.ID != created.Authorization.ID ||
|
||||
command.Type != "CREATE_PENDING_ORDER" ||
|
||||
command.SchemaVersion != 1 ||
|
||||
command.TaskID != taskID ||
|
||||
command.ExecutionID != started.Execution.ID ||
|
||||
command.Quantity != 2 ||
|
||||
len(command.CommandSHA256) != 64 ||
|
||||
command.AuthorizationStatus != "DELIVERED" ||
|
||||
command.Candidate.Key != candidateKey ||
|
||||
command.Candidate.ObservedOrdinal != 1 ||
|
||||
command.Candidate.Title != "设备命令候选" ||
|
||||
command.Candidate.SKUText != "TEST-SKU-COMMAND" {
|
||||
t.Fatalf("order command = %+v", command)
|
||||
}
|
||||
replayedPull := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
})
|
||||
requireDeviceStatus(t, replayedPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
replayedPull.Body.String(),
|
||||
`"command_sha256":"`+command.CommandSHA256+`"`,
|
||||
) {
|
||||
t.Fatalf("replayed command = %s", replayedPull.Body.String())
|
||||
}
|
||||
badAckPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
strings.Repeat("0", 64),
|
||||
)
|
||||
badAck := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(badAckPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack-bad",
|
||||
})
|
||||
requireDeviceStatus(t, badAck, http.StatusConflict)
|
||||
ackPayload := strings.Replace(
|
||||
badAckPayload,
|
||||
strings.Repeat("0", 64),
|
||||
command.CommandSHA256,
|
||||
1,
|
||||
)
|
||||
ack := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(ackPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack",
|
||||
})
|
||||
requireDeviceStatus(t, ack, http.StatusOK)
|
||||
if !strings.Contains(ack.Body.String(), `"status":"ACKNOWLEDGED"`) {
|
||||
t.Fatalf("ack response = %s", ack.Body.String())
|
||||
}
|
||||
ackReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(ackPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack",
|
||||
})
|
||||
requireDeviceStatus(t, ackReplay, http.StatusOK)
|
||||
if !strings.Contains(ackReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("ack replay = %s", ackReplay.Body.String())
|
||||
}
|
||||
acknowledgedPull := performDeviceRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/next",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(pullPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
},
|
||||
)
|
||||
requireDeviceStatus(t, acknowledgedPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
acknowledgedPull.Body.String(),
|
||||
`"authorization_status":"ACKNOWLEDGED"`,
|
||||
) {
|
||||
t.Fatalf("acknowledged pull = %s", acknowledgedPull.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents int
|
||||
for eventType, target := range map[string]*int{
|
||||
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
||||
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
||||
} {
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM task_events
|
||||
WHERE task_id = ? AND event_type = ?`,
|
||||
taskID,
|
||||
eventType,
|
||||
).Scan(target); err != nil {
|
||||
t.Fatalf("count %s events: %v", eventType, err)
|
||||
}
|
||||
}
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 {
|
||||
t.Fatalf(
|
||||
"delivery/ack events = %d/%d",
|
||||
deliveredEvents,
|
||||
acknowledgedEvents,
|
||||
)
|
||||
}
|
||||
runner, err := migration.New(fixture.db)
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("device command migration down succeeded with command data")
|
||||
}
|
||||
}
|
||||
|
||||
func seedDeviceOrderCommandCandidate(
|
||||
t *testing.T,
|
||||
fixture *deviceHTTPFixture,
|
||||
taskID string,
|
||||
executionID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
detail, err := fixture.tasks.Get(
|
||||
context.Background(),
|
||||
localAdminSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get running task detail: %v", err)
|
||||
}
|
||||
taskHash := usecase.TaskContentSHA256(detail.Task)
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
candidateKey := strings.Repeat("7", 64)
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_search_runs (
|
||||
execution_id, task_id, task_content_sha256, execution_mode,
|
||||
search_query, started_at, received_at, observation_count,
|
||||
collection_complete, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, 'MANUAL_FIRST', 'PDD_IMAGE_SEARCH', ?, ?, 1, 1, 0)`,
|
||||
executionID,
|
||||
taskID,
|
||||
taskHash,
|
||||
now,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command search run: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_observations (
|
||||
execution_id, task_id, ordinal, title, sku_text, price_text,
|
||||
product_url, image_url, evidence_asset_ids_json,
|
||||
collection_status, observed_at
|
||||
) VALUES (?, ?, 1, '设备命令候选', 'TEST-SKU-COMMAND', '21.50',
|
||||
'', '', '[]', 'COMPLETE', ?)`,
|
||||
executionID,
|
||||
taskID,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command observation: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_observation_identities (
|
||||
candidate_key, execution_id, candidate_ordinal,
|
||||
card_signature, detail_signature, detail_evidence_sha256,
|
||||
specification_evidence_sha256, identity_version, created_at
|
||||
) VALUES (?, ?, 1, ?, ?, ?, ?, 1, ?)`,
|
||||
candidateKey,
|
||||
executionID,
|
||||
strings.Repeat("8", 64),
|
||||
strings.Repeat("9", 64),
|
||||
strings.Repeat("a", 64),
|
||||
strings.Repeat("b", 64),
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command identity: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`UPDATE purchase_tasks
|
||||
SET status = 'WAITING_CONFIRMATION',
|
||||
version = version + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND status = 'RUNNING'`,
|
||||
now,
|
||||
taskID,
|
||||
); err != nil {
|
||||
t.Fatalf("move order command task to waiting: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`UPDATE task_executions
|
||||
SET current_step = 'WAITING_ADMIN_CONFIRMATION',
|
||||
last_heartbeat_at = ?
|
||||
WHERE id = ?`,
|
||||
now,
|
||||
executionID,
|
||||
); err != nil {
|
||||
t.Fatalf("move order command execution to waiting: %v", err)
|
||||
}
|
||||
return candidateKey
|
||||
}
|
||||
|
||||
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
@@ -1020,11 +1348,16 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
commands, err := usecase.NewDeviceOrderCommandService(store, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
deviceOrderCommandSchemaVersion = 1
|
||||
deviceOrderCommandType = "CREATE_PENDING_ORDER"
|
||||
)
|
||||
|
||||
type PullDeviceOrderCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
}
|
||||
|
||||
type PullDeviceOrderCommandWrite struct {
|
||||
PullDeviceOrderCommand
|
||||
ClaimTokenHash string
|
||||
Now time.Time
|
||||
DeliveredEvent domain.TaskEvent
|
||||
}
|
||||
|
||||
type AcknowledgeDeviceOrderCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
AuthorizationID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
CommandSHA256 string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AcknowledgeDeviceOrderCommandWrite struct {
|
||||
AcknowledgeDeviceOrderCommand
|
||||
ClaimTokenHash string
|
||||
RequestSHA256 string
|
||||
Now time.Time
|
||||
AcknowledgedEvent domain.TaskEvent
|
||||
}
|
||||
|
||||
type AcknowledgeDeviceOrderCommandResult struct {
|
||||
Authorization domain.OrderAuthorization
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type DeviceOrderCommandRepository interface {
|
||||
PullDeviceOrderCommand(
|
||||
context.Context,
|
||||
PullDeviceOrderCommandWrite,
|
||||
) (*domain.DeviceOrderCommand, error)
|
||||
AcknowledgeDeviceOrderCommand(
|
||||
context.Context,
|
||||
AcknowledgeDeviceOrderCommandWrite,
|
||||
) (domain.OrderAuthorization, bool, error)
|
||||
}
|
||||
|
||||
type DeviceOrderCommandService struct {
|
||||
repository DeviceOrderCommandRepository
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
func NewDeviceOrderCommandService(
|
||||
repository DeviceOrderCommandRepository,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*DeviceOrderCommandService, error) {
|
||||
if repository == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("device order command service dependencies are required")
|
||||
}
|
||||
return &DeviceOrderCommandService{
|
||||
repository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *DeviceOrderCommandService) Pull(
|
||||
ctx context.Context,
|
||||
command PullDeviceOrderCommand,
|
||||
) (*domain.DeviceOrderCommand, error) {
|
||||
command = normalizePullDeviceOrderCommand(command)
|
||||
fields := lifecycleClaimFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
)
|
||||
if !isUUID(command.ExecutionID) {
|
||||
fields["execution_id"] = "must be a UUID"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return nil, invalidError(
|
||||
"ORDER_COMMAND_PULL_INVALID",
|
||||
"order command pull request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
eventID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return nil, internalLifecycleFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
userID, deviceID := command.UserID, command.DeviceID
|
||||
result, err := service.repository.PullDeviceOrderCommand(
|
||||
ctx,
|
||||
PullDeviceOrderCommandWrite{
|
||||
PullDeviceOrderCommand: command,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
Now: now,
|
||||
DeliveredEvent: domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &userID,
|
||||
ActorDeviceID: &deviceID,
|
||||
Type: "ORDER_AUTHORIZATION_DELIVERED",
|
||||
Message: "order authorization delivered to device",
|
||||
OccurredAt: now,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *DeviceOrderCommandService) Acknowledge(
|
||||
ctx context.Context,
|
||||
command AcknowledgeDeviceOrderCommand,
|
||||
) (AcknowledgeDeviceOrderCommandResult, error) {
|
||||
command = normalizeAcknowledgeDeviceOrderCommand(command)
|
||||
fields := lifecycleClaimFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
)
|
||||
if !isUUID(command.ExecutionID) {
|
||||
fields["execution_id"] = "must be a UUID"
|
||||
}
|
||||
if !isUUID(command.AuthorizationID) {
|
||||
fields["command_id"] = "must be a UUID"
|
||||
}
|
||||
if !sha256Pattern.MatchString(command.CommandSHA256) {
|
||||
fields["command_sha256"] = "must be lowercase SHA-256"
|
||||
}
|
||||
validateIdempotencyField(fields, command.IdempotencyKey)
|
||||
if len(fields) > 0 {
|
||||
return AcknowledgeDeviceOrderCommandResult{}, invalidError(
|
||||
"ORDER_COMMAND_ACK_INVALID",
|
||||
"order command acknowledgement is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
requestSHA256, err := lifecycleRequestHash(struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
AuthorizationID string `json:"authorization_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
ClaimTokenHash string `json:"claim_token_sha256"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
}{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
TaskID: command.TaskID,
|
||||
ExecutionID: command.ExecutionID,
|
||||
AuthorizationID: command.AuthorizationID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
CommandSHA256: command.CommandSHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return AcknowledgeDeviceOrderCommandResult{},
|
||||
internalLifecycleFailure(err)
|
||||
}
|
||||
eventID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return AcknowledgeDeviceOrderCommandResult{},
|
||||
internalLifecycleFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
userID, deviceID := command.UserID, command.DeviceID
|
||||
authorization, replayed, err :=
|
||||
service.repository.AcknowledgeDeviceOrderCommand(
|
||||
ctx,
|
||||
AcknowledgeDeviceOrderCommandWrite{
|
||||
AcknowledgeDeviceOrderCommand: command,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
RequestSHA256: requestSHA256,
|
||||
Now: now,
|
||||
AcknowledgedEvent: domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &userID,
|
||||
ActorDeviceID: &deviceID,
|
||||
Type: "ORDER_AUTHORIZATION_ACKNOWLEDGED",
|
||||
Message: "order authorization persisted by device",
|
||||
OccurredAt: now,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return AcknowledgeDeviceOrderCommandResult{},
|
||||
wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return AcknowledgeDeviceOrderCommandResult{
|
||||
Authorization: authorization,
|
||||
Replayed: replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func DeviceOrderCommandSHA256(
|
||||
command domain.DeviceOrderCommand,
|
||||
) (string, error) {
|
||||
payload := struct {
|
||||
ID string `json:"id"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Type string `json:"type"`
|
||||
AuthorizationVersion int `json:"authorization_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
TaskContentSHA256 string `json:"task_content_sha256"`
|
||||
OriginalSKU string `json:"original_sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
Candidate struct {
|
||||
CandidateKey string `json:"candidate_key"`
|
||||
ObservedOrdinal int `json:"observed_ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
PriceText string `json:"price_text"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
DetailEvidenceSHA256 string `json:"detail_evidence_sha256"`
|
||||
SpecificationEvidenceSHA256 string `json:"specification_evidence_sha256"`
|
||||
} `json:"candidate"`
|
||||
}{
|
||||
ID: command.ID,
|
||||
SchemaVersion: command.SchemaVersion,
|
||||
Type: command.Type,
|
||||
AuthorizationVersion: command.AuthorizationVersion,
|
||||
TaskID: command.TaskID,
|
||||
ExecutionID: command.ExecutionID,
|
||||
TaskContentSHA256: command.TaskContentSHA256,
|
||||
OriginalSKU: command.OriginalSKU,
|
||||
Quantity: command.Quantity,
|
||||
}
|
||||
payload.Candidate.CandidateKey = command.CandidateKey
|
||||
payload.Candidate.ObservedOrdinal = command.ObservedOrdinal
|
||||
payload.Candidate.Title = command.CandidateTitle
|
||||
payload.Candidate.SKUText = command.CandidateSKUText
|
||||
payload.Candidate.PriceText = command.CandidatePriceText
|
||||
payload.Candidate.CardSignature = command.CardSignature
|
||||
payload.Candidate.DetailSignature = command.DetailSignature
|
||||
payload.Candidate.DetailEvidenceSHA256 = command.DetailEvidenceSHA256
|
||||
payload.Candidate.SpecificationEvidenceSHA256 =
|
||||
command.SpecificationEvidenceSHA256
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func NewDeviceOrderCommand(
|
||||
authorization domain.OrderAuthorization,
|
||||
observedOrdinal int,
|
||||
candidateTitle string,
|
||||
) domain.DeviceOrderCommand {
|
||||
return domain.DeviceOrderCommand{
|
||||
ID: authorization.ID,
|
||||
SchemaVersion: deviceOrderCommandSchemaVersion,
|
||||
Type: deviceOrderCommandType,
|
||||
AuthorizationVersion: authorization.AuthorizationVersion,
|
||||
TaskID: authorization.TaskID,
|
||||
ExecutionID: authorization.ExecutionID,
|
||||
TaskContentSHA256: authorization.TaskContentSHA256,
|
||||
OriginalSKU: authorization.OriginalSKU,
|
||||
Quantity: authorization.Quantity,
|
||||
CandidateKey: authorization.CandidateKey,
|
||||
ObservedOrdinal: observedOrdinal,
|
||||
CandidateTitle: candidateTitle,
|
||||
CandidateSKUText: authorization.CandidateSKUText,
|
||||
CandidatePriceText: authorization.CandidatePriceText,
|
||||
CardSignature: authorization.CardSignature,
|
||||
DetailSignature: authorization.DetailSignature,
|
||||
DetailEvidenceSHA256: authorization.DetailEvidenceSHA256,
|
||||
SpecificationEvidenceSHA256: authorization.SpecificationEvidenceSHA256,
|
||||
AuthorizationStatus: authorization.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePullDeviceOrderCommand(
|
||||
command PullDeviceOrderCommand,
|
||||
) PullDeviceOrderCommand {
|
||||
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.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||
return command
|
||||
}
|
||||
|
||||
func normalizeAcknowledgeDeviceOrderCommand(
|
||||
command AcknowledgeDeviceOrderCommand,
|
||||
) AcknowledgeDeviceOrderCommand {
|
||||
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.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
|
||||
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeviceOrderCommandSHA256MatchesMobileContractVector(t *testing.T) {
|
||||
command := domain.DeviceOrderCommand{
|
||||
ID: "00000000-0000-4000-8000-000000000009",
|
||||
SchemaVersion: 1,
|
||||
Type: "CREATE_PENDING_ORDER",
|
||||
AuthorizationVersion: 3,
|
||||
TaskID: "00000000-0000-4000-8000-000000000001",
|
||||
ExecutionID: "00000000-0000-4000-8000-000000000002",
|
||||
TaskContentSHA256: repeatForCommandTest("a"),
|
||||
OriginalSKU: "红色 M",
|
||||
Quantity: 2,
|
||||
CandidateKey: "candidate-key-1",
|
||||
ObservedOrdinal: 1,
|
||||
CandidateTitle: "红色连衣裙",
|
||||
CandidateSKUText: "红色 / M",
|
||||
CandidatePriceText: "19.90 CNY",
|
||||
CardSignature: repeatForCommandTest("b"),
|
||||
DetailSignature: repeatForCommandTest("c"),
|
||||
DetailEvidenceSHA256: repeatForCommandTest("d"),
|
||||
SpecificationEvidenceSHA256: repeatForCommandTest("e"),
|
||||
}
|
||||
|
||||
got, err := DeviceOrderCommandSHA256(command)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceOrderCommandSHA256() error = %v", err)
|
||||
}
|
||||
const want = "cf3cd802366d4de49d51940551385f7e5bd3283ef18cbf245a227c679d1058f3"
|
||||
if got != want {
|
||||
t.Fatalf("DeviceOrderCommandSHA256() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatForCommandTest(value string) string {
|
||||
result := ""
|
||||
for len(result) < 64 {
|
||||
result += value
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE order_authorizations
|
||||
ADD COLUMN command_sha256 TEXT
|
||||
CHECK (
|
||||
command_sha256 IS NULL
|
||||
OR (
|
||||
length(command_sha256) = 64
|
||||
AND command_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE order_authorizations
|
||||
ADD COLUMN delivery_attempt_count INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (delivery_attempt_count >= 0);
|
||||
|
||||
ALTER TABLE order_authorizations
|
||||
ADD COLUMN last_delivered_at TEXT;
|
||||
|
||||
CREATE TABLE device_order_command_ack_requests (
|
||||
device_id TEXT NOT NULL
|
||||
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
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]*'
|
||||
),
|
||||
task_id TEXT NOT NULL
|
||||
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
authorization_id TEXT NOT NULL
|
||||
REFERENCES order_authorizations(id)
|
||||
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (device_id, idempotency_key)
|
||||
);
|
||||
|
||||
ALTER TABLE task_events RENAME TO task_events_v8;
|
||||
|
||||
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',
|
||||
'CANDIDATES_READY',
|
||||
'ORDER_AUTHORIZATION_CREATED',
|
||||
'ORDER_AUTHORIZATION_DELIVERED',
|
||||
'ORDER_AUTHORIZATION_ACKNOWLEDGED'
|
||||
)
|
||||
),
|
||||
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, actor_device_id
|
||||
FROM task_events_v8;
|
||||
|
||||
DROP TABLE task_events_v8;
|
||||
|
||||
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 device_order_commands_v9_down_guard (
|
||||
allowed INTEGER NOT NULL
|
||||
CHECK (allowed = 1)
|
||||
);
|
||||
|
||||
INSERT INTO device_order_commands_v9_down_guard (allowed)
|
||||
SELECT CASE
|
||||
WHEN EXISTS (SELECT 1 FROM device_order_command_ack_requests)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM order_authorizations
|
||||
WHERE command_sha256 IS NOT NULL
|
||||
OR delivery_attempt_count > 0
|
||||
OR last_delivered_at IS NOT NULL
|
||||
OR status IN ('DELIVERED', 'ACKNOWLEDGED')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM task_events
|
||||
WHERE event_type IN (
|
||||
'ORDER_AUTHORIZATION_DELIVERED',
|
||||
'ORDER_AUTHORIZATION_ACKNOWLEDGED'
|
||||
)
|
||||
)
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END;
|
||||
|
||||
DROP TABLE device_order_commands_v9_down_guard;
|
||||
DROP TABLE device_order_command_ack_requests;
|
||||
|
||||
ALTER TABLE task_events RENAME TO task_events_v9;
|
||||
|
||||
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',
|
||||
'CANDIDATES_READY',
|
||||
'ORDER_AUTHORIZATION_CREATED'
|
||||
)
|
||||
),
|
||||
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, actor_device_id
|
||||
FROM task_events_v9;
|
||||
|
||||
DROP TABLE task_events_v9;
|
||||
|
||||
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);
|
||||
|
||||
ALTER TABLE order_authorizations DROP COLUMN last_delivered_at;
|
||||
ALTER TABLE order_authorizations DROP COLUMN delivery_attempt_count;
|
||||
ALTER TABLE order_authorizations DROP COLUMN command_sha256;
|
||||
@@ -58,7 +58,8 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207
|
||||
证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控
|
||||
规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
|
||||
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
|
||||
Admin 候选确认与不可变待投递授权也已完成;当前实现 T-216 设备命令投递与确认。
|
||||
Admin 候选确认、不可变待投递授权及 T-216 设备命令可靠投递与确认均已完成;下一项
|
||||
是 T-217 已授权商品重新定位与订单 dry-run。
|
||||
不得直接把候选链接或列表 ordinal 当成授权。
|
||||
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
||||
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
|
||||
| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | T-208 已实现 |
|
||||
| IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 |
|
||||
| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215 已完成;T-216 进行中 |
|
||||
| IX-011 | US-010 | Admin 候选授权/App 订单执行 | 选择候选并授权、设备领取执行 | 创建一笔可对账的待付款订单并提醒人工付款 | P0 | T-215/T-216 已完成;T-217 待实现 |
|
||||
|
||||
## IX-001 管理 Web 登录
|
||||
|
||||
|
||||
+16
-12
@@ -5,7 +5,7 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-28
|
||||
- 阶段:T-216 设备下单命令投递、确认与恢复进行中
|
||||
- 阶段:T-216 设备下单命令投递、确认与恢复已完成,待开始 T-217
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、
|
||||
T-208、T-210、T-211、T-212、T-213、T-214 均已纳入 Git 历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
@@ -17,10 +17,10 @@
|
||||
- 本机 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` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:T-215 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过;
|
||||
Debug APK `1.4.12 (17)` 已安装并启动于 PKG110
|
||||
- 后端测试:T-215 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||
v8 migration 往返、带授权数据的降级保护和 Admin 授权接口集成测试均通过
|
||||
- 测试:T-216 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||
Debug APK `1.4.13 (18)` 已构建但未覆盖安装,PKG110 当前仍为 `1.4.12 (17)`
|
||||
- 后端测试:T-216 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||
v9 migration 往返、带命令数据的降级保护及 pull/ACK HTTP 集成测试均通过
|
||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||
@@ -68,6 +68,10 @@
|
||||
candidate key 选择并为全部候选填写结构化理由,服务端在同一事务追加人工 review、
|
||||
不可变授权快照、事件和幂等记录。领取前可按版本改选,已投递后禁改;页面明确仅
|
||||
授权创建设备端待付款订单,不授权付款。
|
||||
- T-216 设备命令:App 在 heartbeat 后主动拉取同一条不可变授权,严格校验跨端
|
||||
canonical hash、任务内容和候选四组指纹,先写 Keystore-backed 状态再幂等 ACK。
|
||||
网络响应丢失和进程重启复用同一 ACK key;后台候选不再允许手机本地选择或重复
|
||||
采集。本任务只同步授权,未打开拼多多或执行下单。
|
||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||
@@ -81,7 +85,8 @@
|
||||
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
|
||||
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
|
||||
RUNNING 不自动重新分配
|
||||
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.12 (17)`;拼多多
|
||||
- 测试设备:OnePlus PKG110,Android 16/API 36;已安装肉包 `1.4.12 (17)`,
|
||||
待安装构建为 `1.4.13 (18)`;拼多多
|
||||
`8.17.0 (81700)`
|
||||
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
|
||||
真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权
|
||||
@@ -96,7 +101,7 @@
|
||||
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
||||
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
||||
- 标准验证路径:`.\init.ps1`
|
||||
- 当前 blocker:T-215 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
|
||||
- 当前 blocker:T-216 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
|
||||
和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+
|
||||
|
||||
## 当前目录
|
||||
@@ -128,7 +133,7 @@
|
||||
| `docs/tasks/T-208.md` | DONE | 归一化候选决策数据并增加结构化人工 review |
|
||||
| `docs/tasks/T-214.md` | DONE | 建立 execution-scoped candidate key 与设备采集指纹 |
|
||||
| `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 |
|
||||
| `docs/tasks/T-216.md` | DOING | App 主动拉取并先加密落盘再确认同一条下单命令 |
|
||||
| `docs/tasks/T-216.md` | DONE | App 主动拉取并先加密落盘再确认同一条下单命令 |
|
||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||
@@ -139,10 +144,9 @@
|
||||
|
||||
## 任务摘要
|
||||
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-215。
|
||||
- 正在进行:T-216 设备下单命令投递、确认与恢复。
|
||||
- 下一步:依次实现设备命令、订单 dry-run、单次提交对账和
|
||||
付款提醒。
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-216。
|
||||
- 正在进行:无。
|
||||
- 下一步:依次实现订单 dry-run、单次提交对账和付款提醒。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+18
-8
@@ -4,7 +4,7 @@ title: 设备下单命令投递、确认与恢复
|
||||
phase: 2
|
||||
deps:
|
||||
- T-215
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-07-28
|
||||
context_ref: 827afc7
|
||||
work_branch: null
|
||||
@@ -127,13 +127,13 @@ v9:
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [ ] 只有原 user/device/execution/generation/token 且租约有效时能拉取命令。
|
||||
- [ ] 首次拉取原子进入 `DELIVERED`;网络重放返回相同 command/hash 且不重复事件。
|
||||
- [ ] App 严格校验并先加密持久化,存储失败不 ACK,重启可恢复同一命令。
|
||||
- [ ] ACK 幂等进入 `ACKNOWLEDGED`,错误 hash/command/设备和并发请求被拒绝。
|
||||
- [ ] 后台候选流程等待 Admin,不再允许手机本地选择并提前完成任务。
|
||||
- [ ] App 等待/已授权状态可见,但不打开拼多多、不选择 SKU/数量、不提交订单。
|
||||
- [ ] v9 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。
|
||||
- [x] 只有原 user/device/execution/generation/token 且租约有效时能拉取命令。
|
||||
- [x] 首次拉取原子进入 `DELIVERED`;网络重放返回相同 command/hash 且不重复事件。
|
||||
- [x] App 严格校验并先加密持久化,存储失败不 ACK,重启可恢复同一命令。
|
||||
- [x] ACK 幂等进入 `ACKNOWLEDGED`,错误 hash/command/设备和并发请求被拒绝。
|
||||
- [x] 后台候选流程等待 Admin,不再允许手机本地选择并提前完成任务。
|
||||
- [x] App 等待/已授权状态可见,但不打开拼多多、不选择 SKU/数量、不提交订单。
|
||||
- [x] v9 migration、Go test/race/vet、Android test/Debug/Release 和根验证通过。
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -147,3 +147,13 @@ v9:
|
||||
- 2026-07-28:T-215 实现提交 `827afc7` 后领取。复用现有 30 秒前台 heartbeat 和
|
||||
Keystore-backed 状态,选择 pull + durable-before-ACK,避免 Android 后台推送和
|
||||
HTTP 不确定结果造成命令丢失。
|
||||
- 2026-07-28:新增 v9、命令 pull/ACK API、同一授权重放和投递/确认审计事件。
|
||||
HTTP 集成覆盖错误 hash、响应丢失后的相同 key 重试、ACK 后恢复拉取、事件不重复
|
||||
及带数据禁止降级。
|
||||
- 2026-07-28:Android `1.4.13 (18)` 增加命令 canonical hash 校验、加密持久化、
|
||||
固定 ACK key、重启恢复和等待/已授权 UI。后台任务有候选后不再出现手机接受/
|
||||
拒绝入口,也禁止再次采集;空候选仍保留安全终态。
|
||||
- 2026-07-28:Go/Kotlin 固定哈希向量一致;Android 测试证明存储失败时 ACK 调用数
|
||||
为 0。`go test ./...`、`go test -race ./...`、`go vet ./...`、Android `test`、
|
||||
Debug/Release assemble 和根 `.\init.ps1` 全部通过。APK 已构建,未在本任务中
|
||||
覆盖安装或执行拼多多页面动作。
|
||||
|
||||
Reference in New Issue
Block a user