feat(t216): deliver recoverable device order commands
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user