feat(t207): audit local procurement results

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