feat(t207): audit local procurement results
This commit is contained in:
@@ -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
|
||||
|
||||
+79
@@ -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
|
||||
}
|
||||
}
|
||||
+60
@@ -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}")
|
||||
|
||||
|
||||
+36
-2
@@ -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(
|
||||
|
||||
+450
-3
@@ -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 =
|
||||
|
||||
+83
-2
@@ -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"
|
||||
|
||||
+53
-2
@@ -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,
|
||||
|
||||
+42
-7
@@ -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)
|
||||
|
||||
+34
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
+57
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
@@ -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 配置。
|
||||
|
||||
严格按以下顺序推进:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 扩展为可训练
|
||||
的逐候选结构化人工理由与修订历史。
|
||||
|
||||
Reference in New Issue
Block a user