feat(t216): deliver recoverable device order commands

This commit is contained in:
QiuSW
2026-07-28 13:23:18 +08:00
parent 75fdc63db2
commit 2372ab2280
32 changed files with 2415 additions and 65 deletions
@@ -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)
@@ -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('"')
}
}
@@ -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)
}
}
@@ -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)
@@ -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,
@@ -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
@@ -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)
@@ -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,
@@ -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))
}
}
@@ -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)
)
}
}
@@ -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",