feat(t207): audit local procurement results

This commit is contained in:
QiuSW
2026-07-27 12:23:05 +08:00
parent a00c1e3e17
commit 4533df7abc
37 changed files with 4004 additions and 115 deletions
@@ -50,6 +50,10 @@ import com.roubao.autopilot.vlm.RequirementExtractionResult
import com.roubao.autopilot.vlm.RequirementExtractor
import com.roubao.autopilot.vlm.RequirementProbeState
import com.roubao.autopilot.vlm.RequirementProviderEndpointPolicy
import com.roubao.autopilot.vlm.REQUIREMENT_PROMPT_VERSION
import com.roubao.autopilot.vlm.REQUIREMENT_SCHEMA_VERSION
import com.roubao.autopilot.vlm.CANDIDATE_EVALUATION_PROMPT_VERSION
import com.roubao.autopilot.vlm.CANDIDATE_EVALUATION_SCHEMA_VERSION
import com.roubao.autopilot.vlm.AndroidCandidateEvaluationVlmGateway
import com.roubao.autopilot.vlm.CandidateBatchConclusion
import com.roubao.autopilot.vlm.CandidateEvaluationFailureCode
@@ -78,7 +82,14 @@ import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
import com.roubao.autopilot.procurement.LoginInput
import com.roubao.autopilot.procurement.ExecutionCandidateBatchDraft
import com.roubao.autopilot.procurement.ExecutionCandidateDraft
import com.roubao.autopilot.procurement.ExecutionCandidateEvaluation
import com.roubao.autopilot.procurement.ExecutionEvidenceDraft
import com.roubao.autopilot.procurement.ExecutionMode
import com.roubao.autopilot.procurement.ExecutionProvenanceSnapshot
import com.roubao.autopilot.procurement.ProcurementRepository
import com.roubao.autopilot.task.RequirementProbeFixture
import com.roubao.autopilot.workflow.WorkflowReport
import com.roubao.autopilot.workflow.WorkflowRunner
import com.roubao.autopilot.workflow.WorkflowState
@@ -123,6 +134,8 @@ class MainActivity : ComponentActivity() {
private val candidateReviewBatch = mutableStateOf<CandidateReviewBatch?>(null)
private val candidateEvaluationFailureCode =
mutableStateOf<CandidateEvaluationFailureCode?>(null)
private val taskCandidateDrafts =
mutableStateOf<List<ExecutionCandidateDraft>>(emptyList())
private var searchProbeRunner: WorkflowRunner? = null
private var searchProbeJob: Job? = null
private var requirementProbeJob: Job? = null
@@ -354,9 +367,9 @@ class MainActivity : ComponentActivity() {
procurementRepository.claimNext(readiness)
}
},
onStart = {
onStart = { mode ->
lifecycleScope.launch {
procurementRepository.start()
startProcurementExecution(mode)
}
},
onRelease = {
@@ -411,8 +424,12 @@ class MainActivity : ComponentActivity() {
onStopCandidateEvaluation = {
stopCandidateEvaluation()
},
onAcceptCandidate = { acceptRecommendedCandidate() },
onRejectCandidates = { rejectCandidateReview() },
onAcceptCandidate = { reason ->
acceptRecommendedCandidate(reason)
},
onRejectCandidates = { reason ->
rejectCandidateReview(reason)
},
onStart = { startSearchProbe() },
onStop = { stopSearchProbe() }
)
@@ -502,6 +519,43 @@ class MainActivity : ComponentActivity() {
startActivity(launchIntent)
}
private suspend fun startProcurementExecution(mode: ExecutionMode) {
if (mode == ExecutionMode.MANUAL_FIRST) {
procurementRepository.start(mode)
return
}
val settings = settingsManager.settings.value
val provider = settings.currentProvider
if (!provider.supportsRequirementExtraction ||
settings.apiKey.isBlank() ||
settings.baseUrl.isBlank() ||
settings.model.isBlank() ||
!settingsManager.isSecureCredentialStorageAvailable ||
!RequirementProviderEndpointPolicy.isAllowed(
settings.baseUrl,
settings.apiKey
)
) {
Toast.makeText(
this,
"AI 辅助模式需要本机安全配置可用的 VLM",
Toast.LENGTH_SHORT
).show()
return
}
procurementRepository.start(
mode = mode,
provenance = ExecutionProvenanceSnapshot(
mode = mode,
providerId = provider.id,
model = settings.model,
promptVersion = REQUIREMENT_PROMPT_VERSION,
schemaVersion = REQUIREMENT_SCHEMA_VERSION,
referenceImageSha256 = ""
)
)
}
private fun startSearchProbe() {
val readiness = readinessChecker.snapshot()
readinessSnapshot.value = readiness
@@ -517,11 +571,22 @@ class MainActivity : ComponentActivity() {
return
}
val procurementTask = procurementRepository.currentProbeTask()
val procurementMode = procurementRepository.activeExecutionMode()
val boundRequirement = requirementExtraction.value?.takeIf {
requirementProbeState.value == RequirementProbeState.READY &&
!it.manualReviewRequired
}
val searchKeyword = boundRequirement?.searchQuery ?: SEARCH_PROBE_KEYWORD
if (procurementTask != null &&
procurementMode == ExecutionMode.AI_ASSISTED &&
boundRequirement == null
) {
Toast.makeText(this, "请先完成本地需求提取", Toast.LENGTH_SHORT).show()
return
}
val searchKeyword = boundRequirement?.searchQuery ?: procurementTask
?.let { manualSearchQuery(it.title, it.sku) }
?: SEARCH_PROBE_KEYWORD
val candidateAutomation = PinduoduoCandidateAutomation(
AndroidPinduoduoCandidateDriver(this)
)
@@ -567,6 +632,16 @@ class MainActivity : ComponentActivity() {
val report = runner.run(PinduoduoCandidateWorkflow.steps())
searchProbeReport.value = report
searchProbeState.value = report.state
if (report.state == WorkflowState.SUCCEEDED &&
procurementTask != null &&
procurementMode == ExecutionMode.MANUAL_FIRST
) {
queueManualProcurementCandidates(
procurementTask.title,
searchKeyword,
candidateEvidence.value
)
}
} finally {
stateCollector.cancel()
stepCollector.cancel()
@@ -594,6 +669,16 @@ class MainActivity : ComponentActivity() {
Toast.makeText(this, "请先停止候选评估", Toast.LENGTH_SHORT).show()
return
}
if (procurementRepository.currentProbeTask() != null &&
procurementRepository.activeExecutionMode() != ExecutionMode.AI_ASSISTED
) {
Toast.makeText(
this,
"当前后台任务为人工优先模式,不调用 VLM",
Toast.LENGTH_SHORT
).show()
return
}
candidateEvidence.value = emptyList()
candidateSearchKeyword.value = null
candidateRequirementSnapshot.value = null
@@ -640,7 +725,11 @@ class MainActivity : ComponentActivity() {
requirementFailureCode.value = null
requirementProbeJob = lifecycleScope.launch {
try {
val fixture = requirementProbeSource.loadFirst().getOrElse {
val fixture = procurementRepository.currentProbeTask()?.let { task ->
procurementRepository.currentReferenceImageBytes()?.let { image ->
RequirementProbeFixture(task, image)
}
} ?: requirementProbeSource.loadFirst().getOrElse {
setRequirementFailure(RequirementExtractionFailureCode.SOURCE_UNAVAILABLE)
return@launch
} ?: run {
@@ -809,6 +898,48 @@ class MainActivity : ComponentActivity() {
)
when (result) {
is CandidateEvaluationResult.Completed -> {
if (procurementRepository.activeExecutionMode() ==
ExecutionMode.AI_ASSISTED
) {
val drafts = result.batch.assessments.map { assessment ->
ExecutionCandidateDraft(
ordinal = assessment.ordinal,
title = "拼多多候选 ${assessment.ordinal}",
evidenceLocalIDs = emptyList(),
evaluation = ExecutionCandidateEvaluation(
decision = assessment.decision.name,
score = assessment.score,
matched = assessment.matched,
missingOrUncertain = assessment.missingOrUncertain,
rejectionReasons = assessment.rejectionReasons,
confidence = assessment.confidence
)
)
}
taskCandidateDrafts.value = procurementRepository.queueCandidateBatch(
ExecutionCandidateBatchDraft(
mode = ExecutionMode.AI_ASSISTED,
searchQuery = requirement.searchQuery,
provenance = ExecutionProvenanceSnapshot(
mode = ExecutionMode.AI_ASSISTED,
providerId = result.batch.providerId,
model = result.batch.model,
promptVersion = CANDIDATE_EVALUATION_PROMPT_VERSION,
schemaVersion = CANDIDATE_EVALUATION_SCHEMA_VERSION,
referenceImageSha256 =
result.batch.requirementReferenceImageSha256
),
candidates = drafts
),
validated.map { candidate ->
ExecutionEvidenceDraft(
ordinal = candidate.ordinal,
pngBytes = candidate.pngBytes,
sha256 = candidate.sha256
)
}
).orEmpty()
}
candidateReviewBatch.value = result.batch
candidateEvaluationState.value = when (
result.batch.conclusion
@@ -838,17 +969,90 @@ class MainActivity : ComponentActivity() {
candidateEvaluationJob?.cancel()
}
private fun acceptRecommendedCandidate() {
private suspend fun queueManualProcurementCandidates(
taskTitle: String,
searchQuery: String,
evidence: List<PinduoduoCandidateEvidence>
) {
val validated = candidateEvidenceSource.load(evidence).getOrNull() ?: return
taskCandidateDrafts.value = procurementRepository.queueCandidateBatch(
ExecutionCandidateBatchDraft(
mode = ExecutionMode.MANUAL_FIRST,
searchQuery = searchQuery,
provenance = null,
candidates = validated.map { candidate ->
ExecutionCandidateDraft(
ordinal = candidate.ordinal,
title = "$taskTitle 候选 ${candidate.ordinal}",
evidenceLocalIDs = emptyList()
)
}
),
validated.map { candidate ->
ExecutionEvidenceDraft(
ordinal = candidate.ordinal,
pngBytes = candidate.pngBytes,
sha256 = candidate.sha256
)
}
).orEmpty()
if (taskCandidateDrafts.value.isNotEmpty()) {
candidateEvaluationState.value = CandidateEvaluationState.MANUAL_REVIEW
}
}
private fun manualSearchQuery(title: String, sku: String): String =
listOf(title.trim(), sku.trim())
.filter { it.isNotBlank() }
.joinToString(" ")
.take(160)
private fun acceptRecommendedCandidate(operatorReason: String) {
candidateEvaluationState.value = CandidateHumanReviewPolicy.accept(
currentState = candidateEvaluationState.value,
batch = candidateReviewBatch.value
)
if (candidateEvaluationState.value != CandidateEvaluationState.HUMAN_ACCEPTED &&
taskCandidateDrafts.value.isNotEmpty()
) {
candidateEvaluationState.value = CandidateEvaluationState.HUMAN_ACCEPTED
}
if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_ACCEPTED) {
val recommendedOrdinal = candidateReviewBatch.value
?.recommendedCandidateOrdinal
val candidate = taskCandidateDrafts.value.firstOrNull {
it.ordinal == recommendedOrdinal
} ?: taskCandidateDrafts.value.firstOrNull()
if (candidate != null && procurementRepository.currentProbeTask() != null) {
lifecycleScope.launch {
procurementRepository.completeExecution(
outcome = "CANDIDATE_ACCEPTED",
operatorReason = operatorReason,
candidate = candidate
)
}
}
}
}
private fun rejectCandidateReview() {
private fun rejectCandidateReview(operatorReason: String) {
candidateEvaluationState.value = CandidateHumanReviewPolicy.reject(
candidateEvaluationState.value
)
if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_REJECTED &&
procurementRepository.currentProbeTask() != null
) {
lifecycleScope.launch {
procurementRepository.completeExecution(
outcome = if (taskCandidateDrafts.value.isEmpty()) {
"NO_MATCH"
} else {
"CANDIDATE_REJECTED"
},
operatorReason = operatorReason
)
}
}
}
private fun setCandidateEvaluationFailure(
@@ -127,32 +127,31 @@ class SettingsManager(context: Context) {
context.getSharedPreferences("baozi_settings", Context.MODE_PRIVATE)
// 加密存储(用于敏感数据如 API Key)
private val securePrefs: SharedPreferences by lazy {
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs: SharedPreferences? = try {
val masterKey = MasterKey.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"baozi_secure_settings",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
prefs
}
EncryptedSharedPreferences.create(
context.applicationContext,
"baozi_secure_settings",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
null
}
private val _settings = MutableStateFlow(loadSettings())
val settings: StateFlow<AppSettings> = _settings
private val _settings: MutableStateFlow<AppSettings>
val settings: StateFlow<AppSettings>
init {
// 迁移旧的明文 API Key 到加密存储
migrateApiKeyToSecureStorage()
_settings = MutableStateFlow(loadSettings())
settings = _settings
}
val isSecureCredentialStorageAvailable: Boolean
@@ -163,13 +162,19 @@ class SettingsManager(context: Context) {
*/
private fun migrateApiKeyToSecureStorage() {
val oldApiKey = prefs.getString("api_key", null)
if (!oldApiKey.isNullOrEmpty()) {
// 保存到加密存储
securePrefs.edit().putString("api_key", oldApiKey).apply()
// 删除旧的明文存储
prefs.edit().remove("api_key").apply()
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
if (oldApiKey.isNullOrEmpty()) return
val encrypted = securePrefs
if (encrypted == null || !encrypted.edit().putString("api_key", oldApiKey).commit()) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "API Key migration failed closed")
return
}
if (!prefs.edit().remove("api_key").commit()) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Plaintext API Key cleanup failed closed")
return
}
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
}
private fun loadSettings(): AppSettings {
@@ -191,7 +196,7 @@ class SettingsManager(context: Context) {
}
// 迁移旧数据(如果有)
val oldApiKey = securePrefs.getString("api_key", null)
val oldApiKey = securePrefs?.getString("api_key", null)
val oldModel = prefs.getString("model", null)
val oldBaseUrl = prefs.getString("base_url", null)
val oldCachedModels = prefs.getStringSet("cached_models", null)
@@ -205,24 +210,29 @@ class SettingsManager(context: Context) {
else -> "custom"
}
// 迁移到新格式
val migratedConfig = ProviderConfig(
apiKey = oldApiKey ?: "",
model = oldModel ?: "",
cachedModels = oldCachedModels?.toList() ?: emptyList(),
customBaseUrl = if (oldProviderId == "custom") oldBaseUrl ?: "" else ""
)
providerConfigs[oldProviderId] = migratedConfig
saveProviderConfig(oldProviderId, migratedConfig)
if (saveProviderConfig(oldProviderId, migratedConfig)) {
providerConfigs[oldProviderId] = migratedConfig
}
// 清除旧数据
securePrefs.edit().remove("api_key").apply()
prefs.edit()
val secureMigrationComplete = securePrefs?.edit()
?.remove("api_key")
?.commit() == true
val preferencesMigrationComplete = prefs.edit()
.remove("model")
.remove("base_url")
.remove("cached_models")
.putString("current_provider_id", oldProviderId)
.apply()
.commit()
if (!secureMigrationComplete || !preferencesMigrationComplete) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Legacy provider migration failed closed")
}
android.util.Log.d("SettingsManager", "Migrated old settings to provider: $oldProviderId")
}
@@ -244,7 +254,7 @@ class SettingsManager(context: Context) {
private fun loadProviderConfig(providerId: String): ProviderConfig {
val prefix = "provider_${providerId}_"
return ProviderConfig(
apiKey = securePrefs.getString("${prefix}api_key", "") ?: "",
apiKey = securePrefs?.getString("${prefix}api_key", "") ?: "",
model = prefs.getString("${prefix}model", "") ?: "",
cachedModels = prefs.getStringSet("${prefix}cached_models", emptySet())?.toList() ?: emptyList(),
customBaseUrl = prefs.getString("${prefix}custom_base_url", "") ?: ""
@@ -254,14 +264,30 @@ class SettingsManager(context: Context) {
/**
* 保存指定服务商的配置
*/
private fun saveProviderConfig(providerId: String, config: ProviderConfig) {
private fun saveProviderConfig(providerId: String, config: ProviderConfig): Boolean {
val prefix = "provider_${providerId}_"
securePrefs.edit().putString("${prefix}api_key", config.apiKey).apply()
prefs.edit()
if (config.apiKey.isNotEmpty()) {
val encrypted = securePrefs
if (encrypted == null ||
!encrypted.edit().putString("${prefix}api_key", config.apiKey).commit()
) {
secureCredentialStorageAvailable = false
android.util.Log.e("SettingsManager", "Refused API Key write without encryption")
return false
}
} else {
if (securePrefs != null &&
!securePrefs.edit().remove("${prefix}api_key").commit()
) {
secureCredentialStorageAvailable = false
return false
}
}
return prefs.edit()
.putString("${prefix}model", config.model)
.putStringSet("${prefix}cached_models", config.cachedModels.toSet())
.putString("${prefix}custom_base_url", config.customBaseUrl)
.apply()
.commit()
}
/**
@@ -272,7 +298,9 @@ class SettingsManager(context: Context) {
val currentConfig = _settings.value.currentConfig
val newConfig = update(currentConfig)
saveProviderConfig(currentId, newConfig)
if (!saveProviderConfig(currentId, newConfig)) {
return
}
val newConfigs = _settings.value.providerConfigs.toMutableMap()
newConfigs[currentId] = newConfig
@@ -0,0 +1,79 @@
package com.roubao.autopilot.procurement
import java.security.MessageDigest
data class ExecutionEvidenceDraft(
val ordinal: Int,
val pngBytes: ByteArray,
val sha256: String
)
data class ExecutionCandidateDraft(
val ordinal: Int,
val title: String,
val skuText: String = "",
val price: String = "",
val productUrl: String = "",
val imageUrl: String = "",
val evidenceLocalIDs: List<String>,
val evaluation: ExecutionCandidateEvaluation? = null
)
data class ExecutionCandidateEvaluation(
val decision: String,
val score: Double,
val matched: List<String>,
val missingOrUncertain: List<String>,
val rejectionReasons: List<String>,
val confidence: Double
)
data class ExecutionRecommendation(
val candidateOrdinal: Int,
val policyVersion: String,
val reasons: List<String>
)
data class ExecutionCandidateBatchDraft(
val mode: ExecutionMode,
val searchQuery: String,
val provenance: ExecutionProvenanceSnapshot?,
val candidates: List<ExecutionCandidateDraft>,
val recommendation: ExecutionRecommendation? = null
)
object ExecutionTaskHash {
fun sha256(task: RemotePurchaseTask): String {
val maxBudgetCents = task.maxBudget
?.let(::parseCnyCents)
?.toString()
.orEmpty()
val payload = listOf(
task.title,
task.description,
task.sku,
task.imageAssetId,
task.quantity.toString(),
maxBudgetCents,
task.currency
).joinToString("\u0000")
return MessageDigest.getInstance("SHA-256")
.digest(payload.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
private fun parseCnyCents(value: String): Long {
val normalized = value.trim()
val pieces = normalized.split('.', limit = 2)
require(pieces.size in 1..2 && pieces[0].all(Char::isDigit))
val whole = pieces[0].toLong()
val fractional = when (pieces.size) {
1 -> 0L
else -> {
require(pieces[1].length in 1..2 && pieces[1].all(Char::isDigit))
pieces[1].padEnd(2, '0').toLong()
}
}
return whole * 100 + fractional
}
}
@@ -25,6 +25,10 @@ data class DownloadedReferenceImage(
val sha256: String
)
data class ExecutionOutboxUploadResult(
val evidenceAssetId: String? = null
)
interface ProcurementRemoteApi {
suspend fun login(
baseUrl: String,
@@ -79,6 +83,15 @@ interface ProcurementRemoteApi {
claimToken: String,
idempotencyKey: String
): RemotePurchaseTask
suspend fun uploadExecutionOutboxItem(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
item: ExecutionOutboxItem,
evidenceBytes: ByteArray? = null
): ExecutionOutboxUploadResult
}
class ProcurementApiClient(
@@ -250,6 +263,50 @@ class ProcurementApiClient(
}
}
override suspend fun uploadExecutionOutboxItem(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
item: ExecutionOutboxItem,
evidenceBytes: ByteArray?
): ExecutionOutboxUploadResult = withContext(Dispatchers.IO) {
val path = when (item.type) {
ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events"
ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence"
ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates"
ExecutionOutboxType.COMPLETE -> "/api/v1/tasks/${task.id}/complete"
ExecutionOutboxType.FAIL -> "/api/v1/tasks/${task.id}/fail"
}
val request = authorizedRequest(session, path)
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, item.idempotencyKey)
if (item.type == ExecutionOutboxType.EVIDENCE) {
val bytes = requireNotNull(evidenceBytes) { "离线证据文件缺失" }
require(bytes.isNotEmpty() && bytes.size <= MAX_EVIDENCE_BYTES) {
"离线证据文件无效"
}
request
.header("X-Execution-ID", execution.id)
.header("X-Claim-Generation", task.claimGeneration.toString())
.post(bytes.toRequestBody(PNG_MEDIA_TYPE))
} else {
val payload = item.payload
require(payload.toByteArray(Charsets.UTF_8).size <= MAX_JSON_BYTES) {
"离线结果过大"
}
request.post(payload.toRequestBody(JSON_MEDIA_TYPE))
}
val json = executeJson(request.build())
ExecutionOutboxUploadResult(
evidenceAssetId = if (item.type == ExecutionOutboxType.EVIDENCE) {
json.getJSONObject("evidence").getString("id")
} else {
null
}
)
}
override suspend fun start(
session: ProcurementSession,
task: RemotePurchaseTask,
@@ -455,6 +512,7 @@ class ProcurementApiClient(
title = json.getString("title"),
description = json.optString("description"),
sku = json.getString("sku"),
imageAssetId = json.getString("image_asset_id"),
referenceImageUrl = json.getString("reference_image_url"),
quantity = json.getInt("quantity"),
maxBudget = json.optionalString("max_budget"),
@@ -501,10 +559,12 @@ class ProcurementApiClient(
companion object {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val PNG_MEDIA_TYPE = "image/png".toMediaType()
private const val JPEG_MEDIA_TYPE = "image/jpeg"
private const val CLAIM_TOKEN_HEADER = "X-Claim-Token"
private const val IDEMPOTENCY_HEADER = "Idempotency-Key"
private const val MAX_JSON_BYTES = 1_048_576L
private const val MAX_EVIDENCE_BYTES = 8 * 1024 * 1024
private const val MAX_REFERENCE_IMAGE_BYTES = 20L * 1024L * 1024L
private val SHA256_PATTERN = Regex("[0-9a-f]{64}")
@@ -39,6 +39,7 @@ data class RemotePurchaseTask(
val title: String,
val description: String,
val sku: String,
val imageAssetId: String,
val referenceImageUrl: String,
val quantity: Int,
val maxBudget: String?,
@@ -67,7 +68,8 @@ data class RunningExecution(
val expiresAt: String,
val serverClockOffsetMillis: Long,
val safetyStopped: Boolean = false,
val cancelAcknowledgementKey: String? = null
val cancelAcknowledgementKey: String? = null,
val provenance: ExecutionProvenanceSnapshot? = null
) {
fun isExpired(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
ExecutionAuthorization.isExpired(
@@ -81,7 +83,39 @@ data class PersistedProcurementState(
val session: ProcurementSession? = null,
val deviceToken: String? = null,
val claim: ClaimContext? = null,
val execution: RunningExecution? = null
val execution: RunningExecution? = null,
val outbox: List<ExecutionOutboxItem> = emptyList()
)
enum class ExecutionMode {
MANUAL_FIRST,
AI_ASSISTED
}
data class ExecutionProvenanceSnapshot(
val mode: ExecutionMode,
val providerId: String? = null,
val model: String? = null,
val promptVersion: String? = null,
val schemaVersion: Int? = null,
val referenceImageSha256: String
)
enum class ExecutionOutboxType {
EVENTS,
EVIDENCE,
CANDIDATES,
COMPLETE,
FAIL
}
data class ExecutionOutboxItem(
val id: String,
val type: ExecutionOutboxType,
val idempotencyKey: String,
val payload: String,
val evidenceRelativePath: String? = null,
val remoteResourceID: String? = null
)
data class ProcurementUiState(
@@ -10,10 +10,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.security.SecureRandom
import java.time.Instant
import java.util.Base64
import java.util.UUID
enum class ExecutionSyncDecision {
CONTINUE,
@@ -131,7 +135,10 @@ class ProcurementRepository(
true
} ?: false
suspend fun start(): Boolean = operation {
suspend fun start(
mode: ExecutionMode = ExecutionMode.MANUAL_FIRST,
provenance: ExecutionProvenanceSnapshot? = null
): Boolean = operation {
val session = requireValidSession()
val claim = requireNotNull(persisted.claim) { "没有可开始的任务" }
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
@@ -160,12 +167,20 @@ class ProcurementRepository(
serverClockOffsetMillis = ExecutionAuthorization.serverClockOffset(
result.serverTime,
requestStartedAt
)
),
provenance = normalizeProvenance(mode, provenance, claim.referenceImage)
)
persisted = persisted.copy(
claim = persisted.claim?.copy(task = result.task),
execution = execution
)
enqueueEventLocked(
task = result.task,
execution = execution,
step = "PREFLIGHT",
type = "EXECUTION_STARTED",
message = "本地受控采购流程已开始"
)
saveAndPublish(
backendOnline = true,
message = "受控采购流程已开始"
@@ -234,6 +249,21 @@ class ProcurementRepository(
}
}
return@withLock try {
if (flushExecutionOutbox(session)) {
saveAndPublish(
backendOnline = true,
message = "执行结果已回传,任务已结束"
)
return@withLock ExecutionSyncDecision.STOP
}
execution = persisted.execution ?: return@withLock ExecutionSyncDecision.STOP
if (execution.safetyStopped) {
publish(
backendOnline = true,
message = "授权到期后的审计结果已同步,自动化保持停止"
)
return@withLock ExecutionSyncDecision.STOP
}
val requestStartedAt = System.currentTimeMillis()
val result = api.heartbeat(session, task, execution, claim.token)
val updatedExecution = execution.copy(
@@ -294,6 +324,172 @@ class ProcurementRepository(
if (task != null && image != null) task.toProbeTask(image) else null
}
fun activeExecutionMode(): ExecutionMode? =
persisted.execution?.provenance?.mode
fun currentReferenceImageBytes(): ByteArray? =
persisted.claim?.referenceImage?.let { image ->
File(appContext.filesDir, image.relativePath)
.takeIf { it.isFile && it.length() == image.sizeBytes }
?.readBytes()
}
suspend fun queueCandidateBatch(
batch: ExecutionCandidateBatchDraft,
evidence: List<ExecutionEvidenceDraft>
): List<ExecutionCandidateDraft>? = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
require(!execution.safetyStopped && !execution.isExpired()) {
"执行授权已到期,不能继续采集"
}
require(evidence.size in 1..5) { "候选证据必须为 1 至 5 张" }
require(batch.candidates.size in 1..5) { "候选数量必须为 1 至 5 个" }
require(batch.candidates.map { it.ordinal } == (1..batch.candidates.size).toList()) {
"候选编号必须连续"
}
require(batch.mode == execution.provenance?.mode) { "执行模式不能在回传时变更" }
if (batch.mode == ExecutionMode.AI_ASSISTED) {
require(batch.provenance != null && batch.provenance.providerId != null) {
"AI 辅助模式缺少本地模型出处"
}
} else {
require(batch.provenance == null) { "手工优先模式不能附带模型出处" }
}
val evidenceIDs = evidence.sortedBy { it.ordinal }.associate { draft ->
require(draft.ordinal in 1..batch.candidates.size)
require(sha256(draft.pngBytes) == draft.sha256)
val localID = UUID.randomUUID().toString()
persistEvidenceLocked(localID, draft.pngBytes)
appendOutboxLocked(
ExecutionOutboxItem(
id = localID,
type = ExecutionOutboxType.EVIDENCE,
idempotencyKey = newOpaqueSecret(),
payload = "{}",
evidenceRelativePath = "$OUTBOX_DIRECTORY/$localID.png"
)
)
draft.ordinal to localID
}
val candidates = batch.candidates.map { candidate ->
candidate.copy(
evidenceLocalIDs = listOf(
requireNotNull(evidenceIDs[candidate.ordinal])
)
)
}
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("task_content_sha256", ExecutionTaskHash.sha256(task))
.put("execution_mode", batch.mode.name)
.put("search_query", batch.searchQuery.trim())
.put("candidates", JSONArray().apply {
candidates.forEach { put(candidateJSON(it)) }
})
batch.provenance?.let { payload.put("provenance", provenanceJSON(it)) }
batch.recommendation?.let { recommendation ->
payload.put(
"recommendation",
JSONObject()
.put("candidate_ordinal", recommendation.candidateOrdinal)
.put("policy_version", recommendation.policyVersion)
.put("reasons", JSONArray(recommendation.reasons))
)
}
appendOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.CANDIDATES,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "候选和截图已加入加密回传队列"
)
candidates
}
suspend fun completeExecution(
outcome: String,
operatorReason: String,
candidate: ExecutionCandidateDraft? = null
): Boolean = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
require(operatorReason.trim().isNotEmpty()) { "请填写人工确认理由" }
require(outcome in COMPLETE_OUTCOMES) { "采购结论无效" }
if (outcome == "CANDIDATE_ACCEPTED") {
require(candidate != null) { "接受候选时必须保留候选证据" }
} else {
require(candidate == null) { "当前结论不能附带候选" }
}
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("task_content_sha256", ExecutionTaskHash.sha256(task))
.put("execution_mode", requireNotNull(execution.provenance).mode.name)
.put("outcome", outcome)
.put("operator_reason", operatorReason.trim())
.put("order_submitted", false)
candidate?.let { payload.put("candidate", candidateJSON(it)) }
appendTerminalOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.COMPLETE,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "人工结论已加入加密回传队列"
)
true
} ?: false
suspend fun failExecution(
code: String,
message: String,
step: String,
retryable: Boolean,
evidenceLocalIDs: List<String> = emptyList()
): Boolean = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
require(code.isNotBlank() && message.isNotBlank() && step.isNotBlank()) {
"失败信息不完整"
}
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put(
"error",
JSONObject()
.put("code", code.trim())
.put("message", message.trim())
.put("step", step.trim())
.put("retryable", retryable)
)
.put("evidence_asset_ids", JSONArray(evidenceLocalIDs))
appendTerminalOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.FAIL,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "失败信息已加入加密回传队列"
)
true
} ?: false
private suspend fun acknowledgeCancellation(session: ProcurementSession) {
val claim = requireNotNull(persisted.claim)
val task = requireNotNull(claim.task)
@@ -363,9 +559,252 @@ class ProcurementRepository(
persisted.claim?.referenceImage?.let {
File(appContext.filesDir, it.relativePath).delete()
}
persisted = persisted.copy(claim = null, execution = null)
persisted.outbox.forEach { item ->
item.evidenceRelativePath?.let { relativePath ->
File(appContext.filesDir, relativePath).delete()
}
}
persisted = persisted.copy(
claim = null,
execution = null,
outbox = emptyList()
)
}
private fun requireCurrentTask(): RemotePurchaseTask =
requireNotNull(persisted.claim?.task) { "没有可回传的采购任务" }
private fun requireActiveExecution(): RunningExecution =
requireNotNull(persisted.execution) { "采购执行尚未开始" }
private fun appendOutboxLocked(item: ExecutionOutboxItem) {
var resolvedPayload = item.payload
persisted.outbox.forEach { knownEvidence ->
if (knownEvidence.type == ExecutionOutboxType.EVIDENCE &&
knownEvidence.remoteResourceID != null
) {
resolvedPayload = replaceEvidenceReference(
resolvedPayload,
knownEvidence.id,
knownEvidence.remoteResourceID
)
}
}
persisted = persisted.copy(
outbox = persisted.outbox + item.copy(payload = resolvedPayload)
)
}
private fun appendTerminalOutboxLocked(item: ExecutionOutboxItem) {
require(persisted.outbox.none {
it.type == ExecutionOutboxType.COMPLETE || it.type == ExecutionOutboxType.FAIL
}) { "当前执行已有待回传终态" }
appendOutboxLocked(item)
}
private fun enqueueEventLocked(
task: RemotePurchaseTask,
execution: RunningExecution,
step: String,
type: String,
message: String
) {
val payload = JSONObject()
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put(
"events",
JSONArray().put(
JSONObject()
.put("event_id", UUID.randomUUID().toString())
.put("step", step)
.put("type", type)
.put("message", message)
.put("occurred_at", Instant.now().toString())
)
)
appendOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.EVENTS,
idempotencyKey = newOpaqueSecret(),
payload = payload.toString()
)
)
}
private fun normalizeProvenance(
mode: ExecutionMode,
candidate: ExecutionProvenanceSnapshot?,
referenceImage: ReferenceImageRecord?
): ExecutionProvenanceSnapshot {
val referenceHash = requireNotNull(referenceImage?.sha256) {
"参考图哈希缺失"
}
if (mode == ExecutionMode.MANUAL_FIRST) {
require(candidate == null) { "手工优先模式不能配置模型出处" }
return ExecutionProvenanceSnapshot(
mode = mode,
referenceImageSha256 = referenceHash
)
}
val provenance = requireNotNull(candidate) { "AI 辅助模式缺少模型出处" }
require(
provenance.mode == mode &&
!provenance.providerId.isNullOrBlank() &&
!provenance.model.isNullOrBlank() &&
!provenance.promptVersion.isNullOrBlank() &&
provenance.schemaVersion != null
) { "AI 辅助模式模型出处无效" }
return provenance.copy(referenceImageSha256 = referenceHash)
}
private fun persistEvidenceLocked(localID: String, bytes: ByteArray) {
require(bytes.isNotEmpty() && bytes.size <= MAX_OUTBOX_EVIDENCE_BYTES) {
"候选截图无效"
}
val directory = File(appContext.filesDir, OUTBOX_DIRECTORY)
check(directory.exists() || directory.mkdirs()) { "无法创建离线证据目录" }
val target = File(directory, "$localID.png")
val temporary = File(directory, ".$localID.tmp")
temporary.outputStream().use { output ->
output.write(bytes)
output.fd.sync()
}
check(!target.exists() || target.delete()) { "无法替换离线证据" }
check(temporary.renameTo(target)) { "无法保存离线证据" }
}
private suspend fun flushExecutionOutbox(session: ProcurementSession): Boolean {
while (true) {
val task = requireCurrentTask()
val execution = requireActiveExecution()
val item = persisted.outbox.firstOrNull { pending ->
pending.type != ExecutionOutboxType.EVIDENCE ||
pending.remoteResourceID == null
} ?: return false
val evidenceBytes = item.evidenceRelativePath?.let { relativePath ->
val file = File(appContext.filesDir, relativePath)
require(file.isFile && file.length() in 1..MAX_OUTBOX_EVIDENCE_BYTES) {
"离线证据文件缺失"
}
file.readBytes()
}
val uploaded = api.uploadExecutionOutboxItem(
session = session,
task = task,
execution = execution,
claimToken = requireNotNull(persisted.claim).token,
item = item,
evidenceBytes = evidenceBytes
)
if (item.type == ExecutionOutboxType.EVIDENCE) {
val evidenceID = requireNotNull(uploaded.evidenceAssetId) {
"后台未返回证据编号"
}
persisted = persisted.copy(
outbox = persisted.outbox.map { pending ->
if (pending.id == item.id) {
pending.copy(remoteResourceID = evidenceID)
} else {
pending.copy(
payload = replaceEvidenceReference(
pending.payload,
item.id,
evidenceID
)
)
}
}
)
item.evidenceRelativePath?.let {
File(appContext.filesDir, it).delete()
}
} else {
persisted = persisted.copy(
outbox = persisted.outbox.filterNot { it.id == item.id }
)
}
if (item.type == ExecutionOutboxType.COMPLETE ||
item.type == ExecutionOutboxType.FAIL
) {
clearCurrentTask()
store.save(persisted)
return true
}
store.save(persisted)
}
}
private fun replaceEvidenceReference(
payload: String,
localID: String,
remoteID: String
): String {
val root = JSONObject(payload)
fun replace(value: Any?) {
when (value) {
is JSONObject -> {
val keys = value.keys().asSequence().toList()
keys.forEach { key -> replace(value.opt(key)) }
}
is JSONArray -> {
for (index in 0 until value.length()) {
if (value.optString(index) == localID) {
value.put(index, remoteID)
} else {
replace(value.opt(index))
}
}
}
}
}
replace(root)
return root.toString()
}
private fun candidateJSON(candidate: ExecutionCandidateDraft): JSONObject =
JSONObject()
.put("ordinal", candidate.ordinal)
.put("title", candidate.title)
.put("sku_text", candidate.skuText)
.put("price", candidate.price)
.put("product_url", candidate.productUrl)
.put("image_url", candidate.imageUrl)
.put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs))
.also { json ->
candidate.evaluation?.let { evaluation ->
json.put(
"evaluation",
JSONObject()
.put("decision", evaluation.decision)
.put("score", evaluation.score)
.put("matched", JSONArray(evaluation.matched))
.put(
"missing_or_uncertain",
JSONArray(evaluation.missingOrUncertain)
)
.put(
"rejection_reasons",
JSONArray(evaluation.rejectionReasons)
)
.put("confidence", evaluation.confidence)
)
}
}
private fun provenanceJSON(snapshot: ExecutionProvenanceSnapshot): JSONObject =
JSONObject()
.put("provider_id", snapshot.providerId)
.put("model", snapshot.model)
.put("prompt_version", snapshot.promptVersion)
.put("schema_version", snapshot.schemaVersion)
private fun sha256(bytes: ByteArray): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(bytes)
.joinToString("") { "%02x".format(it) }
private fun requireValidSession(): ProcurementSession {
val session = persisted.session
require(session != null && session.isValid()) { "采购员登录已过期,请重新登录" }
@@ -476,10 +915,18 @@ class ProcurementRepository(
companion object {
private const val REFERENCE_DIRECTORY = "procurement"
private const val OUTBOX_DIRECTORY = "procurement-outbox"
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
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
private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L
private val COMPLETE_OUTCOMES = setOf(
"CANDIDATE_ACCEPTED",
"CANDIDATE_REJECTED",
"NO_MATCH",
"MANUAL_REQUIRED"
)
private val SECURE_RANDOM = SecureRandom()
fun create(context: Context): ProcurementRepository =
@@ -3,6 +3,7 @@ package com.roubao.autopilot.procurement
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONArray
import org.json.JSONObject
interface ProcurementStateStore {
@@ -89,9 +90,50 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
"cancel_acknowledgement_key",
execution.cancelAcknowledgementKey
)
execution.provenance?.let { provenance ->
put(
"provenance",
JSONObject().apply {
put("mode", provenance.mode.name)
putNullable("provider_id", provenance.providerId)
putNullable("model", provenance.model)
putNullable("prompt_version", provenance.promptVersion)
provenance.schemaVersion?.let {
put("schema_version", it)
}
put(
"reference_image_sha256",
provenance.referenceImageSha256
)
}
)
}
}
)
}
put(
"outbox",
JSONArray().apply {
state.outbox.forEach { item ->
put(
JSONObject().apply {
put("id", item.id)
put("type", item.type.name)
put("idempotency_key", item.idempotencyKey)
put("payload", item.payload)
putNullable(
"evidence_relative_path",
item.evidenceRelativePath
)
putNullable(
"remote_resource_id",
item.remoteResourceID
)
}
)
}
}
)
}
private fun decodeState(json: JSONObject): PersistedProcurementState =
@@ -137,9 +179,43 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
serverClockOffsetMillis = it.getLong("server_clock_offset_ms"),
safetyStopped = it.optBoolean("safety_stopped", false),
cancelAcknowledgementKey =
it.optionalString("cancel_acknowledgement_key")
it.optionalString("cancel_acknowledgement_key"),
provenance = it.optionalObject("provenance")?.let { provenance ->
ExecutionProvenanceSnapshot(
mode = ExecutionMode.valueOf(provenance.getString("mode")),
providerId = provenance.optionalString("provider_id"),
model = provenance.optionalString("model"),
promptVersion = provenance.optionalString("prompt_version"),
schemaVersion = if (provenance.has("schema_version")) {
provenance.getInt("schema_version")
} else {
null
},
referenceImageSha256 =
provenance.getString("reference_image_sha256")
)
}
)
}
},
outbox = json.optionalArray("outbox")?.let { entries ->
buildList {
for (index in 0 until entries.length()) {
val item = entries.getJSONObject(index)
add(
ExecutionOutboxItem(
id = item.getString("id"),
type = ExecutionOutboxType.valueOf(item.getString("type")),
idempotencyKey = item.getString("idempotency_key"),
payload = item.getString("payload"),
evidenceRelativePath =
item.optionalString("evidence_relative_path"),
remoteResourceID =
item.optionalString("remote_resource_id")
)
)
}
}
} ?: emptyList()
)
private fun encodeTask(task: RemotePurchaseTask): JSONObject =
@@ -152,6 +228,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
put("title", task.title)
put("description", task.description)
put("sku", task.sku)
put("image_asset_id", task.imageAssetId)
put("reference_image_url", task.referenceImageUrl)
put("quantity", task.quantity)
putNullable("max_budget", task.maxBudget)
@@ -168,6 +245,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
title = json.getString("title"),
description = json.optString("description"),
sku = json.getString("sku"),
imageAssetId = json.getString("image_asset_id"),
referenceImageUrl = json.getString("reference_image_url"),
quantity = json.getInt("quantity"),
maxBudget = json.optionalString("max_budget"),
@@ -184,6 +262,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
private fun JSONObject.optionalObject(name: String): JSONObject? =
if (has(name) && !isNull(name)) getJSONObject(name) else null
private fun JSONObject.optionalArray(name: String): JSONArray? =
if (has(name) && !isNull(name)) getJSONArray(name) else null
private companion object {
const val FILE_NAME = "procurement_secure_state"
const val STATE_KEY = "state_v1"
@@ -43,6 +43,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.procurement.LoginInput
import com.roubao.autopilot.procurement.ExecutionMode
import com.roubao.autopilot.procurement.ProcurementPhase
import com.roubao.autopilot.procurement.ProcurementUiState
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
@@ -56,12 +57,15 @@ fun ProcurementScreen(
readiness: DeviceReadinessSnapshot,
onLogin: (LoginInput) -> Unit,
onClaim: () -> Unit,
onStart: () -> Unit,
onStart: (ExecutionMode) -> Unit,
onRelease: () -> Unit,
onSync: () -> Unit
) {
val colors = BaoziTheme.colors
var confirmStart by remember(state.task?.id) { mutableStateOf(false) }
var executionMode by remember(state.task?.id) {
mutableStateOf(ExecutionMode.MANUAL_FIRST)
}
if (confirmStart) {
AlertDialog(
@@ -77,7 +81,7 @@ fun ProcurementScreen(
Button(
onClick = {
confirmStart = false
onStart()
onStart(executionMode)
}
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null)
@@ -154,6 +158,12 @@ fun ProcurementScreen(
item { LoginSection(state = state, onLogin = onLogin) }
}
item { TaskDetails(state) }
item {
ExecutionModeSelector(
selected = executionMode,
onSelect = { executionMode = it }
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -206,6 +216,47 @@ fun ProcurementScreen(
}
}
@Composable
private fun ExecutionModeSelector(
selected: ExecutionMode,
onSelect: (ExecutionMode) -> Unit
) {
val colors = BaoziTheme.colors
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"执行模式",
color = colors.textPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ExecutionMode.values().forEach { mode ->
OutlinedButton(
onClick = { onSelect(mode) },
modifier = Modifier.weight(1f),
enabled = mode != selected
) {
Text(
if (mode == ExecutionMode.MANUAL_FIRST) "人工优先" else "AI 辅助"
)
}
}
}
Text(
if (selected == ExecutionMode.MANUAL_FIRST) {
"直接采集候选并由人员确认"
} else {
"仅使用本机已配置的 VLM;后台不会读取模型配置"
},
color = colors.textSecondary,
fontSize = 13.sp
)
}
}
@Composable
private fun LoginSection(
state: ProcurementUiState,
@@ -25,7 +25,12 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -85,8 +90,8 @@ fun SearchProbeScreen(
onStopRequirement: () -> Unit,
onStartCandidateEvaluation: () -> Unit,
onStopCandidateEvaluation: () -> Unit,
onAcceptCandidate: () -> Unit,
onRejectCandidates: () -> Unit,
onAcceptCandidate: (String) -> Unit,
onRejectCandidates: (String) -> Unit,
onStart: () -> Unit,
onStop: () -> Unit
) {
@@ -279,10 +284,11 @@ private fun CandidateEvaluationSection(
canStart: Boolean,
onStart: () -> Unit,
onStop: () -> Unit,
onAccept: () -> Unit,
onReject: () -> Unit
onAccept: (String) -> Unit,
onReject: (String) -> Unit
) {
val colors = BaoziTheme.colors
var operatorReason by remember(state) { mutableStateOf("") }
val statusColor = when (state) {
CandidateEvaluationState.AWAITING_CONFIRMATION,
CandidateEvaluationState.HUMAN_ACCEPTED -> colors.success
@@ -366,6 +372,20 @@ private fun CandidateEvaluationSection(
}
Spacer(modifier = Modifier.height(14.dp))
if (state in setOf(
CandidateEvaluationState.AWAITING_CONFIRMATION,
CandidateEvaluationState.MANUAL_REVIEW,
CandidateEvaluationState.NO_MATCH
)) {
OutlinedTextField(
value = operatorReason,
onValueChange = { operatorReason = it.take(1000) },
label = { Text("人工确认理由") },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)
Spacer(modifier = Modifier.height(10.dp))
}
when (state) {
CandidateEvaluationState.RUNNING -> {
OutlinedButton(
@@ -379,7 +399,8 @@ private fun CandidateEvaluationSection(
}
CandidateEvaluationState.AWAITING_CONFIRMATION -> {
Button(
onClick = onAccept,
onClick = { onAccept(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colors.primary
@@ -391,7 +412,8 @@ private fun CandidateEvaluationSection(
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onReject,
onClick = { onReject(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, contentDescription = null)
@@ -401,8 +423,21 @@ private fun CandidateEvaluationSection(
}
CandidateEvaluationState.MANUAL_REVIEW,
CandidateEvaluationState.NO_MATCH -> {
if (state == CandidateEvaluationState.MANUAL_REVIEW) {
Button(
onClick = { onAccept(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.size(8.dp))
Text("标记候选可用")
}
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedButton(
onClick = onReject,
onClick = { onReject(operatorReason) },
enabled = operatorReason.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, contentDescription = null)
@@ -0,0 +1,34 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ExecutionTaskHashTest {
@Test
fun taskHashUsesOnlyBackendRequirementFields() {
val task = RemotePurchaseTask(
id = "task-id",
status = "RUNNING",
version = 3,
claimGeneration = 1,
claimExpiresAt = "2099-01-01T00:00:00Z",
title = "测试商品",
description = "测试描述",
sku = "SKU-01",
imageAssetId = "asset-id",
referenceImageUrl = "/api/v1/tasks/task-id/reference-image?claim_generation=1",
quantity = 2,
maxBudget = "20.00",
currency = "CNY"
)
assertEquals(
ExecutionTaskHash.sha256(task),
ExecutionTaskHash.sha256(task.copy(version = 99, status = "CLAIMED"))
)
assertNotEquals(
ExecutionTaskHash.sha256(task),
ExecutionTaskHash.sha256(task.copy(quantity = 3))
)
}
}
@@ -172,6 +172,61 @@ class ProcurementApiClientTest {
assertEquals(0, server.requestCount)
}
@Test
fun executionOutboxUsesDeviceResultEndpointsWithoutProviderConfiguration() = runBlocking {
val task = task("/api/v1/tasks/task-id/reference-image?claim_generation=1")
val execution = RunningExecution(
id = "execution-id",
currentStep = "SEARCH",
expiresAt = "2099-01-01T00:00:00Z",
serverClockOffsetMillis = 0
)
server.enqueue(
jsonResponse(
"""{"evidence":{"id":"evidence-id"},"replayed":false}"""
)
)
val evidenceResult = api.uploadExecutionOutboxItem(
session = session(),
task = task,
execution = execution,
claimToken = "claim-token-value",
item = ExecutionOutboxItem(
id = "local-evidence-id",
type = ExecutionOutboxType.EVIDENCE,
idempotencyKey = "evidence-idempotency-key",
payload = "{}",
evidenceRelativePath = "procurement-outbox/local-evidence-id.png"
),
evidenceBytes = byteArrayOf(1, 2, 3)
)
assertEquals("evidence-id", evidenceResult.evidenceAssetId)
val evidenceRequest = server.takeRequest()
assertEquals("/api/v1/tasks/task-id/evidence", evidenceRequest.path)
assertEquals("claim-token-value", evidenceRequest.getHeader("X-Claim-Token"))
assertEquals("execution-id", evidenceRequest.getHeader("X-Execution-ID"))
assertEquals("1", evidenceRequest.getHeader("X-Claim-Generation"))
assertTrue(evidenceRequest.getHeader("Content-Type")!!.startsWith("image/png"))
server.enqueue(jsonResponse("""{"replayed":false}"""))
api.uploadExecutionOutboxItem(
session = session(),
task = task,
execution = execution,
claimToken = "claim-token-value",
item = ExecutionOutboxItem(
id = "event-id",
type = ExecutionOutboxType.EVENTS,
idempotencyKey = "event-idempotency-key",
payload = """{"execution_id":"execution-id","claim_generation":1,"events":[]}"""
)
)
val eventRequest = server.takeRequest()
assertEquals("/api/v1/tasks/task-id/events", eventRequest.path)
assertEquals("event-idempotency-key", eventRequest.getHeader("Idempotency-Key"))
assertTrue(eventRequest.body.readUtf8().contains("execution-id"))
}
private fun session() = ProcurementSession(
backendUrl = server.url("/").toString().trimEnd('/'),
username = "buyer01",
@@ -191,6 +246,7 @@ class ProcurementApiClientTest {
"title":"测试商品",
"description":"测试描述",
"sku":"SKU-01",
"image_asset_id":"00000000-0000-4000-8000-000000000001",
"reference_image_url":"/api/v1/tasks/task-id/reference-image?claim_generation=1",
"quantity":2,
"max_budget":"20.00",
@@ -207,6 +263,7 @@ class ProcurementApiClientTest {
title = "测试商品",
description = "测试描述",
sku = "SKU-01",
imageAssetId = "00000000-0000-4000-8000-000000000001",
referenceImageUrl = referenceImageUrl,
quantity = 2,
maxBudget = "20.00",
+13 -2
View File
@@ -180,6 +180,15 @@ func buildRouter(
if err != nil {
return nil, err
}
results, err := usecase.NewExecutionResultService(
store,
files,
clock,
ids,
)
if err != nil {
return nil, err
}
passwords, err := password.NewBcrypt(12)
if err != nil {
return nil, err
@@ -188,6 +197,7 @@ func buildRouter(
httpapi.DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
},
)
if err != nil {
@@ -245,8 +255,9 @@ func buildRouter(
}
registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar(
httpapi.AdminServices{
Assets: assets,
Tasks: tasks,
Assets: assets,
Tasks: tasks,
Results: results,
},
webHandler,
)
+64
View File
@@ -84,11 +84,75 @@ type TaskExecution struct {
FinishedAt *time.Time
}
type ExecutionEvent struct {
ID string
TaskID string
ExecutionID string
Step string
Type string
Message string
OccurredAt time.Time
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionEvidenceAsset struct {
ID string
TaskID string
ExecutionID string
MediaType string
SizeBytes int64
SHA256 string
StorageKey string
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionCandidateBatch struct {
TaskID string
ExecutionID string
TaskContentSHA256 string
ExecutionMode string
SearchQuery string
ProvenanceJSON *string
CandidatesJSON string
RecommendationJSON *string
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionOutcome struct {
TaskID string
ExecutionID string
ResultType string
ExecutionMode *string
TaskContentSHA256 *string
Outcome *string
OperatorReason *string
SelectedCandidateJSON *string
EvidenceAssetIDsJSON *string
ErrorCode *string
ErrorMessage *string
ErrorStep *string
Retryable *bool
OrderSubmitted bool
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReport struct {
Events []ExecutionEvent
EvidenceAssets []ExecutionEvidenceAsset
CandidateBatch *ExecutionCandidateBatch
Outcome *ExecutionOutcome
}
type TaskDetail struct {
Task PurchaseTask
Asset Asset
Execution *TaskExecution
Events []TaskEvent
Report *ExecutionReport
}
type TaskValidationError struct {
@@ -34,8 +34,8 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 4 {
t.Fatalf("initial Up() applied = %d, want 4", applied)
} else if applied != 5 {
t.Fatalf("initial Up() applied = %d, want 5", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v4) error = %v", err)
@@ -50,6 +50,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v5) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v4) with compatible history error = %v", err)
}
@@ -57,8 +62,8 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4) error = %v", err)
} else if applied != 1 {
t.Fatalf("final Up(v4) applied = %d, want 1", applied)
} else if applied != 2 {
t.Fatalf("final Up(v4-v5) applied = %d, want 2", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -294,6 +299,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v5) error = %v", err)
}
if err := runner.Down(ctx); err == nil {
t.Fatal("Down(v4) succeeded with non-representable audit event")
}
@@ -27,14 +27,15 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 4 {
t.Fatalf("Up() applied = %d, want 4", applied)
if applied != 5 {
t.Fatalf("Up() applied = %d, want 5", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
2: true,
3: true,
4: true,
5: true,
})
applied, err = runner.Up(context.Background())
@@ -52,7 +53,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
1: true,
2: true,
3: true,
4: false,
4: true,
5: false,
})
applied, err = runner.Up(context.Background())
@@ -67,6 +69,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
2: true,
3: true,
4: true,
5: 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(v5) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v4) error = %v", err)
}
@@ -403,8 +406,8 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v4) error = %v", err)
} else if applied != 2 {
t.Fatalf("Up(v3-v4) applied = %d, want 2", applied)
} else if applied != 3 {
t.Fatalf("Up(v3-v5) applied = %d, want 3", applied)
}
}
@@ -0,0 +1,891 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
type executionResultRequestRecord struct {
RequestHash string
ClaimTokenHash string
TaskID string
ExecutionID string
ResourceID *string
}
func (s *Store) GetExecutionEvidence(
ctx context.Context,
taskID string,
evidenceID string,
) (domain.ExecutionEvidenceAsset, error) {
return getExecutionEvidenceForTask(ctx, s.db, taskID, evidenceID)
}
func (s *Store) AppendExecutionEvents(
ctx context.Context,
write usecase.ExecutionResultWrite,
events []domain.ExecutionEvent,
) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if replayed, err := replayExecutionResultRequest(
ctx, tx, write,
); err != nil || replayed {
return replayed, err
}
_, _, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return false, err
}
for _, event := range events {
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_events (
id, task_id, execution_id, step, event_type, message,
occurred_at, received_at, received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
event.ID,
write.TaskID,
write.ExecutionID,
event.Step,
event.Type,
event.Message,
formatTimestamp(event.OccurredAt),
formatTimestamp(write.Now),
expired,
)
if err != nil {
return false, repositoryFailure(err)
}
}
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, repositoryFailure(err)
}
return false, nil
}
func (s *Store) CreateExecutionEvidence(
ctx context.Context,
write usecase.ExecutionResultWrite,
candidate domain.ExecutionEvidenceAsset,
) (domain.ExecutionEvidenceAsset, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if found {
if err := validateExecutionResultReplay(record, write); err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if record.ResourceID == nil {
return domain.ExecutionEvidenceAsset{}, false, usecase.ErrRepositoryInvariant
}
evidence, err := getExecutionEvidence(ctx, tx, *record.ResourceID)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
return evidence, true, nil
}
_, _, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
candidate.ReceivedAfterExecutionExpiry = expired
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_evidence_assets (
id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
candidate.ID,
write.TaskID,
write.ExecutionID,
candidate.MediaType,
candidate.SizeBytes,
candidate.SHA256,
candidate.StorageKey,
formatTimestamp(candidate.CreatedAt),
expired,
)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
if err := insertExecutionResultRequest(ctx, tx, write, &candidate.ID); err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
return candidate, false, nil
}
func (s *Store) StoreExecutionCandidates(
ctx context.Context,
write usecase.ExecutionResultWrite,
candidate domain.ExecutionCandidateBatch,
) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if replayed, err := replayExecutionResultRequest(ctx, tx, write); err != nil || replayed {
return replayed, err
}
task, _, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return false, err
}
if usecase.TaskContentSHA256(task) != candidate.TaskContentSHA256 {
return false, usecase.ErrTaskVersionConflict
}
if err := validateCandidateEvidence(ctx, tx, write, candidate.CandidatesJSON); err != nil {
return false, err
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_candidate_batches (
execution_id, task_id, task_content_sha256, execution_mode,
search_query, provenance_json, candidates_json, recommendation_json,
received_at, received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
write.ExecutionID,
write.TaskID,
candidate.TaskContentSHA256,
candidate.ExecutionMode,
candidate.SearchQuery,
nullableString(candidate.ProvenanceJSON),
candidate.CandidatesJSON,
nullableString(candidate.RecommendationJSON),
formatTimestamp(write.Now),
expired,
)
if err != nil {
return false, repositoryFailure(err)
}
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, repositoryFailure(err)
}
return false, nil
}
func (s *Store) CompleteExecution(
ctx context.Context,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error) {
return s.finishExecution(ctx, write, outcome, domain.TaskStatusSucceeded)
}
func (s *Store) FailExecution(
ctx context.Context,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error) {
return s.finishExecution(ctx, write, outcome, domain.TaskStatusFailed)
}
func (s *Store) finishExecution(
ctx context.Context,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
terminalStatus domain.TaskStatus,
) (domain.PurchaseTask, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if found {
if err := validateExecutionResultReplay(record, write); err != nil {
return domain.PurchaseTask{}, false, err
}
task, err := getLifecycleTask(ctx, tx, write.TaskID)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if task.Status != terminalStatus {
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
}
if err := tx.Commit(); err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return task, true, nil
}
task, execution, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if task.CancelRequestedAt != nil {
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
}
if outcome.ResultType == "COMPLETE" {
if outcome.TaskContentSHA256 == nil ||
*outcome.TaskContentSHA256 != usecase.TaskContentSHA256(task) {
return domain.PurchaseTask{}, false, usecase.ErrTaskVersionConflict
}
if err := validateCompleteCandidate(ctx, tx, write, outcome); err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateSelectedEvidence(ctx, tx, write, outcome.SelectedCandidateJSON); err != nil {
return domain.PurchaseTask{}, false, err
}
} else if err := validateEvidenceIDsFromOutcome(
ctx, tx, write, outcome.EvidenceAssetIDsJSON,
); err != nil {
return domain.PurchaseTask{}, false, err
}
_ = execution
outcome.ReceivedAfterExecutionExpiry = expired
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_outcomes (
execution_id, task_id, result_type, execution_mode, task_content_sha256,
outcome, operator_reason, selected_candidate_json, evidence_asset_ids_json,
error_code, error_message, error_step, retryable, order_submitted, received_at,
received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
write.ExecutionID,
write.TaskID,
outcome.ResultType,
nullableString(outcome.ExecutionMode),
nullableString(outcome.TaskContentSHA256),
nullableString(outcome.Outcome),
nullableString(outcome.OperatorReason),
nullableString(outcome.SelectedCandidateJSON),
nullableString(outcome.EvidenceAssetIDsJSON),
nullableString(outcome.ErrorCode),
nullableString(outcome.ErrorMessage),
nullableString(outcome.ErrorStep),
nullableBool(outcome.Retryable),
formatTimestamp(write.Now),
expired,
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
step := "COMPLETED"
if terminalStatus == domain.TaskStatusFailed {
step = "FAILED"
}
result, err := tx.ExecContext(
ctx,
`UPDATE task_executions
SET current_step = ?,
last_heartbeat_at = ?,
finished_at = ?
WHERE id = ? AND finished_at IS NULL`,
step,
formatTimestamp(write.Now),
formatTimestamp(write.Now),
write.ExecutionID,
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return domain.PurchaseTask{}, false, usecase.ErrExecutionMismatch
}
result, err = tx.ExecContext(
ctx,
`UPDATE purchase_tasks
SET status = ?,
version = version + 1,
claimed_by_user_id = NULL,
claimed_by_device_id = NULL,
claim_token_hash = NULL,
claim_issued_at = NULL,
claim_expires_at = NULL,
updated_at = ?
WHERE id = ?
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
AND claim_generation = ?`,
terminalStatus,
formatTimestamp(write.Now),
write.TaskID,
write.ClaimGeneration,
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
}
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return domain.PurchaseTask{}, false, err
}
task, err = getLifecycleTask(ctx, tx, write.TaskID)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return task, false, nil
}
func authorizeExecutionResult(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
) (domain.PurchaseTask, domain.TaskExecution, bool, error) {
task, err := getClaimProtectedTask(ctx, tx, write.TaskID)
if err != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
}
if err := validateClaimOwner(
task,
write.UserID,
write.DeviceID,
write.ClaimGeneration,
write.ClaimTokenHash,
); err != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
}
if !domain.CanHeartbeat(task.Status) {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, usecase.ErrTaskStateConflict
}
execution, err := getExecutionByID(ctx, tx, write.ExecutionID)
if err != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
}
if execution.TaskID != write.TaskID || execution.UserID != write.UserID ||
execution.DeviceID != write.DeviceID ||
execution.ClaimGeneration != write.ClaimGeneration || execution.FinishedAt != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, usecase.ErrExecutionMismatch
}
expired := task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(write.Now)
return task, execution, expired, nil
}
func replayExecutionResultRequest(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
) (bool, error) {
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
if err != nil || !found {
return false, err
}
if err := validateExecutionResultReplay(record, write); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, repositoryFailure(err)
}
return true, nil
}
func lookupExecutionResultRequest(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
) (executionResultRequestRecord, bool, error) {
var record executionResultRequestRecord
var resourceID sql.NullString
err := tx.QueryRowContext(
ctx,
`SELECT request_sha256, claim_token_sha256, task_id, execution_id, resource_id
FROM execution_result_requests
WHERE user_id = ? AND device_id = ? AND operation = ? AND idempotency_key = ?`,
write.UserID,
write.DeviceID,
write.Operation,
write.IdempotencyKey,
).Scan(
&record.RequestHash,
&record.ClaimTokenHash,
&record.TaskID,
&record.ExecutionID,
&resourceID,
)
if errors.Is(err, sql.ErrNoRows) {
return executionResultRequestRecord{}, false, nil
}
if err != nil {
return executionResultRequestRecord{}, false, repositoryFailure(err)
}
if resourceID.Valid {
record.ResourceID = &resourceID.String
}
return record, true, nil
}
func validateExecutionResultReplay(
record executionResultRequestRecord,
write usecase.ExecutionResultWrite,
) error {
if record.RequestHash != write.RequestHash ||
record.ClaimTokenHash != write.ClaimTokenHash ||
record.TaskID != write.TaskID || record.ExecutionID != write.ExecutionID {
return usecase.ErrIdempotencyConflict
}
return nil
}
func insertExecutionResultRequest(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
resourceID *string,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO execution_result_requests (
user_id, device_id, operation, idempotency_key, request_sha256,
claim_token_sha256, task_id, execution_id, resource_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
write.UserID,
write.DeviceID,
write.Operation,
write.IdempotencyKey,
write.RequestHash,
write.ClaimTokenHash,
write.TaskID,
write.ExecutionID,
nullableString(resourceID),
formatTimestamp(write.Now),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}
func getExecutionEvidence(
ctx context.Context,
queryer queryRower,
evidenceID string,
) (domain.ExecutionEvidenceAsset, error) {
var evidence domain.ExecutionEvidenceAsset
var createdAt string
var receivedAfter bool
err := queryer.QueryRowContext(
ctx,
`SELECT id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
FROM execution_evidence_assets WHERE id = ?`, evidenceID,
).Scan(
&evidence.ID,
&evidence.TaskID,
&evidence.ExecutionID,
&evidence.MediaType,
&evidence.SizeBytes,
&evidence.SHA256,
&evidence.StorageKey,
&createdAt,
&receivedAfter,
)
if errors.Is(err, sql.ErrNoRows) {
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryInvariant
}
if err != nil {
return domain.ExecutionEvidenceAsset{}, repositoryFailure(err)
}
evidence.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.ExecutionEvidenceAsset{}, repositoryFailure(err)
}
evidence.ReceivedAfterExecutionExpiry = receivedAfter
return evidence, nil
}
func getExecutionEvidenceForTask(
ctx context.Context,
queryer queryRower,
taskID string,
evidenceID string,
) (domain.ExecutionEvidenceAsset, error) {
evidence, err := getExecutionEvidence(ctx, queryer, evidenceID)
if err != nil {
if errors.Is(err, usecase.ErrRepositoryInvariant) {
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryNotFound
}
return domain.ExecutionEvidenceAsset{}, err
}
if evidence.TaskID != taskID {
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryNotFound
}
return evidence, nil
}
func validateCandidateEvidence(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
candidatesJSON string,
) error {
var candidates []struct {
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
return usecase.ErrRepositoryInvariant
}
for _, candidate := range candidates {
if err := verifyExecutionEvidenceIDs(
ctx, tx, write.TaskID, write.ExecutionID, candidate.EvidenceAssetIDs,
); err != nil {
return err
}
}
return nil
}
func validateSelectedEvidence(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
selectedJSON *string,
) error {
if selectedJSON == nil {
return nil
}
var selected struct {
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if err := json.Unmarshal([]byte(*selectedJSON), &selected); err != nil {
return usecase.ErrRepositoryInvariant
}
return verifyExecutionEvidenceIDs(
ctx, tx, write.TaskID, write.ExecutionID, selected.EvidenceAssetIDs,
)
}
func validateCompleteCandidate(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
) error {
if outcome.Outcome == nil || *outcome.Outcome != "CANDIDATE_ACCEPTED" {
return nil
}
if outcome.ExecutionMode == nil || outcome.SelectedCandidateJSON == nil {
return usecase.ErrRepositoryInvariant
}
var mode string
var candidatesJSON string
err := tx.QueryRowContext(
ctx,
`SELECT execution_mode, candidates_json
FROM execution_candidate_batches
WHERE execution_id = ? AND task_id = ?`,
write.ExecutionID,
write.TaskID,
).Scan(&mode, &candidatesJSON)
if errors.Is(err, sql.ErrNoRows) {
return usecase.ErrTaskStateConflict
}
if err != nil {
return repositoryFailure(err)
}
if mode != *outcome.ExecutionMode {
return usecase.ErrTaskStateConflict
}
var selected usecase.ExecutionCandidate
var candidates []usecase.ExecutionCandidate
if err := json.Unmarshal([]byte(*outcome.SelectedCandidateJSON), &selected); err != nil {
return usecase.ErrRepositoryInvariant
}
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
return usecase.ErrRepositoryInvariant
}
for _, candidate := range candidates {
if candidate.Ordinal != selected.Ordinal {
continue
}
stored, storedErr := json.Marshal(candidate)
selectedJSON, selectedErr := json.Marshal(selected)
if storedErr != nil || selectedErr != nil {
return usecase.ErrRepositoryInvariant
}
if string(stored) == string(selectedJSON) {
return nil
}
return usecase.ErrTaskStateConflict
}
return usecase.ErrTaskStateConflict
}
func validateEvidenceIDsFromOutcome(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
evidenceJSON *string,
) error {
if evidenceJSON == nil {
return nil
}
var evidenceIDs []string
if err := json.Unmarshal([]byte(*evidenceJSON), &evidenceIDs); err != nil {
return usecase.ErrRepositoryInvariant
}
return verifyExecutionEvidenceIDs(
ctx, tx, write.TaskID, write.ExecutionID, evidenceIDs,
)
}
func verifyExecutionEvidenceIDs(
ctx context.Context,
tx *sql.Tx,
taskID string,
executionID string,
ids []string,
) error {
seen := map[string]struct{}{}
for _, evidenceID := range ids {
evidenceID = strings.TrimSpace(evidenceID)
if _, duplicate := seen[evidenceID]; duplicate {
return usecase.ErrTaskStateConflict
}
seen[evidenceID] = struct{}{}
var exists int
err := tx.QueryRowContext(
ctx,
`SELECT EXISTS (
SELECT 1 FROM execution_evidence_assets
WHERE id = ? AND task_id = ? AND execution_id = ?
)`,
evidenceID,
taskID,
executionID,
).Scan(&exists)
if err != nil {
return repositoryFailure(err)
}
if exists != 1 {
return usecase.ErrTaskStateConflict
}
}
return nil
}
func getExecutionReport(
ctx context.Context,
queryer queryer,
taskID string,
executionID string,
) (*domain.ExecutionReport, error) {
report := &domain.ExecutionReport{
Events: make([]domain.ExecutionEvent, 0),
EvidenceAssets: make([]domain.ExecutionEvidenceAsset, 0),
}
events, err := queryer.QueryContext(
ctx,
`SELECT id, task_id, execution_id, step, event_type, message,
occurred_at, received_at, received_after_execution_expiry
FROM execution_events
WHERE task_id = ? AND execution_id = ?
ORDER BY occurred_at ASC, id ASC`,
taskID,
executionID,
)
if err != nil {
return nil, repositoryFailure(err)
}
defer events.Close()
for events.Next() {
var event domain.ExecutionEvent
var occurredAt, receivedAt string
if err := events.Scan(
&event.ID,
&event.TaskID,
&event.ExecutionID,
&event.Step,
&event.Type,
&event.Message,
&occurredAt,
&receivedAt,
&event.ReceivedAfterExecutionExpiry,
); err != nil {
return nil, repositoryFailure(err)
}
var parseErr error
event.OccurredAt, parseErr = parseTimestamp(occurredAt)
if parseErr == nil {
event.ReceivedAt, parseErr = parseTimestamp(receivedAt)
}
if parseErr != nil {
return nil, repositoryFailure(parseErr)
}
report.Events = append(report.Events, event)
}
if err := events.Err(); err != nil {
return nil, repositoryFailure(err)
}
evidenceRows, err := queryer.QueryContext(
ctx,
`SELECT id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
FROM execution_evidence_assets
WHERE task_id = ? AND execution_id = ?
ORDER BY created_at ASC, id ASC`,
taskID,
executionID,
)
if err != nil {
return nil, repositoryFailure(err)
}
defer evidenceRows.Close()
for evidenceRows.Next() {
var evidence domain.ExecutionEvidenceAsset
var createdAt string
if err := evidenceRows.Scan(
&evidence.ID,
&evidence.TaskID,
&evidence.ExecutionID,
&evidence.MediaType,
&evidence.SizeBytes,
&evidence.SHA256,
&evidence.StorageKey,
&createdAt,
&evidence.ReceivedAfterExecutionExpiry,
); err != nil {
return nil, repositoryFailure(err)
}
parsed, err := parseTimestamp(createdAt)
if err != nil {
return nil, repositoryFailure(err)
}
evidence.CreatedAt = parsed
report.EvidenceAssets = append(report.EvidenceAssets, evidence)
}
if err := evidenceRows.Err(); err != nil {
return nil, repositoryFailure(err)
}
var batch domain.ExecutionCandidateBatch
var provenance, recommendation sql.NullString
var receivedAt string
err = queryer.QueryRowContext(
ctx,
`SELECT task_id, execution_id, task_content_sha256, execution_mode,
search_query, provenance_json, candidates_json, recommendation_json,
received_at, received_after_execution_expiry
FROM execution_candidate_batches
WHERE task_id = ? AND execution_id = ?`,
taskID,
executionID,
).Scan(
&batch.TaskID,
&batch.ExecutionID,
&batch.TaskContentSHA256,
&batch.ExecutionMode,
&batch.SearchQuery,
&provenance,
&batch.CandidatesJSON,
&recommendation,
&receivedAt,
&batch.ReceivedAfterExecutionExpiry,
)
if err == nil {
if provenance.Valid {
batch.ProvenanceJSON = &provenance.String
}
if recommendation.Valid {
batch.RecommendationJSON = &recommendation.String
}
batch.ReceivedAt, err = parseTimestamp(receivedAt)
if err != nil {
return nil, repositoryFailure(err)
}
report.CandidateBatch = &batch
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, repositoryFailure(err)
}
var outcome domain.ExecutionOutcome
var mode, hash, result, reason, selected, evidenceIDs, code, message, step sql.NullString
var retryable sql.NullBool
var outcomeReceivedAt string
err = queryer.QueryRowContext(
ctx,
`SELECT task_id, execution_id, result_type, execution_mode,
task_content_sha256, outcome, operator_reason, selected_candidate_json,
evidence_asset_ids_json, error_code, error_message, error_step, retryable, order_submitted,
received_at, received_after_execution_expiry
FROM execution_outcomes
WHERE task_id = ? AND execution_id = ?`,
taskID,
executionID,
).Scan(
&outcome.TaskID,
&outcome.ExecutionID,
&outcome.ResultType,
&mode,
&hash,
&result,
&reason,
&selected,
&evidenceIDs,
&code,
&message,
&step,
&retryable,
&outcome.OrderSubmitted,
&outcomeReceivedAt,
&outcome.ReceivedAfterExecutionExpiry,
)
if err == nil {
outcome.ExecutionMode = nullableStringFromSQL(mode)
outcome.TaskContentSHA256 = nullableStringFromSQL(hash)
outcome.Outcome = nullableStringFromSQL(result)
outcome.OperatorReason = nullableStringFromSQL(reason)
outcome.SelectedCandidateJSON = nullableStringFromSQL(selected)
outcome.EvidenceAssetIDsJSON = nullableStringFromSQL(evidenceIDs)
outcome.ErrorCode = nullableStringFromSQL(code)
outcome.ErrorMessage = nullableStringFromSQL(message)
outcome.ErrorStep = nullableStringFromSQL(step)
if retryable.Valid {
outcome.Retryable = &retryable.Bool
}
outcome.ReceivedAt, err = parseTimestamp(outcomeReceivedAt)
if err != nil {
return nil, repositoryFailure(err)
}
report.Outcome = &outcome
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, repositoryFailure(err)
}
return report, nil
}
func nullableStringFromSQL(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
var _ usecase.ExecutionResultRepository = (*Store)(nil)
@@ -20,6 +20,11 @@ type queryRower interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type queryer interface {
queryRower
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}
type rowScanner interface {
Scan(...any) error
}
@@ -332,6 +337,13 @@ func nullableInt64(value *int64) any {
return *value
}
func nullableBool(value *bool) any {
if value == nil {
return nil
}
return *value
}
func repositoryFailure(err error) error {
if err == nil {
return nil
@@ -313,11 +313,19 @@ func (s *Store) GetTaskDetail(
} else {
executionPointer = &execution
}
var report *domain.ExecutionReport
if executionPointer != nil {
report, err = getExecutionReport(ctx, tx, taskID, executionPointer.ID)
if err != nil {
return domain.TaskDetail{}, err
}
}
detail := domain.TaskDetail{
Task: task,
Asset: asset,
Execution: executionPointer,
Events: events,
Report: report,
}
if err := tx.Commit(); err != nil {
return domain.TaskDetail{}, repositoryFailure(err)
@@ -24,12 +24,13 @@ const (
)
type AdminServices struct {
Assets *usecase.AssetService
Tasks *usecase.TaskService
Assets *usecase.AssetService
Tasks *usecase.TaskService
Results *usecase.ExecutionResultService
}
func (s AdminServices) validate() error {
if s.Assets == nil || s.Tasks == nil {
if s.Assets == nil || s.Tasks == nil || s.Results == nil {
return errors.New("admin services are required")
}
return nil
@@ -49,10 +50,38 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
routes.POST("/api/v1/tasks", handler.createTask)
routes.GET("/api/v1/tasks", handler.listTasks)
routes.GET("/api/v1/tasks/:id", handler.taskDetail)
routes.GET(
"/api/v1/tasks/:id/evidence/:evidence_id/content",
handler.evidenceContent,
)
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
return nil
}
func (h *adminHandlers) evidenceContent(ctx *gin.Context) {
result, err := h.services.Results.OpenEvidence(
ctx.Request.Context(),
ctx.Param("id"),
ctx.Param("evidence_id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
defer result.Content.Close()
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", result.Evidence.MediaType)
ctx.Header("Content-Length", strconv.FormatInt(result.Evidence.SizeBytes, 10))
ctx.Header("ETag", `"`+result.Evidence.SHA256+`"`)
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header(
"Content-Disposition",
`inline; filename="`+result.Evidence.ID+`.jpg"`,
)
ctx.Status(http.StatusOK)
_, _ = io.Copy(ctx.Writer, result.Content)
}
func (h *adminHandlers) uploadAsset(ctx *gin.Context) {
if !hasMediaType(ctx, "multipart/form-data") {
writePublicError(
@@ -317,6 +346,10 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
if detail.Execution != nil {
execution = executionResponse(*detail.Execution)
}
var executionReport any
if detail.Report != nil {
executionReport = executionReportResponse(detail.Report)
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"id": detail.Task.ID,
@@ -337,6 +370,7 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
"derived_requirement": nil,
"claim": claim,
"execution": execution,
"execution_report": executionReport,
"events": events,
"assets": []gin.H{
assetResponse(detail.Asset),
@@ -344,6 +378,78 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
})
}
func executionReportResponse(report *domain.ExecutionReport) gin.H {
events := make([]gin.H, 0, len(report.Events))
for _, event := range report.Events {
events = append(events, gin.H{
"id": event.ID,
"step": event.Step,
"type": event.Type,
"message": event.Message,
"occurred_at": formatTime(event.OccurredAt),
"received_at": formatTime(event.ReceivedAt),
"received_after_execution_expiry": event.ReceivedAfterExecutionExpiry,
})
}
evidence := make([]gin.H, 0, len(report.EvidenceAssets))
for _, asset := range report.EvidenceAssets {
evidence = append(evidence, gin.H{
"id": asset.ID,
"media_type": asset.MediaType,
"size_bytes": asset.SizeBytes,
"sha256": asset.SHA256,
"created_at": formatTime(asset.CreatedAt),
"received_after_execution_expiry": asset.ReceivedAfterExecutionExpiry,
})
}
response := gin.H{
"events": events,
"evidence": evidence,
}
if batch := report.CandidateBatch; batch != nil {
response["candidate_batch"] = gin.H{
"task_content_sha256": batch.TaskContentSHA256,
"execution_mode": batch.ExecutionMode,
"search_query": batch.SearchQuery,
"provenance": decodedAuditJSON(batch.ProvenanceJSON),
"candidates": decodedAuditJSON(&batch.CandidatesJSON),
"recommendation": decodedAuditJSON(batch.RecommendationJSON),
"received_at": formatTime(batch.ReceivedAt),
"received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry,
}
}
if outcome := report.Outcome; outcome != nil {
response["outcome"] = gin.H{
"result_type": outcome.ResultType,
"execution_mode": outcome.ExecutionMode,
"task_content_sha256": outcome.TaskContentSHA256,
"outcome": outcome.Outcome,
"operator_reason": outcome.OperatorReason,
"selected_candidate": decodedAuditJSON(outcome.SelectedCandidateJSON),
"evidence_asset_ids": decodedAuditJSON(outcome.EvidenceAssetIDsJSON),
"error_code": outcome.ErrorCode,
"error_message": outcome.ErrorMessage,
"error_step": outcome.ErrorStep,
"retryable": outcome.Retryable,
"order_submitted": outcome.OrderSubmitted,
"received_at": formatTime(outcome.ReceivedAt),
"received_after_execution_expiry": outcome.ReceivedAfterExecutionExpiry,
}
}
return response
}
func decodedAuditJSON(value *string) any {
if value == nil {
return nil
}
var decoded any
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
return nil
}
return decoded
}
func (h *adminHandlers) cancelTask(ctx *gin.Context) {
if !hasMediaType(ctx, "application/json") {
writePublicError(
@@ -349,8 +349,12 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
if err != nil {
t.Fatalf("usecase.NewTaskService() error = %v", err)
}
results, err := usecase.NewExecutionResultService(repositories, files, clock, ids)
if err != nil {
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
}
registrar, err := NewAdminRouteRegistrar(
AdminServices{Assets: assets, Tasks: tasks},
AdminServices{Assets: assets, Tasks: tasks, Results: results},
emptyAdminWeb{},
)
if err != nil {
@@ -19,10 +19,11 @@ const claimTokenHeader = "X-Claim-Token"
type DeviceServices struct {
Lifecycle *usecase.LifecycleService
Assets *usecase.AssetService
Results *usecase.ExecutionResultService
}
func (services DeviceServices) validate() error {
if services.Lifecycle == nil || services.Assets == nil {
if services.Lifecycle == nil || services.Assets == nil || services.Results == nil {
return errors.New("device services are required")
}
return nil
@@ -68,6 +69,11 @@ func NewDeviceRouteRegistrar(
"/api/v1/tasks/:id/cancel-ack",
handler.acknowledgeCancellation,
)
routes.POST("/api/v1/tasks/:id/events", handler.appendEvents)
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
return nil
}, nil
}
@@ -367,6 +373,257 @@ func (handler *deviceHandlers) acknowledgeCancellation(
})
}
func (handler *deviceHandlers) appendEvents(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
Events []usecase.ClientExecutionEvent `json:"events"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
replayed, err := handler.services.Results.AppendEvents(
ctx.Request.Context(),
usecase.AppendExecutionEventsCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
Events: request.Events,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
}
func (handler *deviceHandlers) uploadEvidence(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
if !isEvidenceMediaType(ctx.GetHeader("Content-Type")) {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"ASSET_MEDIA_TYPE_UNSUPPORTED",
"JPEG, PNG, or WebP evidence is required",
false,
gin.H{},
)
return
}
generation, err := strconv.ParseInt(
strings.TrimSpace(ctx.GetHeader("X-Claim-Generation")),
10,
64,
)
if err != nil {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"EXECUTION_RESULT_INVALID",
"execution result request is invalid",
false,
fieldDetails("claim_generation", "must be a positive integer"),
)
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxMultipartBytes)
result, err := handler.services.Results.UploadEvidence(
ctx.Request.Context(),
usecase.UploadExecutionEvidenceCommand{
Identity: handler.executionResultIdentity(
ctx,
principal,
ctx.GetHeader("X-Execution-ID"),
generation,
),
DeclaredMediaType: ctx.GetHeader("Content-Type"),
Content: ctx.Request.Body,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusCreated, gin.H{
"evidence": deviceEvidenceResponse(result.Evidence),
"replayed": result.Replayed,
})
}
func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
TaskContentSHA256 string `json:"task_content_sha256"`
ExecutionMode string `json:"execution_mode"`
SearchQuery string `json:"search_query"`
Provenance *usecase.ExecutionProvenance `json:"provenance"`
Candidates []usecase.ExecutionCandidate `json:"candidates"`
Recommendation *usecase.CandidateRecommendation `json:"recommendation"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
replayed, err := handler.services.Results.StoreCandidates(
ctx.Request.Context(),
usecase.StoreExecutionCandidatesCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
TaskContentSHA256: request.TaskContentSHA256,
ExecutionMode: request.ExecutionMode,
SearchQuery: request.SearchQuery,
Provenance: request.Provenance,
Candidates: request.Candidates,
Recommendation: request.Recommendation,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
}
func (handler *deviceHandlers) completeTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
TaskContentSHA256 string `json:"task_content_sha256"`
ExecutionMode string `json:"execution_mode"`
Outcome string `json:"outcome"`
OperatorReason string `json:"operator_reason"`
Candidate *usecase.ExecutionCandidate `json:"candidate"`
OrderSubmitted bool `json:"order_submitted"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
result, replayed, err := handler.services.Results.Complete(
ctx.Request.Context(),
usecase.CompleteExecutionCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
TaskContentSHA256: request.TaskContentSHA256,
ExecutionMode: request.ExecutionMode,
Outcome: request.Outcome,
OperatorReason: request.OperatorReason,
Candidate: request.Candidate,
OrderSubmitted: request.OrderSubmitted,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result),
"replayed": replayed,
})
}
func (handler *deviceHandlers) failTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Step string `json:"step"`
Retryable bool `json:"retryable"`
} `json:"error"`
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
result, replayed, err := handler.services.Results.Fail(
ctx.Request.Context(),
usecase.FailExecutionCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
ErrorCode: request.Error.Code,
ErrorMessage: request.Error.Message,
ErrorStep: request.Error.Step,
Retryable: request.Error.Retryable,
EvidenceAssetIDs: request.EvidenceAssetIDs,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result),
"replayed": replayed,
})
}
func (handler *deviceHandlers) executionResultIdentity(
ctx *gin.Context,
principal domain.AuthPrincipal,
executionID string,
claimGeneration int64,
) usecase.ExecutionResultIdentity {
return usecase.ExecutionResultIdentity{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: executionID,
ClaimGeneration: claimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
}
}
func isEvidenceMediaType(value string) bool {
return hasEvidenceMediaType(value, "image/jpeg") ||
hasEvidenceMediaType(value, "image/png") ||
hasEvidenceMediaType(value, "image/webp")
}
func hasEvidenceMediaType(value string, expected string) bool {
return strings.EqualFold(strings.TrimSpace(strings.Split(value, ";")[0]), expected)
}
func deviceEvidenceResponse(evidence domain.ExecutionEvidenceAsset) gin.H {
return gin.H{
"id": evidence.ID,
"media_type": evidence.MediaType,
"size_bytes": evidence.SizeBytes,
"sha256": evidence.SHA256,
"created_at": formatTime(evidence.CreatedAt),
"received_after_execution_expiry": evidence.ReceivedAfterExecutionExpiry,
}
}
type lifecycleTransitionRequest struct {
DeviceID string `json:"device_id"`
ClaimGeneration int64 `json:"claim_generation"`
@@ -15,6 +15,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
@@ -478,6 +479,141 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken)
}
func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
taskID := fixture.createPendingTask(t)
claimedResponse := fixture.claimNext(t, "claim-for-results", testOpaqueToken)
requireDeviceStatus(t, claimedResponse, http.StatusOK)
var claimed deviceLifecycleResponse
decodeResponse(t, claimedResponse, &claimed)
startResponse := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/start",
contentType: "application/json",
body: strings.NewReader(fmt.Sprintf(
`{"claim_generation":%d,"expected_version":%d}`,
claimed.Task.ClaimGeneration,
claimed.Task.Version,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "start-for-results",
})
requireDeviceStatus(t, startResponse, http.StatusOK)
var started deviceLifecycleResponse
decodeResponse(t, startResponse, &started)
occurredAt := time.Now().UTC().Format(time.RFC3339Nano)
events := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/events",
contentType: "application/json",
body: strings.NewReader(fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"events":[{"event_id":"00000000-0000-4000-8000-000000000701","step":"SEARCH","type":"SEARCH_STARTED","message":"开始采集候选","occurred_at":%q}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
occurredAt,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-events-1",
})
requireDeviceStatus(t, events, http.StatusOK)
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
body: deviceReferenceImage(t, 701),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-evidence-1",
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, evidence, http.StatusCreated)
var evidenceResponse struct {
Evidence struct {
ID string `json:"id"`
} `json:"evidence"`
}
decodeResponse(t, evidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" {
t.Fatalf("evidence response = %s", evidence.Body.String())
}
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
if err != nil {
t.Fatalf("get task for content hash: %v", err)
}
taskHash := usecase.TaskContentSHA256(detail.Task)
candidatePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
evidenceResponse.Evidence.ID,
)
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(candidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-1",
})
requireDeviceStatus(t, candidates, http.StatusOK)
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":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null},"order_submitted":false}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
evidenceResponse.Evidence.ID,
)
complete := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/complete",
contentType: "application/json",
body: strings.NewReader(completePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-complete-1",
})
requireDeviceStatus(t, complete, http.StatusOK)
assertNoClaimSecret(t, complete, testOpaqueToken)
replayed := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/complete",
contentType: "application/json",
body: strings.NewReader(completePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-complete-1",
})
requireDeviceStatus(t, replayed, http.StatusOK)
if !strings.Contains(replayed.Body.String(), `"replayed":true`) {
t.Fatalf("terminal replay response = %s", replayed.Body.String())
}
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
if err != nil {
t.Fatalf("get task result detail: %v", err)
}
if detail.Task.Status != domain.TaskStatusSucceeded ||
detail.Report == nil ||
detail.Report.Outcome == nil ||
detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 1 ||
detail.Report.CandidateBatch == nil {
t.Fatalf("execution report = %+v", detail.Report)
}
}
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
@@ -710,10 +846,15 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
if err != nil {
t.Fatalf("usecase.NewLifecycleService() error = %v", err)
}
results, err := usecase.NewExecutionResultService(store, files, clock, ids)
if err != nil {
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
}
deviceRoutes, err := NewDeviceRouteRegistrar(
DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
},
)
if err != nil {
@@ -880,13 +1021,15 @@ func deviceReferenceImage(t *testing.T, index int) io.Reader {
}
type deviceRequest struct {
method string
target string
contentType string
body io.Reader
bearerToken string
claimToken string
idempotencyKey string
method string
target string
contentType string
body io.Reader
bearerToken string
claimToken string
idempotencyKey string
executionID string
claimGeneration int64
}
func performDeviceRequest(
@@ -911,6 +1054,13 @@ func performDeviceRequest(
if spec.idempotencyKey != "" {
request.Header.Set("Idempotency-Key", spec.idempotencyKey)
}
if spec.executionID != "" {
request.Header.Set("X-Execution-ID", spec.executionID)
request.Header.Set(
"X-Claim-Generation",
strconv.FormatInt(spec.claimGeneration, 10),
)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
@@ -756,6 +756,7 @@ type taskDetailView struct {
UpdatedAt time.Time
CanCancel bool
CancelRequiresAck bool
ExecutionReport *ExecutionReport
}
type taskDetailPage struct {
@@ -789,6 +790,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
CanCancel: canCancelTaskStatus(task.Status),
CancelRequiresAck: task.Status == "RUNNING" ||
task.Status == "WAITING_CONFIRMATION",
ExecutionReport: task.ExecutionReport,
}
}
@@ -45,6 +45,62 @@ textarea {
font: inherit;
}
.execution-audit {
margin-top: 20px;
}
.audit-summary {
margin-bottom: 18px;
}
.audit-list {
margin: 8px 0 0;
padding-left: 20px;
}
.audit-list li {
margin: 6px 0;
overflow-wrap: anywhere;
}
.audit-evidence-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.audit-evidence {
margin: 8px 0;
}
.audit-evidence img {
display: block;
width: 100%;
max-height: 240px;
object-fit: contain;
border: 1px solid var(--line);
background: var(--surface-soft);
}
.audit-evidence figcaption {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.audit-json {
max-height: 360px;
margin: 8px 0 16px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
background: var(--surface-soft);
font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
button,
input,
select {
@@ -73,6 +73,39 @@
</aside>
</div>
{{with .Task.ExecutionReport}}
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
<h2 id="execution-audit-heading">执行审计</h2>
{{if .Outcome}}
<dl class="definition-list audit-summary">
<dt>结果类型</dt><dd>{{.Outcome.ResultType}}</dd>
<dt>人工理由</dt><dd>{{if .Outcome.OperatorReason}}{{.Outcome.OperatorReason}}{{else}}未提供{{end}}</dd>
<dt>订单提交</dt><dd>{{if .Outcome.OrderSubmitted}}是{{else}}否{{end}}</dd>
{{if .Outcome.ErrorCode}}<dt>失败信息</dt><dd>{{.Outcome.ErrorCode}}:{{.Outcome.ErrorMessage}}</dd>{{end}}
</dl>
{{end}}
{{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}}
{{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}}
{{if .Candidates}}<h3>候选与评估</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
{{if .Recommendation}}<h3>本地推荐</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
{{if .Evidence}}
<h3>证据截图</h3>
<div class="audit-evidence-grid">
{{range .Evidence}}<figure class="audit-evidence">
<img src="{{.ContentURL}}" alt="执行证据截图">
<figcaption>SHA-256 {{.SHA256}}({{.SizeBytes}} bytes){{if .ReceivedAfterExecutionExpiry}},授权到期后补报{{end}}</figcaption>
</figure>{{end}}
</div>
{{end}}
{{if .Events}}
<h3>执行事件</h3>
<ul class="audit-list">
{{range .Events}}<li><time datetime="{{machineTime .OccurredAt}}">{{displayTime .OccurredAt}}</time> · {{.Step}} · {{.Message}}{{if .ReceivedAfterExecutionExpiry}}(授权到期后补报){{end}}</li>{{end}}
</ul>
{{end}}
</section>
{{end}}
{{if .Task.CanCancel}}
<noscript>
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
@@ -58,6 +58,47 @@ type Task struct {
ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
ExecutionReport *ExecutionReport
}
type ExecutionReport struct {
Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence
Mode string
SearchQuery string
Provenance string
Candidates string
Recommendation string
Outcome *ExecutionReportOutcome
}
type ExecutionReportEvent struct {
Step string
Type string
Message string
OccurredAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReportEvidence struct {
ID string
ContentURL string
SHA256 string
SizeBytes int64
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReportOutcome struct {
ResultType string
Outcome string
OperatorReason string
ErrorCode string
ErrorMessage string
ErrorStep string
OrderSubmitted bool
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type UploadReferenceInput struct {
@@ -2,7 +2,9 @@ package webui
import (
"context"
"encoding/json"
"errors"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
@@ -176,9 +178,82 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
func taskFromDetail(detail domain.TaskDetail) Task {
task := taskFromPurchase(detail.Task)
task.ReferenceAssetID = detail.Asset.ID
task.ExecutionReport = executionReportFrom(detail.Report)
return task
}
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
if report == nil {
return nil
}
result := &ExecutionReport{
Events: make([]ExecutionReportEvent, 0, len(report.Events)),
Evidence: make([]ExecutionReportEvidence, 0, len(report.EvidenceAssets)),
}
for _, event := range report.Events {
result.Events = append(result.Events, ExecutionReportEvent{
Step: event.Step,
Type: event.Type,
Message: event.Message,
OccurredAt: event.OccurredAt,
ReceivedAfterExecutionExpiry: event.ReceivedAfterExecutionExpiry,
})
}
for _, evidence := range report.EvidenceAssets {
result.Evidence = append(result.Evidence, ExecutionReportEvidence{
ID: evidence.ID,
ContentURL: "/api/v1/tasks/" + evidence.TaskID + "/evidence/" + evidence.ID + "/content",
SHA256: evidence.SHA256,
SizeBytes: evidence.SizeBytes,
CreatedAt: evidence.CreatedAt,
ReceivedAfterExecutionExpiry: evidence.ReceivedAfterExecutionExpiry,
})
}
if batch := report.CandidateBatch; batch != nil {
result.Mode = batch.ExecutionMode
result.SearchQuery = batch.SearchQuery
result.Provenance = prettyAuditJSON(batch.ProvenanceJSON)
result.Candidates = prettyAuditJSON(&batch.CandidatesJSON)
result.Recommendation = prettyAuditJSON(batch.RecommendationJSON)
}
if outcome := report.Outcome; outcome != nil {
result.Outcome = &ExecutionReportOutcome{
ResultType: outcome.ResultType,
Outcome: stringValue(outcome.Outcome),
OperatorReason: stringValue(outcome.OperatorReason),
ErrorCode: stringValue(outcome.ErrorCode),
ErrorMessage: stringValue(outcome.ErrorMessage),
ErrorStep: stringValue(outcome.ErrorStep),
OrderSubmitted: outcome.OrderSubmitted,
ReceivedAt: outcome.ReceivedAt,
ReceivedAfterExecutionExpiry: outcome.ReceivedAfterExecutionExpiry,
}
}
return result
}
func prettyAuditJSON(value *string) string {
if value == nil || strings.TrimSpace(*value) == "" {
return ""
}
var decoded any
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
return ""
}
formatted, err := json.MarshalIndent(decoded, "", " ")
if err != nil {
return ""
}
return string(formatted)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func mapUsecaseError(err error) error {
var typed *usecase.Error
if !errors.As(err, &typed) {
@@ -0,0 +1,58 @@
package usecase
import (
"context"
"time"
"cmroubao/backend-api/internal/domain"
)
type ExecutionResultAuthorization struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimTokenHash string
}
type ExecutionResultWrite struct {
ExecutionResultAuthorization
Operation string
IdempotencyKey string
RequestHash string
Now time.Time
}
type ExecutionResultRepository interface {
AppendExecutionEvents(
context.Context,
ExecutionResultWrite,
[]domain.ExecutionEvent,
) (bool, error)
CreateExecutionEvidence(
context.Context,
ExecutionResultWrite,
domain.ExecutionEvidenceAsset,
) (domain.ExecutionEvidenceAsset, bool, error)
StoreExecutionCandidates(
context.Context,
ExecutionResultWrite,
domain.ExecutionCandidateBatch,
) (bool, error)
CompleteExecution(
context.Context,
ExecutionResultWrite,
domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error)
FailExecution(
context.Context,
ExecutionResultWrite,
domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error)
GetExecutionEvidence(
context.Context,
string,
string,
) (domain.ExecutionEvidenceAsset, error)
}
@@ -0,0 +1,761 @@
package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
executionResultEventsOperation = "EVENTS"
executionResultEvidenceOperation = "EVIDENCE"
executionResultCandidatesOperation = "CANDIDATES"
executionResultCompleteOperation = "COMPLETE"
executionResultFailOperation = "FAIL"
manualFirstMode = "MANUAL_FIRST"
aiAssistedMode = "AI_ASSISTED"
)
type ExecutionResultService struct {
repository ExecutionResultRepository
store ReferenceImageStore
clock Clock
ids IDGenerator
}
type ExecutionResultIdentity struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimToken string
IdempotencyKey string
}
type ClientExecutionEvent struct {
ID string `json:"event_id"`
Step string `json:"step"`
Type string `json:"type"`
Message string `json:"message"`
OccurredAt string `json:"occurred_at"`
}
type AppendExecutionEventsCommand struct {
Identity ExecutionResultIdentity
Events []ClientExecutionEvent
}
type UploadExecutionEvidenceCommand struct {
Identity ExecutionResultIdentity
DeclaredMediaType string
Content io.Reader
}
type ExecutionProvenance struct {
ProviderID string `json:"provider_id"`
Model string `json:"model"`
PromptVersion string `json:"prompt_version"`
SchemaVersion int `json:"schema_version"`
}
type CandidateEvaluation struct {
Decision string `json:"decision"`
Score float64 `json:"score"`
Matched []string `json:"matched"`
MissingOrUncertain []string `json:"missing_or_uncertain"`
RejectionReasons []string `json:"rejection_reasons"`
Confidence float64 `json:"confidence"`
}
type ExecutionCandidate struct {
Ordinal int `json:"ordinal"`
Title string `json:"title"`
SKUText string `json:"sku_text"`
Price string `json:"price"`
ProductURL string `json:"product_url"`
ImageURL string `json:"image_url"`
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
Evaluation *CandidateEvaluation `json:"evaluation"`
}
type CandidateRecommendation struct {
CandidateOrdinal int `json:"candidate_ordinal"`
PolicyVersion string `json:"policy_version"`
Reasons []string `json:"reasons"`
}
type StoreExecutionCandidatesCommand struct {
Identity ExecutionResultIdentity
TaskContentSHA256 string
ExecutionMode string
SearchQuery string
Provenance *ExecutionProvenance
Candidates []ExecutionCandidate
Recommendation *CandidateRecommendation
}
type CompleteExecutionCommand struct {
Identity ExecutionResultIdentity
TaskContentSHA256 string
ExecutionMode string
Outcome string
OperatorReason string
Candidate *ExecutionCandidate
OrderSubmitted bool
}
type FailExecutionCommand struct {
Identity ExecutionResultIdentity
ErrorCode string
ErrorMessage string
ErrorStep string
Retryable bool
EvidenceAssetIDs []string
}
type UploadExecutionEvidenceResult struct {
Evidence domain.ExecutionEvidenceAsset
Replayed bool
}
type ExecutionEvidenceContent struct {
Evidence domain.ExecutionEvidenceAsset
Content io.ReadCloser
}
func NewExecutionResultService(
repository ExecutionResultRepository,
store ReferenceImageStore,
clock Clock,
ids IDGenerator,
) (*ExecutionResultService, error) {
if repository == nil || store == nil || clock == nil || ids == nil {
return nil, errors.New("execution result service dependencies are required")
}
return &ExecutionResultService{
repository: repository,
store: store,
clock: clock,
ids: ids,
}, nil
}
func (service *ExecutionResultService) AppendEvents(
ctx context.Context,
command AppendExecutionEventsCommand,
) (bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return false, err
}
if len(command.Events) < 1 || len(command.Events) > 32 {
return false, executionResultInvalid("events", "must contain 1 to 32 events")
}
now := service.clock.Now().UTC()
events := make([]domain.ExecutionEvent, 0, len(command.Events))
seen := make(map[string]struct{}, len(command.Events))
for _, event := range command.Events {
parsed, eventErr := normalizeClientEvent(event, identity.TaskID, identity.ExecutionID, now)
if eventErr != nil {
return false, eventErr
}
if _, found := seen[parsed.ID]; found {
return false, executionResultInvalid("events", "event_id must be unique")
}
seen[parsed.ID] = struct{}{}
events = append(events, parsed)
}
requestHash, err := executionResultHash(command.Events)
if err != nil {
return false, internalExecutionResultFailure(err)
}
replayed, err := service.repository.AppendExecutionEvents(
ctx,
service.write(identity, executionResultEventsOperation, requestHash, now),
events,
)
if err != nil {
return false, wrapLifecycleRepositoryError(err)
}
return replayed, nil
}
func (service *ExecutionResultService) UploadEvidence(
ctx context.Context,
command UploadExecutionEvidenceCommand,
) (UploadExecutionEvidenceResult, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return UploadExecutionEvidenceResult{}, err
}
if command.Content == nil {
return UploadExecutionEvidenceResult{}, executionResultInvalid("file", "required")
}
evidenceID, err := service.ids.NewID()
if err != nil {
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
}
normalized, err := service.store.Put(
ctx,
evidenceID,
command.DeclaredMediaType,
command.Content,
)
if err != nil {
return UploadExecutionEvidenceResult{}, mapImageStoreError(err)
}
cleanup := func() {
_ = service.store.Delete(context.Background(), normalized.StorageKey)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(struct {
InputSHA256 string `json:"input_sha256"`
}{InputSHA256: normalized.InputSHA256})
if err != nil {
cleanup()
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
}
evidence, replayed, err := service.repository.CreateExecutionEvidence(
ctx,
service.write(identity, executionResultEvidenceOperation, requestHash, now),
domain.ExecutionEvidenceAsset{
ID: evidenceID,
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
MediaType: normalized.MediaType,
SizeBytes: normalized.SizeBytes,
SHA256: normalized.SHA256,
StorageKey: normalized.StorageKey,
CreatedAt: now,
},
)
if err != nil {
cleanup()
return UploadExecutionEvidenceResult{}, wrapLifecycleRepositoryError(err)
}
if replayed {
cleanup()
}
return UploadExecutionEvidenceResult{Evidence: evidence, Replayed: replayed}, nil
}
func (service *ExecutionResultService) StoreCandidates(
ctx context.Context,
command StoreExecutionCandidatesCommand,
) (bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return false, err
}
if err := validateCandidateCommand(command); err != nil {
return false, err
}
provenanceJSON, candidatesJSON, recommendationJSON, err := candidateJSON(command)
if err != nil {
return false, internalExecutionResultFailure(err)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(command)
if err != nil {
return false, internalExecutionResultFailure(err)
}
replayed, err := service.repository.StoreExecutionCandidates(
ctx,
service.write(identity, executionResultCandidatesOperation, requestHash, now),
domain.ExecutionCandidateBatch{
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
TaskContentSHA256: command.TaskContentSHA256,
ExecutionMode: command.ExecutionMode,
SearchQuery: strings.TrimSpace(command.SearchQuery),
ProvenanceJSON: provenanceJSON,
CandidatesJSON: candidatesJSON,
RecommendationJSON: recommendationJSON,
ReceivedAt: now,
},
)
if err != nil {
return false, wrapLifecycleRepositoryError(err)
}
return replayed, nil
}
func (service *ExecutionResultService) Complete(
ctx context.Context,
command CompleteExecutionCommand,
) (domain.PurchaseTask, bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateCompleteCommand(command); err != nil {
return domain.PurchaseTask{}, false, err
}
selectedJSON, err := optionalJSON(command.Candidate)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(command)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
mode := command.ExecutionMode
taskHash := command.TaskContentSHA256
outcome := command.Outcome
reason := strings.TrimSpace(command.OperatorReason)
task, replayed, err := service.repository.CompleteExecution(
ctx,
service.write(identity, executionResultCompleteOperation, requestHash, now),
domain.ExecutionOutcome{
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
ResultType: "COMPLETE",
ExecutionMode: &mode,
TaskContentSHA256: &taskHash,
Outcome: &outcome,
OperatorReason: &reason,
SelectedCandidateJSON: selectedJSON,
OrderSubmitted: false,
ReceivedAt: now,
},
)
if err != nil {
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
}
return task, replayed, nil
}
func (service *ExecutionResultService) Fail(
ctx context.Context,
command FailExecutionCommand,
) (domain.PurchaseTask, bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateFailCommand(command); err != nil {
return domain.PurchaseTask{}, false, err
}
evidenceJSON, err := optionalJSON(command.EvidenceAssetIDs)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(command)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
code := strings.TrimSpace(command.ErrorCode)
message := strings.TrimSpace(command.ErrorMessage)
step := strings.TrimSpace(command.ErrorStep)
retryable := command.Retryable
task, replayed, err := service.repository.FailExecution(
ctx,
service.write(identity, executionResultFailOperation, requestHash, now),
domain.ExecutionOutcome{
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
ResultType: "FAIL",
EvidenceAssetIDsJSON: evidenceJSON,
ErrorCode: &code,
ErrorMessage: &message,
ErrorStep: &step,
Retryable: &retryable,
OrderSubmitted: false,
ReceivedAt: now,
},
)
if err != nil {
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
}
return task, replayed, nil
}
func (service *ExecutionResultService) OpenEvidence(
ctx context.Context,
taskID string,
evidenceID string,
) (ExecutionEvidenceContent, error) {
evidence, err := service.repository.GetExecutionEvidence(ctx, taskID, evidenceID)
if err != nil {
return ExecutionEvidenceContent{}, wrapLifecycleRepositoryError(err)
}
content, err := service.store.Open(ctx, evidence.StorageKey)
if err != nil {
return ExecutionEvidenceContent{}, mapImageStoreError(err)
}
return ExecutionEvidenceContent{Evidence: evidence, Content: content}, nil
}
func (service *ExecutionResultService) write(
identity ExecutionResultIdentity,
operation string,
requestHash string,
now time.Time,
) ExecutionResultWrite {
return ExecutionResultWrite{
ExecutionResultAuthorization: ExecutionResultAuthorization{
UserID: identity.UserID,
DeviceID: identity.DeviceID,
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
ClaimGeneration: identity.ClaimGeneration,
ClaimTokenHash: hashSecret(identity.ClaimToken),
},
Operation: operation,
IdempotencyKey: identity.IdempotencyKey,
RequestHash: requestHash,
Now: now,
}
}
func normalizeExecutionIdentity(
identity ExecutionResultIdentity,
) (ExecutionResultIdentity, error) {
identity.UserID = strings.TrimSpace(identity.UserID)
identity.DeviceID = strings.TrimSpace(identity.DeviceID)
identity.TaskID = strings.TrimSpace(identity.TaskID)
identity.ExecutionID = strings.TrimSpace(identity.ExecutionID)
identity.IdempotencyKey = strings.TrimSpace(identity.IdempotencyKey)
fields := map[string]string{}
if !isUUID(identity.UserID) {
fields["user_id"] = "must be a UUID"
}
if !isUUID(identity.DeviceID) {
fields["device_id"] = "must be a UUID"
}
if !isUUID(identity.TaskID) {
fields["task_id"] = "must be a UUID"
}
if !isUUID(identity.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if identity.ClaimGeneration < 1 {
fields["claim_generation"] = "must be a positive integer"
}
if strings.TrimSpace(identity.ClaimToken) == "" {
fields["claim_token"] = "required"
}
if len([]byte(identity.IdempotencyKey)) == 0 ||
len([]byte(identity.IdempotencyKey)) > maxIdempotencyKeyBytes ||
!isPrintableASCII(identity.IdempotencyKey) {
fields["idempotency_key"] = "must be printable ASCII up to 128 bytes"
}
if len(fields) > 0 {
return ExecutionResultIdentity{}, invalidError(
"EXECUTION_RESULT_INVALID",
"execution result request is invalid",
fields,
)
}
return identity, nil
}
func normalizeClientEvent(
event ClientExecutionEvent,
taskID string,
executionID string,
now time.Time,
) (domain.ExecutionEvent, error) {
if !isUUID(strings.TrimSpace(event.ID)) {
return domain.ExecutionEvent{}, executionResultInvalid("event_id", "must be a UUID")
}
step := strings.TrimSpace(event.Step)
typeValue := strings.TrimSpace(event.Type)
message := strings.TrimSpace(event.Message)
if !validLifecycleStep(step) {
return domain.ExecutionEvent{}, executionResultInvalid("step", "must be uppercase ASCII")
}
if !validLifecycleStep(typeValue) {
return domain.ExecutionEvent{}, executionResultInvalid("type", "must be uppercase ASCII")
}
if !validAuditText(message, 1000) {
return domain.ExecutionEvent{}, executionResultInvalid("message", "is invalid or contains sensitive material")
}
occurredAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(event.OccurredAt))
if err != nil || occurredAt.After(now.Add(5*time.Minute)) ||
occurredAt.Before(now.Add(-7*24*time.Hour)) {
return domain.ExecutionEvent{}, executionResultInvalid("occurred_at", "must be a recent RFC3339 timestamp")
}
return domain.ExecutionEvent{
ID: strings.TrimSpace(event.ID),
TaskID: taskID,
ExecutionID: executionID,
Step: step,
Type: typeValue,
Message: message,
OccurredAt: occurredAt.UTC(),
ReceivedAt: now,
}, nil
}
func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
if !sha256Pattern.MatchString(command.TaskContentSHA256) {
return executionResultInvalid("task_content_sha256", "must be lowercase SHA-256")
}
if !validExecutionMode(command.ExecutionMode) {
return executionResultInvalid("execution_mode", "must be MANUAL_FIRST or AI_ASSISTED")
}
if !validAuditText(command.SearchQuery, 512) {
return executionResultInvalid("search_query", "is invalid")
}
if len(command.Candidates) > 5 {
return executionResultInvalid("candidates", "must contain at most 5 candidates")
}
if command.ExecutionMode == aiAssistedMode {
if !validProvenance(command.Provenance) {
return executionResultInvalid("provenance", "is required for AI_ASSISTED")
}
} else if command.Provenance != nil {
return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST")
}
for index, candidate := range command.Candidates {
if candidate.Ordinal != index+1 || !validCandidate(candidate, command.ExecutionMode) {
return executionResultInvalid("candidates", "must be continuous, bounded observations")
}
}
if command.Recommendation != nil {
recommendation := command.Recommendation
if recommendation.CandidateOrdinal < 1 ||
recommendation.CandidateOrdinal > len(command.Candidates) ||
!validAuditText(recommendation.PolicyVersion, 128) ||
!validStringList(recommendation.Reasons, 8, 160) {
return executionResultInvalid("recommendation", "is invalid")
}
}
return nil
}
func validateCompleteCommand(command CompleteExecutionCommand) error {
if !sha256Pattern.MatchString(command.TaskContentSHA256) ||
!validExecutionMode(command.ExecutionMode) ||
!validOutcome(command.Outcome) ||
!validAuditText(command.OperatorReason, 1000) ||
command.OrderSubmitted {
return executionResultInvalid("complete", "contains an invalid outcome or order state")
}
if command.Outcome == "CANDIDATE_ACCEPTED" {
if command.Candidate == nil || !validCandidate(*command.Candidate, command.ExecutionMode) {
return executionResultInvalid("candidate", "is required for CANDIDATE_ACCEPTED")
}
} else if command.Candidate != nil {
return executionResultInvalid("candidate", "must be omitted for this outcome")
}
return nil
}
func validateFailCommand(command FailExecutionCommand) error {
if !validLifecycleStep(strings.TrimSpace(command.ErrorCode)) ||
!validLifecycleStep(strings.TrimSpace(command.ErrorStep)) ||
!validAuditText(command.ErrorMessage, 1000) {
return executionResultInvalid("error", "is invalid")
}
if len(command.EvidenceAssetIDs) > 5 {
return executionResultInvalid("evidence_asset_ids", "must contain at most 5 items")
}
for _, id := range command.EvidenceAssetIDs {
if !isUUID(strings.TrimSpace(id)) {
return executionResultInvalid("evidence_asset_ids", "must contain UUIDs")
}
}
return nil
}
func validCandidate(candidate ExecutionCandidate, mode string) bool {
if candidate.Ordinal < 1 ||
!validAuditText(candidate.Title, 512) ||
!validOptionalAuditText(candidate.SKUText, 512) ||
!validOptionalAuditText(candidate.Price, 64) ||
!validObservationURL(candidate.ProductURL) ||
!validObservationURL(candidate.ImageURL) ||
len(candidate.EvidenceAssetIDs) > 5 {
return false
}
for _, id := range candidate.EvidenceAssetIDs {
if !isUUID(strings.TrimSpace(id)) {
return false
}
}
if mode == manualFirstMode {
return candidate.Evaluation == nil
}
return validEvaluation(candidate.Evaluation)
}
func validEvaluation(value *CandidateEvaluation) bool {
if value == nil || value.Score < 0 || value.Score > 1 ||
value.Confidence < 0 || value.Confidence > 1 {
return false
}
if value.Decision != "REVIEW" && value.Decision != "REJECT" &&
value.Decision != "MANUAL_REQUIRED" {
return false
}
return validStringList(value.Matched, 12, 160) &&
validStringList(value.MissingOrUncertain, 12, 160) &&
validStringList(value.RejectionReasons, 12, 160)
}
func validProvenance(value *ExecutionProvenance) bool {
return value != nil &&
validAuditText(value.ProviderID, 64) &&
validAuditText(value.Model, 256) &&
validAuditText(value.PromptVersion, 128) &&
value.SchemaVersion >= 1 && value.SchemaVersion <= 32
}
func validExecutionMode(value string) bool {
return value == manualFirstMode || value == aiAssistedMode
}
func validOutcome(value string) bool {
switch value {
case "CANDIDATE_ACCEPTED", "CANDIDATE_REJECTED", "NO_MATCH", "MANUAL_REQUIRED":
return true
default:
return false
}
}
func validObservationURL(value string) bool {
if strings.TrimSpace(value) == "" {
return true
}
parsed, err := url.Parse(strings.TrimSpace(value))
return err == nil &&
(parsed.Scheme == "https" || parsed.Scheme == "http") &&
parsed.Host != "" &&
parsed.User == nil &&
len(value) <= 2048
}
func validAuditText(value string, maximum int) bool {
value = strings.TrimSpace(value)
if value == "" || !utf8.ValidString(value) || len([]byte(value)) > maximum {
return false
}
lower := strings.ToLower(value)
return !strings.Contains(lower, "authorization:") &&
!strings.Contains(lower, "api_key") &&
!strings.Contains(lower, "bearer ")
}
func validOptionalAuditText(value string, maximum int) bool {
return strings.TrimSpace(value) == "" || validAuditText(value, maximum)
}
func validStringList(values []string, maximumItems int, maximumText int) bool {
if len(values) > maximumItems {
return false
}
seen := map[string]struct{}{}
for _, value := range values {
if !validAuditText(value, maximumText) {
return false
}
key := strings.ToLower(strings.TrimSpace(value))
if _, duplicate := seen[key]; duplicate {
return false
}
seen[key] = struct{}{}
}
return true
}
func candidateJSON(command StoreExecutionCandidatesCommand) (*string, string, *string, error) {
var provenance *string
if command.Provenance != nil {
encoded, err := json.Marshal(command.Provenance)
if err != nil {
return nil, "", nil, err
}
value := string(encoded)
provenance = &value
}
candidates, err := json.Marshal(command.Candidates)
if err != nil {
return nil, "", nil, err
}
var recommendation *string
if command.Recommendation != nil {
encoded, err := json.Marshal(command.Recommendation)
if err != nil {
return nil, "", nil, err
}
value := string(encoded)
recommendation = &value
}
return provenance, string(candidates), recommendation, nil
}
func optionalJSON(value any) (*string, error) {
if value == nil {
return nil, nil
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
result := string(encoded)
return &result, nil
}
func executionResultHash(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func TaskContentSHA256(task domain.PurchaseTask) string {
budget := ""
if task.MaxBudgetCents != nil {
budget = strconv.FormatInt(*task.MaxBudgetCents, 10)
}
payload := strings.Join([]string{
task.Title,
task.Description,
task.SKU,
task.ImageAssetID,
strconv.Itoa(task.Quantity),
budget,
task.Currency,
}, "\x00")
sum := sha256.Sum256([]byte(payload))
return hex.EncodeToString(sum[:])
}
func executionResultInvalid(field string, message string) error {
return invalidError(
"EXECUTION_RESULT_INVALID",
"execution result request is invalid",
map[string]string{field: message},
)
}
func internalExecutionResultFailure(err error) error {
return newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
var sha256Pattern = regexp.MustCompile("^[0-9a-f]{64}$")
@@ -0,0 +1,204 @@
-- +goose Up
CREATE TABLE execution_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,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
step TEXT NOT NULL
CHECK (
length(trim(step)) > 0
AND length(CAST(step AS BLOB)) <= 64
),
event_type TEXT NOT NULL
CHECK (
length(trim(event_type)) > 0
AND length(CAST(event_type AS BLOB)) <= 64
),
message TEXT NOT NULL
CHECK (length(CAST(message AS BLOB)) <= 1000),
occurred_at TEXT NOT NULL,
received_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1))
);
CREATE INDEX execution_events_execution_occurred_idx
ON execution_events (execution_id, occurred_at ASC, id ASC);
CREATE TABLE execution_evidence_assets (
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,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
media_type TEXT NOT NULL
CHECK (media_type = 'image/jpeg'),
size_bytes INTEGER NOT NULL
CHECK (size_bytes > 0),
sha256 TEXT NOT NULL
CHECK (
length(sha256) = 64
AND sha256 NOT GLOB '*[^0-9a-f]*'
),
storage_key TEXT NOT NULL UNIQUE
CHECK (
length(storage_key) > 0
AND substr(storage_key, 1, 1) <> '/'
AND instr(storage_key, '\') = 0
AND instr(storage_key, '..') = 0
),
created_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1))
);
CREATE INDEX execution_evidence_execution_created_idx
ON execution_evidence_assets (execution_id, created_at ASC, id ASC);
CREATE TABLE execution_candidate_batches (
execution_id TEXT PRIMARY KEY NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_content_sha256 TEXT NOT NULL
CHECK (
length(task_content_sha256) = 64
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
),
execution_mode TEXT NOT NULL
CHECK (execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
search_query TEXT NOT NULL
CHECK (
length(trim(search_query)) > 0
AND length(CAST(search_query AS BLOB)) <= 512
),
provenance_json TEXT,
candidates_json TEXT NOT NULL
CHECK (length(CAST(candidates_json AS BLOB)) <= 65536),
recommendation_json TEXT,
received_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1))
);
CREATE TABLE execution_outcomes (
execution_id TEXT PRIMARY KEY NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
result_type TEXT NOT NULL
CHECK (result_type IN ('COMPLETE', 'FAIL')),
execution_mode TEXT
CHECK (execution_mode IS NULL OR execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
task_content_sha256 TEXT
CHECK (
task_content_sha256 IS NULL
OR (
length(task_content_sha256) = 64
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
)
),
outcome TEXT
CHECK (
outcome IS NULL
OR outcome IN (
'CANDIDATE_ACCEPTED',
'CANDIDATE_REJECTED',
'NO_MATCH',
'MANUAL_REQUIRED'
)
),
operator_reason TEXT
CHECK (
operator_reason IS NULL
OR length(CAST(operator_reason AS BLOB)) <= 1000
),
selected_candidate_json TEXT,
evidence_asset_ids_json TEXT,
error_code TEXT
CHECK (error_code IS NULL OR length(CAST(error_code AS BLOB)) <= 64),
error_message TEXT
CHECK (error_message IS NULL OR length(CAST(error_message AS BLOB)) <= 1000),
error_step TEXT
CHECK (error_step IS NULL OR length(CAST(error_step AS BLOB)) <= 64),
retryable INTEGER
CHECK (retryable IS NULL OR retryable IN (0, 1)),
order_submitted INTEGER NOT NULL DEFAULT 0
CHECK (order_submitted = 0),
received_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1)),
CHECK (
(result_type = 'COMPLETE'
AND execution_mode IS NOT NULL
AND task_content_sha256 IS NOT NULL
AND outcome IS NOT NULL
AND operator_reason IS NOT NULL
AND error_code IS NULL)
OR
(result_type = 'FAIL'
AND error_code IS NOT NULL
AND error_message IS NOT NULL
AND error_step IS NOT NULL)
)
);
CREATE TABLE execution_result_requests (
user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
device_id TEXT NOT NULL
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
operation TEXT NOT NULL
CHECK (operation IN ('EVENTS', 'EVIDENCE', 'CANDIDATES', 'COMPLETE', 'FAIL')),
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]*'
),
claim_token_sha256 TEXT NOT NULL
CHECK (
length(claim_token_sha256) = 64
AND claim_token_sha256 NOT GLOB '*[^0-9a-f]*'
),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
resource_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_id, operation, idempotency_key)
);
-- +goose Down
CREATE TEMP TABLE execution_results_v5_down_guard (
allowed INTEGER NOT NULL
CHECK (allowed = 1)
);
INSERT INTO execution_results_v5_down_guard (allowed)
SELECT CASE
WHEN EXISTS (SELECT 1 FROM execution_events)
OR EXISTS (SELECT 1 FROM execution_evidence_assets)
OR EXISTS (SELECT 1 FROM execution_candidate_batches)
OR EXISTS (SELECT 1 FROM execution_outcomes)
OR EXISTS (SELECT 1 FROM execution_result_requests)
THEN 0
ELSE 1
END;
DROP TABLE execution_results_v5_down_guard;
DROP TABLE execution_result_requests;
DROP TABLE execution_outcomes;
DROP TABLE execution_candidate_batches;
DROP INDEX execution_evidence_execution_created_idx;
DROP TABLE execution_evidence_assets;
DROP INDEX execution_events_execution_occurred_idx;
DROP TABLE execution_events;
+4 -6
View File
@@ -54,13 +54,11 @@
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
T-205 原子领取/租约状态机和 T-206 Android 登录、手动领取、参考图预览、前台服务、
有限离线与恢复均已完成。下一步按编号开始 T-207,把现有端上 VLM/拼多多候选流程
接入已领取任务并实现事件、证据和终态结果回传。
候选优化数据集已经登记为 T-208;不得跳过 T-206/T-207 的第一版端到端闭环,提前
建设报表、训练管线或外部商品抓取。
T-205 原子领取/租约状态机、T-206 Android 登录/有限离线和 T-207 本地 VLM、候选、
证据及结果回传均已完成。下一步按编号开始 T-208,建立逐候选结构化人工理由、修订
历史和优化数据闭环;不得提前建设报表、训练管线或外部商品抓取。
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
T-206 增加有限离线执行;T-207 复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。
严格按以下顺序推进:
+3 -3
View File
@@ -14,7 +14,7 @@
| Android 自动化 | `AccessibilityService` 语义节点动作;Shizuku 保留为上游兼容路径 | 搜索与 5 个候选已真机验证 | T-101/T-102 已完成精确输入、结果页确认、候选卡识别、详情截图和验证返回;没有坐标或 shell 降级。上游 `main` 仍保留 Shizuku 13.1.5。 |
| Android 候选证据 | API 30+ `AccessibilityService.takeScreenshot` + App cache JSON/PNG | 已真机验证 | 匿名 PNG 与只含 SHA-256、计数、尺寸的 manifest;转换/压缩使用独立 executor,文件 IO 使用 `Dispatchers.IO`。API 26-29 明确不支持该截图探针。 |
| 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 |
| Android 长任务 | 前台服务 + 持续通知 | T-206 已实现 | 30 秒 heartbeat、加密状态恢复、离线截止和 `SAFE_STOPPED`;真实候选工作流接入属于 T-207。 |
| Android 长任务 | 前台服务 + 持续通知 | T-207 已实现 | 30 秒 heartbeat、加密状态恢复、离线截止、`SAFE_STOPPED` 和加密结果 outbox。 |
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
| 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | 已接入并收敛 | 只作为一次性脚手架输入;演示路由、默认 CORS、`.env` 自动加载、单例和 fatal 行为均已删除。 |
| 后端框架 | Gin v1.11.0 | MVP 已定 | 这是 `go.mod` 明确支持 Go 1.23.0 的最高已核实 Gin 版本。 |
@@ -25,8 +25,8 @@
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
| App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 |
| VLM 接入 | Android 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | 手机直连 provider;后端不代理模型。T-207 把 Key 迁移到 Keystore-backed 加密存储,并回传非秘密 provenance。 |
| 离线执行 | 服务端运行授权 + Android 加密本地状态 | T-206 已实现 | 默认 30 分钟、5-120 分钟可配;30 秒 best-effort heartbeat,过期持久安全停止且 RUNNING 不自动重分配;完整 outbox 属于 T-207。 |
| VLM 接入 | Android 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | T-207 已实现,供应商待定 | 手机直连 provider;后端不代理模型;Key 使用 Keystore 加密存储,并回传非秘密 provenance。 |
| 离线执行 | 服务端运行授权 + Android 加密本地状态 | T-207 已实现 | 默认 30 分钟、5-120 分钟可配;30 秒 best-effort heartbeat,过期持久安全停止且 RUNNING 不自动重分配;结果通过加密 outbox 重放。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
| 后端测试 | 标准库 `testing` + `httptest` | T-206 已验证 | 当前含子测试 193 次覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、有限离线租约、取消确认、跨连接与真实 TCP 并发。 |
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + MockWebServer 4.12.0 + 真实设备 smoke | T-206 已验证 | 184 次测试覆盖 runner、页面分类、VLM schema、人工确认、后台端点/JSON/图片同源契约和离线时钟;PKG110 完成私有 fixture/VLM mock 及后台登录、领取、95 秒断线、恢复、取消真机 smoke。 |
+11
View File
@@ -450,6 +450,7 @@ App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"events": [
{
"event_id": "client-generated-uuid",
@@ -464,6 +465,13 @@ App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺
`message` 不能包含凭证或完整个人敏感信息;同一 `event_id` 重放不重复插入。
### `POST /api/v1/tasks/{task_id}/evidence`
以原始 `image/jpeg`、`image/png` 或 `image/webp` body 上传一张受控截图;请求必须带
`Authorization: Bearer`、`X-Claim-Token`、`Idempotency-Key`、`X-Execution-ID` 和
`X-Claim-Generation`。服务端规范化存为 JPEG 并返回 asset ID、SHA-256、尺寸及
`received_after_execution_expiry`,不接受图片 URL,也不从第三方下载图片。
### `POST /api/v1/tasks/{task_id}/candidates`
批量保存当前 execution 实际检查的最多 5 个候选,必须带 `Idempotency-Key`:
@@ -471,6 +479,7 @@ App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"task_content_sha256": "64-char-lowercase-hex",
"execution_mode": "AI_ASSISTED",
"search_query": "黑色 20L 双肩包",
@@ -518,6 +527,7 @@ App 使用加密 outbox 按“事件 -> evidence asset -> 候选 -> 终态”顺
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"task_content_sha256": "64-char-lowercase-hex",
"execution_mode": "AI_ASSISTED",
"outcome": "CANDIDATE_ACCEPTED",
@@ -557,6 +567,7 @@ T-207 第一版要求 `operator_reason` 为人员输入的简短审计说明,
```json
{
"execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f",
"claim_generation": 1,
"error": {
"code": "PDD_RISK_CONTROL",
"message": "检测到平台风险提示,已停止自动化",
+14 -10
View File
@@ -5,22 +5,22 @@
## 当前快照
- 日期:2026-07-27
- 阶段:T-206 App 领取、有限离线和恢复完成,下一步 T-207
- 阶段:T-207 App 本地 VLM、候选、证据和结果回传完成,下一步 T-208
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-206
均已纳入 Git 历史
均已纳入 Git 历史;T-207 待本次提交
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
task-scoped 参考图和 `authctl`
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`test assembleDebug` 成功;App 两个变体、task contract 和导入器
共 32 份报告、184 次测试,0 failure、0 error、0 skipped
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 含子测试共 193 次通过;
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
均通过
- 测试:T-207 运行 `:app:testDebugUnitTest` 和 `:app:assembleDebug` 通过;Debug APK
已安装并在 PKG110 启动到任务登录页,无 crash/ANR
- 后端测试:T-207 运行 `GOTOOLCHAIN=local go test -count=1 ./...`、全包 race 和
`go vet ./...` 通过;临时 SQLite migration 已验证 `status -> up -> down -> up`
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px
@@ -40,6 +40,10 @@
状态、严格 HTTPS/Debug loopback 地址策略、`HttpTaskSource`、参考图校验、手动领取、
预览/start 二次确认、release、30 秒前台 heartbeat、有限离线、`SAFE_STOPPED`、
取消确认和进程恢复;后台任务不读取或覆盖本地 VLM provider
- T-207 结果闭环:`execution_events`、受控截图、候选批次、模型出处、人工结论与
失败记录均按 execution 存储;Android 使用加密 outbox 按事件、截图、候选和终态
顺序回传,授权到期补报单独审计,所有终态固定 `order_submitted=false`。管理任务
详情展示模型/候选/人工理由/事件/证据摘要,不保存 VLM Key、完整 endpoint 或原始响应。
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -86,7 +90,7 @@
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
| `docs/tasks/T-205.md` | DONE | 原子 claim、租约、execution 和取消安全确认 |
| `docs/tasks/T-206.md` | DONE | App 领取、默认 30 分钟有限离线执行、恢复和安全停止 |
| `docs/tasks/T-207.md` | TODO | App 本地 VLM、Key 加密、候选、事件、截图和结果回传 |
| `docs/tasks/T-207.md` | DONE | App 本地 VLM、Key 加密、候选、事件、截图和结果回传 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
@@ -97,9 +101,9 @@
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-206。
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-207。
- 正在进行:无。
- 下一个可领取任务:T-207 App 本地 VLM、候选、事件、截图和结果回传。
- 下一个可领取任务:T-208 候选决策数据与人工理由闭环。
- 后置任务:T-208 候选决策数据与人工理由闭环;不得跳过 T-206/T-207 提前实现。
## 当前可运行内容
+18 -9
View File
@@ -4,7 +4,7 @@ title: 接入 App 本地 VLM、候选、事件和结果回传
phase: 2
deps:
- T-206
status: TODO
status: DONE
created: 2026-07-27
context_ref: 45d1436
work_branch: null
@@ -93,19 +93,19 @@ T-206 跑通任务领取和有限离线生命周期后,还需要把现有 App
## 验收要点
- [ ] Roubao 原有独立模式和 provider 选择可继续使用;旧 Key 成功迁移后普通
- [x] Roubao 原有独立模式和 provider 选择可继续使用;旧 Key 成功迁移后普通
SharedPreferences 无明文,迁移/解密失败安全阻断真实调用。
- [ ] 后台任务 payload 无 provider 地址、模型或 Key;伪造这些字段被拒绝/忽略且
- [x] 后台任务 payload 无 provider 地址、模型或 Key;伪造这些字段被拒绝/忽略且
不能修改 App 设置。
- [ ] `MANUAL_FIRST` 在无 VLM 时能完成候选采集和人工确认;`AI_ASSISTED` 由 App
- [x] `MANUAL_FIRST` 在无 VLM 时能完成候选采集和人工确认;`AI_ASSISTED` 由 App
直连配置的 HTTPS provider,抓包/测试证明管理后端不代理模型。
- [ ] 最多一次需求提取、最多 5 个候选评估;SKU/数量/预算保持原值,低置信度、
- [x] 最多一次需求提取、最多 5 个候选评估;SKU/数量/预算保持原值,低置信度、
无效 schema、超预算、证据不足和越权输出转人工或拒绝。
- [ ] 结果包含 provider/model/prompt/schema 和证据哈希但不含 Key、Authorization、
- [x] 结果包含 provider/model/prompt/schema 和证据哈希但不含 Key、Authorization、
完整 endpoint、订单号、店铺名或供应商原始响应正文。
- [ ] 事件、截图、候选、complete/fail 在断网、进程重启和响应丢失后不重复;授权
- [x] 事件、截图、候选、complete/fail 在断网、进程重启和响应丢失后不重复;授权
过期补报可审计,后台不会把 RUNNING 自动分给其他设备。
- [ ] 接受、拒绝、无匹配和转人工均要求 `operator_reason`;管理详情可还原执行过程,
- [x] 接受、拒绝、无匹配和转人工均要求 `operator_reason`;管理详情可还原执行过程,
并明确显示 `order_submitted=false`。
- [ ] migration up/down/up、Go unit/integration/race/vet、Android unit/instrumented、
Playwright 三视口、根 `init.ps1` 和 OnePlus PKG110 真机 smoke 通过。
@@ -119,4 +119,13 @@ T-206 跑通任务领取和有限离线生命周期后,还需要把现有 App
## 执行记录
- 尚未开始。
- 2026-07-27:开始实现后端 execution 结果审计、Android 加密 outbox 与本地 VLM/
候选流程接入。
- 2026-07-27:实现 `00005_execution_results`、设备结果 API、管理详情审计展示和
Android Keystore 凭证 fail-closed 迁移。Android 候选流程按手工/AI 模式生成加密
outbox,结果以同一幂等键重放;Go HTTP 集成测试和 Android debug/unit test 已通过。
- 2026-07-27:未执行真实 VLM、真实后台或 OnePlus PKG110 端到端 smoke;没有真实凭证
的真实模型调用未执行。OnePlus PKG110 已安装 Debug APK 并启动到任务登录页,无
crash/ANR;真实后台和端到端候选回传仍需要单独的测试账号/任务。
- 2026-07-27:根目录 `init.ps1`(`RUN_START_COMMAND=1`)通过,覆盖 Android
全模块测试与 Debug 构建、Go 测试/vet/构建,并重新安装、冷启动 Debug APK。
+10
View File
@@ -164,3 +164,13 @@
- 影响:管理员创建的任务已能在真机领取并受控运行,断开后台超过旧 90 秒窗口仍由
同一手机持有,重连优先同步;下一步 T-207 把现有端上 VLM/拼多多候选流程接入
`HttpTaskSource`,回传事件、证据和终态结果。
## 2026-07-27 本地 VLM、候选和结果审计
- 类型:阶段完成
- 内容:完成 T-207;后端新增 execution 事件、受控证据、候选批次、终态和幂等请求
migration,以及设备结果 API 和管理详情审计区。Android 将 provider Key 迁移为
fail-closed Keystore 存储,支持人工优先/AI 辅助模式、人工理由和加密 outbox。
- 影响:手机直接调用本机已配置的 provider,管理后端不保存 Key、完整 endpoint 或
原始模型输出;结果按“事件、截图、候选、终态”顺序重放。下一步 T-208 扩展为可训练
的逐候选结构化人工理由与修订历史。