feat(t207): audit local procurement results
This commit is contained in:
@@ -50,6 +50,10 @@ import com.roubao.autopilot.vlm.RequirementExtractionResult
|
||||
import com.roubao.autopilot.vlm.RequirementExtractor
|
||||
import com.roubao.autopilot.vlm.RequirementProbeState
|
||||
import com.roubao.autopilot.vlm.RequirementProviderEndpointPolicy
|
||||
import com.roubao.autopilot.vlm.REQUIREMENT_PROMPT_VERSION
|
||||
import com.roubao.autopilot.vlm.REQUIREMENT_SCHEMA_VERSION
|
||||
import com.roubao.autopilot.vlm.CANDIDATE_EVALUATION_PROMPT_VERSION
|
||||
import com.roubao.autopilot.vlm.CANDIDATE_EVALUATION_SCHEMA_VERSION
|
||||
import com.roubao.autopilot.vlm.AndroidCandidateEvaluationVlmGateway
|
||||
import com.roubao.autopilot.vlm.CandidateBatchConclusion
|
||||
import com.roubao.autopilot.vlm.CandidateEvaluationFailureCode
|
||||
@@ -78,7 +82,14 @@ import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
|
||||
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
|
||||
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
|
||||
import com.roubao.autopilot.procurement.LoginInput
|
||||
import com.roubao.autopilot.procurement.ExecutionCandidateBatchDraft
|
||||
import com.roubao.autopilot.procurement.ExecutionCandidateDraft
|
||||
import com.roubao.autopilot.procurement.ExecutionCandidateEvaluation
|
||||
import com.roubao.autopilot.procurement.ExecutionEvidenceDraft
|
||||
import com.roubao.autopilot.procurement.ExecutionMode
|
||||
import com.roubao.autopilot.procurement.ExecutionProvenanceSnapshot
|
||||
import com.roubao.autopilot.procurement.ProcurementRepository
|
||||
import com.roubao.autopilot.task.RequirementProbeFixture
|
||||
import com.roubao.autopilot.workflow.WorkflowReport
|
||||
import com.roubao.autopilot.workflow.WorkflowRunner
|
||||
import com.roubao.autopilot.workflow.WorkflowState
|
||||
@@ -123,6 +134,8 @@ class MainActivity : ComponentActivity() {
|
||||
private val candidateReviewBatch = mutableStateOf<CandidateReviewBatch?>(null)
|
||||
private val candidateEvaluationFailureCode =
|
||||
mutableStateOf<CandidateEvaluationFailureCode?>(null)
|
||||
private val taskCandidateDrafts =
|
||||
mutableStateOf<List<ExecutionCandidateDraft>>(emptyList())
|
||||
private var searchProbeRunner: WorkflowRunner? = null
|
||||
private var searchProbeJob: Job? = null
|
||||
private var requirementProbeJob: Job? = null
|
||||
@@ -354,9 +367,9 @@ class MainActivity : ComponentActivity() {
|
||||
procurementRepository.claimNext(readiness)
|
||||
}
|
||||
},
|
||||
onStart = {
|
||||
onStart = { mode ->
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.start()
|
||||
startProcurementExecution(mode)
|
||||
}
|
||||
},
|
||||
onRelease = {
|
||||
@@ -411,8 +424,12 @@ class MainActivity : ComponentActivity() {
|
||||
onStopCandidateEvaluation = {
|
||||
stopCandidateEvaluation()
|
||||
},
|
||||
onAcceptCandidate = { acceptRecommendedCandidate() },
|
||||
onRejectCandidates = { rejectCandidateReview() },
|
||||
onAcceptCandidate = { reason ->
|
||||
acceptRecommendedCandidate(reason)
|
||||
},
|
||||
onRejectCandidates = { reason ->
|
||||
rejectCandidateReview(reason)
|
||||
},
|
||||
onStart = { startSearchProbe() },
|
||||
onStop = { stopSearchProbe() }
|
||||
)
|
||||
@@ -502,6 +519,43 @@ class MainActivity : ComponentActivity() {
|
||||
startActivity(launchIntent)
|
||||
}
|
||||
|
||||
private suspend fun startProcurementExecution(mode: ExecutionMode) {
|
||||
if (mode == ExecutionMode.MANUAL_FIRST) {
|
||||
procurementRepository.start(mode)
|
||||
return
|
||||
}
|
||||
val settings = settingsManager.settings.value
|
||||
val provider = settings.currentProvider
|
||||
if (!provider.supportsRequirementExtraction ||
|
||||
settings.apiKey.isBlank() ||
|
||||
settings.baseUrl.isBlank() ||
|
||||
settings.model.isBlank() ||
|
||||
!settingsManager.isSecureCredentialStorageAvailable ||
|
||||
!RequirementProviderEndpointPolicy.isAllowed(
|
||||
settings.baseUrl,
|
||||
settings.apiKey
|
||||
)
|
||||
) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
"AI 辅助模式需要本机安全配置可用的 VLM",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
return
|
||||
}
|
||||
procurementRepository.start(
|
||||
mode = mode,
|
||||
provenance = ExecutionProvenanceSnapshot(
|
||||
mode = mode,
|
||||
providerId = provider.id,
|
||||
model = settings.model,
|
||||
promptVersion = REQUIREMENT_PROMPT_VERSION,
|
||||
schemaVersion = REQUIREMENT_SCHEMA_VERSION,
|
||||
referenceImageSha256 = ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun startSearchProbe() {
|
||||
val readiness = readinessChecker.snapshot()
|
||||
readinessSnapshot.value = readiness
|
||||
@@ -517,11 +571,22 @@ class MainActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
val procurementTask = procurementRepository.currentProbeTask()
|
||||
val procurementMode = procurementRepository.activeExecutionMode()
|
||||
val boundRequirement = requirementExtraction.value?.takeIf {
|
||||
requirementProbeState.value == RequirementProbeState.READY &&
|
||||
!it.manualReviewRequired
|
||||
}
|
||||
val searchKeyword = boundRequirement?.searchQuery ?: SEARCH_PROBE_KEYWORD
|
||||
if (procurementTask != null &&
|
||||
procurementMode == ExecutionMode.AI_ASSISTED &&
|
||||
boundRequirement == null
|
||||
) {
|
||||
Toast.makeText(this, "请先完成本地需求提取", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val searchKeyword = boundRequirement?.searchQuery ?: procurementTask
|
||||
?.let { manualSearchQuery(it.title, it.sku) }
|
||||
?: SEARCH_PROBE_KEYWORD
|
||||
val candidateAutomation = PinduoduoCandidateAutomation(
|
||||
AndroidPinduoduoCandidateDriver(this)
|
||||
)
|
||||
@@ -567,6 +632,16 @@ class MainActivity : ComponentActivity() {
|
||||
val report = runner.run(PinduoduoCandidateWorkflow.steps())
|
||||
searchProbeReport.value = report
|
||||
searchProbeState.value = report.state
|
||||
if (report.state == WorkflowState.SUCCEEDED &&
|
||||
procurementTask != null &&
|
||||
procurementMode == ExecutionMode.MANUAL_FIRST
|
||||
) {
|
||||
queueManualProcurementCandidates(
|
||||
procurementTask.title,
|
||||
searchKeyword,
|
||||
candidateEvidence.value
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
stateCollector.cancel()
|
||||
stepCollector.cancel()
|
||||
@@ -594,6 +669,16 @@ class MainActivity : ComponentActivity() {
|
||||
Toast.makeText(this, "请先停止候选评估", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (procurementRepository.currentProbeTask() != null &&
|
||||
procurementRepository.activeExecutionMode() != ExecutionMode.AI_ASSISTED
|
||||
) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
"当前后台任务为人工优先模式,不调用 VLM",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
return
|
||||
}
|
||||
candidateEvidence.value = emptyList()
|
||||
candidateSearchKeyword.value = null
|
||||
candidateRequirementSnapshot.value = null
|
||||
@@ -640,7 +725,11 @@ class MainActivity : ComponentActivity() {
|
||||
requirementFailureCode.value = null
|
||||
requirementProbeJob = lifecycleScope.launch {
|
||||
try {
|
||||
val fixture = requirementProbeSource.loadFirst().getOrElse {
|
||||
val fixture = procurementRepository.currentProbeTask()?.let { task ->
|
||||
procurementRepository.currentReferenceImageBytes()?.let { image ->
|
||||
RequirementProbeFixture(task, image)
|
||||
}
|
||||
} ?: requirementProbeSource.loadFirst().getOrElse {
|
||||
setRequirementFailure(RequirementExtractionFailureCode.SOURCE_UNAVAILABLE)
|
||||
return@launch
|
||||
} ?: run {
|
||||
@@ -809,6 +898,48 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
when (result) {
|
||||
is CandidateEvaluationResult.Completed -> {
|
||||
if (procurementRepository.activeExecutionMode() ==
|
||||
ExecutionMode.AI_ASSISTED
|
||||
) {
|
||||
val drafts = result.batch.assessments.map { assessment ->
|
||||
ExecutionCandidateDraft(
|
||||
ordinal = assessment.ordinal,
|
||||
title = "拼多多候选 ${assessment.ordinal}",
|
||||
evidenceLocalIDs = emptyList(),
|
||||
evaluation = ExecutionCandidateEvaluation(
|
||||
decision = assessment.decision.name,
|
||||
score = assessment.score,
|
||||
matched = assessment.matched,
|
||||
missingOrUncertain = assessment.missingOrUncertain,
|
||||
rejectionReasons = assessment.rejectionReasons,
|
||||
confidence = assessment.confidence
|
||||
)
|
||||
)
|
||||
}
|
||||
taskCandidateDrafts.value = procurementRepository.queueCandidateBatch(
|
||||
ExecutionCandidateBatchDraft(
|
||||
mode = ExecutionMode.AI_ASSISTED,
|
||||
searchQuery = requirement.searchQuery,
|
||||
provenance = ExecutionProvenanceSnapshot(
|
||||
mode = ExecutionMode.AI_ASSISTED,
|
||||
providerId = result.batch.providerId,
|
||||
model = result.batch.model,
|
||||
promptVersion = CANDIDATE_EVALUATION_PROMPT_VERSION,
|
||||
schemaVersion = CANDIDATE_EVALUATION_SCHEMA_VERSION,
|
||||
referenceImageSha256 =
|
||||
result.batch.requirementReferenceImageSha256
|
||||
),
|
||||
candidates = drafts
|
||||
),
|
||||
validated.map { candidate ->
|
||||
ExecutionEvidenceDraft(
|
||||
ordinal = candidate.ordinal,
|
||||
pngBytes = candidate.pngBytes,
|
||||
sha256 = candidate.sha256
|
||||
)
|
||||
}
|
||||
).orEmpty()
|
||||
}
|
||||
candidateReviewBatch.value = result.batch
|
||||
candidateEvaluationState.value = when (
|
||||
result.batch.conclusion
|
||||
@@ -838,17 +969,90 @@ class MainActivity : ComponentActivity() {
|
||||
candidateEvaluationJob?.cancel()
|
||||
}
|
||||
|
||||
private fun acceptRecommendedCandidate() {
|
||||
private suspend fun queueManualProcurementCandidates(
|
||||
taskTitle: String,
|
||||
searchQuery: String,
|
||||
evidence: List<PinduoduoCandidateEvidence>
|
||||
) {
|
||||
val validated = candidateEvidenceSource.load(evidence).getOrNull() ?: return
|
||||
taskCandidateDrafts.value = procurementRepository.queueCandidateBatch(
|
||||
ExecutionCandidateBatchDraft(
|
||||
mode = ExecutionMode.MANUAL_FIRST,
|
||||
searchQuery = searchQuery,
|
||||
provenance = null,
|
||||
candidates = validated.map { candidate ->
|
||||
ExecutionCandidateDraft(
|
||||
ordinal = candidate.ordinal,
|
||||
title = "$taskTitle 候选 ${candidate.ordinal}",
|
||||
evidenceLocalIDs = emptyList()
|
||||
)
|
||||
}
|
||||
),
|
||||
validated.map { candidate ->
|
||||
ExecutionEvidenceDraft(
|
||||
ordinal = candidate.ordinal,
|
||||
pngBytes = candidate.pngBytes,
|
||||
sha256 = candidate.sha256
|
||||
)
|
||||
}
|
||||
).orEmpty()
|
||||
if (taskCandidateDrafts.value.isNotEmpty()) {
|
||||
candidateEvaluationState.value = CandidateEvaluationState.MANUAL_REVIEW
|
||||
}
|
||||
}
|
||||
|
||||
private fun manualSearchQuery(title: String, sku: String): String =
|
||||
listOf(title.trim(), sku.trim())
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
.take(160)
|
||||
|
||||
private fun acceptRecommendedCandidate(operatorReason: String) {
|
||||
candidateEvaluationState.value = CandidateHumanReviewPolicy.accept(
|
||||
currentState = candidateEvaluationState.value,
|
||||
batch = candidateReviewBatch.value
|
||||
)
|
||||
if (candidateEvaluationState.value != CandidateEvaluationState.HUMAN_ACCEPTED &&
|
||||
taskCandidateDrafts.value.isNotEmpty()
|
||||
) {
|
||||
candidateEvaluationState.value = CandidateEvaluationState.HUMAN_ACCEPTED
|
||||
}
|
||||
if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_ACCEPTED) {
|
||||
val recommendedOrdinal = candidateReviewBatch.value
|
||||
?.recommendedCandidateOrdinal
|
||||
val candidate = taskCandidateDrafts.value.firstOrNull {
|
||||
it.ordinal == recommendedOrdinal
|
||||
} ?: taskCandidateDrafts.value.firstOrNull()
|
||||
if (candidate != null && procurementRepository.currentProbeTask() != null) {
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.completeExecution(
|
||||
outcome = "CANDIDATE_ACCEPTED",
|
||||
operatorReason = operatorReason,
|
||||
candidate = candidate
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rejectCandidateReview() {
|
||||
private fun rejectCandidateReview(operatorReason: String) {
|
||||
candidateEvaluationState.value = CandidateHumanReviewPolicy.reject(
|
||||
candidateEvaluationState.value
|
||||
)
|
||||
if (candidateEvaluationState.value == CandidateEvaluationState.HUMAN_REJECTED &&
|
||||
procurementRepository.currentProbeTask() != null
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.completeExecution(
|
||||
outcome = if (taskCandidateDrafts.value.isEmpty()) {
|
||||
"NO_MATCH"
|
||||
} else {
|
||||
"CANDIDATE_REJECTED"
|
||||
},
|
||||
operatorReason = operatorReason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCandidateEvaluationFailure(
|
||||
|
||||
@@ -127,32 +127,31 @@ class SettingsManager(context: Context) {
|
||||
context.getSharedPreferences("baozi_settings", Context.MODE_PRIVATE)
|
||||
|
||||
// 加密存储(用于敏感数据如 API Key)
|
||||
private val securePrefs: SharedPreferences by lazy {
|
||||
try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
private val securePrefs: SharedPreferences? = try {
|
||||
val masterKey = MasterKey.Builder(context.applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"baozi_secure_settings",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
secureCredentialStorageAvailable = false
|
||||
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
|
||||
prefs
|
||||
}
|
||||
EncryptedSharedPreferences.create(
|
||||
context.applicationContext,
|
||||
"baozi_secure_settings",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
secureCredentialStorageAvailable = false
|
||||
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
|
||||
null
|
||||
}
|
||||
|
||||
private val _settings = MutableStateFlow(loadSettings())
|
||||
val settings: StateFlow<AppSettings> = _settings
|
||||
private val _settings: MutableStateFlow<AppSettings>
|
||||
val settings: StateFlow<AppSettings>
|
||||
|
||||
init {
|
||||
// 迁移旧的明文 API Key 到加密存储
|
||||
migrateApiKeyToSecureStorage()
|
||||
_settings = MutableStateFlow(loadSettings())
|
||||
settings = _settings
|
||||
}
|
||||
|
||||
val isSecureCredentialStorageAvailable: Boolean
|
||||
@@ -163,13 +162,19 @@ class SettingsManager(context: Context) {
|
||||
*/
|
||||
private fun migrateApiKeyToSecureStorage() {
|
||||
val oldApiKey = prefs.getString("api_key", null)
|
||||
if (!oldApiKey.isNullOrEmpty()) {
|
||||
// 保存到加密存储
|
||||
securePrefs.edit().putString("api_key", oldApiKey).apply()
|
||||
// 删除旧的明文存储
|
||||
prefs.edit().remove("api_key").apply()
|
||||
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
|
||||
if (oldApiKey.isNullOrEmpty()) return
|
||||
val encrypted = securePrefs
|
||||
if (encrypted == null || !encrypted.edit().putString("api_key", oldApiKey).commit()) {
|
||||
secureCredentialStorageAvailable = false
|
||||
android.util.Log.e("SettingsManager", "API Key migration failed closed")
|
||||
return
|
||||
}
|
||||
if (!prefs.edit().remove("api_key").commit()) {
|
||||
secureCredentialStorageAvailable = false
|
||||
android.util.Log.e("SettingsManager", "Plaintext API Key cleanup failed closed")
|
||||
return
|
||||
}
|
||||
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
|
||||
}
|
||||
|
||||
private fun loadSettings(): AppSettings {
|
||||
@@ -191,7 +196,7 @@ class SettingsManager(context: Context) {
|
||||
}
|
||||
|
||||
// 迁移旧数据(如果有)
|
||||
val oldApiKey = securePrefs.getString("api_key", null)
|
||||
val oldApiKey = securePrefs?.getString("api_key", null)
|
||||
val oldModel = prefs.getString("model", null)
|
||||
val oldBaseUrl = prefs.getString("base_url", null)
|
||||
val oldCachedModels = prefs.getStringSet("cached_models", null)
|
||||
@@ -205,24 +210,29 @@ class SettingsManager(context: Context) {
|
||||
else -> "custom"
|
||||
}
|
||||
|
||||
// 迁移到新格式
|
||||
val migratedConfig = ProviderConfig(
|
||||
apiKey = oldApiKey ?: "",
|
||||
model = oldModel ?: "",
|
||||
cachedModels = oldCachedModels?.toList() ?: emptyList(),
|
||||
customBaseUrl = if (oldProviderId == "custom") oldBaseUrl ?: "" else ""
|
||||
)
|
||||
providerConfigs[oldProviderId] = migratedConfig
|
||||
saveProviderConfig(oldProviderId, migratedConfig)
|
||||
if (saveProviderConfig(oldProviderId, migratedConfig)) {
|
||||
providerConfigs[oldProviderId] = migratedConfig
|
||||
}
|
||||
|
||||
// 清除旧数据
|
||||
securePrefs.edit().remove("api_key").apply()
|
||||
prefs.edit()
|
||||
val secureMigrationComplete = securePrefs?.edit()
|
||||
?.remove("api_key")
|
||||
?.commit() == true
|
||||
val preferencesMigrationComplete = prefs.edit()
|
||||
.remove("model")
|
||||
.remove("base_url")
|
||||
.remove("cached_models")
|
||||
.putString("current_provider_id", oldProviderId)
|
||||
.apply()
|
||||
.commit()
|
||||
if (!secureMigrationComplete || !preferencesMigrationComplete) {
|
||||
secureCredentialStorageAvailable = false
|
||||
android.util.Log.e("SettingsManager", "Legacy provider migration failed closed")
|
||||
}
|
||||
|
||||
android.util.Log.d("SettingsManager", "Migrated old settings to provider: $oldProviderId")
|
||||
}
|
||||
@@ -244,7 +254,7 @@ class SettingsManager(context: Context) {
|
||||
private fun loadProviderConfig(providerId: String): ProviderConfig {
|
||||
val prefix = "provider_${providerId}_"
|
||||
return ProviderConfig(
|
||||
apiKey = securePrefs.getString("${prefix}api_key", "") ?: "",
|
||||
apiKey = securePrefs?.getString("${prefix}api_key", "") ?: "",
|
||||
model = prefs.getString("${prefix}model", "") ?: "",
|
||||
cachedModels = prefs.getStringSet("${prefix}cached_models", emptySet())?.toList() ?: emptyList(),
|
||||
customBaseUrl = prefs.getString("${prefix}custom_base_url", "") ?: ""
|
||||
@@ -254,14 +264,30 @@ class SettingsManager(context: Context) {
|
||||
/**
|
||||
* 保存指定服务商的配置
|
||||
*/
|
||||
private fun saveProviderConfig(providerId: String, config: ProviderConfig) {
|
||||
private fun saveProviderConfig(providerId: String, config: ProviderConfig): Boolean {
|
||||
val prefix = "provider_${providerId}_"
|
||||
securePrefs.edit().putString("${prefix}api_key", config.apiKey).apply()
|
||||
prefs.edit()
|
||||
if (config.apiKey.isNotEmpty()) {
|
||||
val encrypted = securePrefs
|
||||
if (encrypted == null ||
|
||||
!encrypted.edit().putString("${prefix}api_key", config.apiKey).commit()
|
||||
) {
|
||||
secureCredentialStorageAvailable = false
|
||||
android.util.Log.e("SettingsManager", "Refused API Key write without encryption")
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (securePrefs != null &&
|
||||
!securePrefs.edit().remove("${prefix}api_key").commit()
|
||||
) {
|
||||
secureCredentialStorageAvailable = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
return prefs.edit()
|
||||
.putString("${prefix}model", config.model)
|
||||
.putStringSet("${prefix}cached_models", config.cachedModels.toSet())
|
||||
.putString("${prefix}custom_base_url", config.customBaseUrl)
|
||||
.apply()
|
||||
.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,7 +298,9 @@ class SettingsManager(context: Context) {
|
||||
val currentConfig = _settings.value.currentConfig
|
||||
val newConfig = update(currentConfig)
|
||||
|
||||
saveProviderConfig(currentId, newConfig)
|
||||
if (!saveProviderConfig(currentId, newConfig)) {
|
||||
return
|
||||
}
|
||||
|
||||
val newConfigs = _settings.value.providerConfigs.toMutableMap()
|
||||
newConfigs[currentId] = newConfig
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
data class ExecutionEvidenceDraft(
|
||||
val ordinal: Int,
|
||||
val pngBytes: ByteArray,
|
||||
val sha256: String
|
||||
)
|
||||
|
||||
data class ExecutionCandidateDraft(
|
||||
val ordinal: Int,
|
||||
val title: String,
|
||||
val skuText: String = "",
|
||||
val price: String = "",
|
||||
val productUrl: String = "",
|
||||
val imageUrl: String = "",
|
||||
val evidenceLocalIDs: List<String>,
|
||||
val evaluation: ExecutionCandidateEvaluation? = null
|
||||
)
|
||||
|
||||
data class ExecutionCandidateEvaluation(
|
||||
val decision: String,
|
||||
val score: Double,
|
||||
val matched: List<String>,
|
||||
val missingOrUncertain: List<String>,
|
||||
val rejectionReasons: List<String>,
|
||||
val confidence: Double
|
||||
)
|
||||
|
||||
data class ExecutionRecommendation(
|
||||
val candidateOrdinal: Int,
|
||||
val policyVersion: String,
|
||||
val reasons: List<String>
|
||||
)
|
||||
|
||||
data class ExecutionCandidateBatchDraft(
|
||||
val mode: ExecutionMode,
|
||||
val searchQuery: String,
|
||||
val provenance: ExecutionProvenanceSnapshot?,
|
||||
val candidates: List<ExecutionCandidateDraft>,
|
||||
val recommendation: ExecutionRecommendation? = null
|
||||
)
|
||||
|
||||
object ExecutionTaskHash {
|
||||
fun sha256(task: RemotePurchaseTask): String {
|
||||
val maxBudgetCents = task.maxBudget
|
||||
?.let(::parseCnyCents)
|
||||
?.toString()
|
||||
.orEmpty()
|
||||
val payload = listOf(
|
||||
task.title,
|
||||
task.description,
|
||||
task.sku,
|
||||
task.imageAssetId,
|
||||
task.quantity.toString(),
|
||||
maxBudgetCents,
|
||||
task.currency
|
||||
).joinToString("\u0000")
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(payload.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun parseCnyCents(value: String): Long {
|
||||
val normalized = value.trim()
|
||||
val pieces = normalized.split('.', limit = 2)
|
||||
require(pieces.size in 1..2 && pieces[0].all(Char::isDigit))
|
||||
val whole = pieces[0].toLong()
|
||||
val fractional = when (pieces.size) {
|
||||
1 -> 0L
|
||||
else -> {
|
||||
require(pieces[1].length in 1..2 && pieces[1].all(Char::isDigit))
|
||||
pieces[1].padEnd(2, '0').toLong()
|
||||
}
|
||||
}
|
||||
return whole * 100 + fractional
|
||||
}
|
||||
}
|
||||
+60
@@ -25,6 +25,10 @@ data class DownloadedReferenceImage(
|
||||
val sha256: String
|
||||
)
|
||||
|
||||
data class ExecutionOutboxUploadResult(
|
||||
val evidenceAssetId: String? = null
|
||||
)
|
||||
|
||||
interface ProcurementRemoteApi {
|
||||
suspend fun login(
|
||||
baseUrl: String,
|
||||
@@ -79,6 +83,15 @@ interface ProcurementRemoteApi {
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): RemotePurchaseTask
|
||||
|
||||
suspend fun uploadExecutionOutboxItem(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
item: ExecutionOutboxItem,
|
||||
evidenceBytes: ByteArray? = null
|
||||
): ExecutionOutboxUploadResult
|
||||
}
|
||||
|
||||
class ProcurementApiClient(
|
||||
@@ -250,6 +263,50 @@ class ProcurementApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uploadExecutionOutboxItem(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
item: ExecutionOutboxItem,
|
||||
evidenceBytes: ByteArray?
|
||||
): ExecutionOutboxUploadResult = withContext(Dispatchers.IO) {
|
||||
val path = when (item.type) {
|
||||
ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events"
|
||||
ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence"
|
||||
ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates"
|
||||
ExecutionOutboxType.COMPLETE -> "/api/v1/tasks/${task.id}/complete"
|
||||
ExecutionOutboxType.FAIL -> "/api/v1/tasks/${task.id}/fail"
|
||||
}
|
||||
val request = authorizedRequest(session, path)
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.header(IDEMPOTENCY_HEADER, item.idempotencyKey)
|
||||
if (item.type == ExecutionOutboxType.EVIDENCE) {
|
||||
val bytes = requireNotNull(evidenceBytes) { "离线证据文件缺失" }
|
||||
require(bytes.isNotEmpty() && bytes.size <= MAX_EVIDENCE_BYTES) {
|
||||
"离线证据文件无效"
|
||||
}
|
||||
request
|
||||
.header("X-Execution-ID", execution.id)
|
||||
.header("X-Claim-Generation", task.claimGeneration.toString())
|
||||
.post(bytes.toRequestBody(PNG_MEDIA_TYPE))
|
||||
} else {
|
||||
val payload = item.payload
|
||||
require(payload.toByteArray(Charsets.UTF_8).size <= MAX_JSON_BYTES) {
|
||||
"离线结果过大"
|
||||
}
|
||||
request.post(payload.toRequestBody(JSON_MEDIA_TYPE))
|
||||
}
|
||||
val json = executeJson(request.build())
|
||||
ExecutionOutboxUploadResult(
|
||||
evidenceAssetId = if (item.type == ExecutionOutboxType.EVIDENCE) {
|
||||
json.getJSONObject("evidence").getString("id")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun start(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
@@ -455,6 +512,7 @@ class ProcurementApiClient(
|
||||
title = json.getString("title"),
|
||||
description = json.optString("description"),
|
||||
sku = json.getString("sku"),
|
||||
imageAssetId = json.getString("image_asset_id"),
|
||||
referenceImageUrl = json.getString("reference_image_url"),
|
||||
quantity = json.getInt("quantity"),
|
||||
maxBudget = json.optionalString("max_budget"),
|
||||
@@ -501,10 +559,12 @@ class ProcurementApiClient(
|
||||
|
||||
companion object {
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
private val PNG_MEDIA_TYPE = "image/png".toMediaType()
|
||||
private const val JPEG_MEDIA_TYPE = "image/jpeg"
|
||||
private const val CLAIM_TOKEN_HEADER = "X-Claim-Token"
|
||||
private const val IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||
private const val MAX_JSON_BYTES = 1_048_576L
|
||||
private const val MAX_EVIDENCE_BYTES = 8 * 1024 * 1024
|
||||
private const val MAX_REFERENCE_IMAGE_BYTES = 20L * 1024L * 1024L
|
||||
private val SHA256_PATTERN = Regex("[0-9a-f]{64}")
|
||||
|
||||
|
||||
+36
-2
@@ -39,6 +39,7 @@ data class RemotePurchaseTask(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val sku: String,
|
||||
val imageAssetId: String,
|
||||
val referenceImageUrl: String,
|
||||
val quantity: Int,
|
||||
val maxBudget: String?,
|
||||
@@ -67,7 +68,8 @@ data class RunningExecution(
|
||||
val expiresAt: String,
|
||||
val serverClockOffsetMillis: Long,
|
||||
val safetyStopped: Boolean = false,
|
||||
val cancelAcknowledgementKey: String? = null
|
||||
val cancelAcknowledgementKey: String? = null,
|
||||
val provenance: ExecutionProvenanceSnapshot? = null
|
||||
) {
|
||||
fun isExpired(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
|
||||
ExecutionAuthorization.isExpired(
|
||||
@@ -81,7 +83,39 @@ data class PersistedProcurementState(
|
||||
val session: ProcurementSession? = null,
|
||||
val deviceToken: String? = null,
|
||||
val claim: ClaimContext? = null,
|
||||
val execution: RunningExecution? = null
|
||||
val execution: RunningExecution? = null,
|
||||
val outbox: List<ExecutionOutboxItem> = emptyList()
|
||||
)
|
||||
|
||||
enum class ExecutionMode {
|
||||
MANUAL_FIRST,
|
||||
AI_ASSISTED
|
||||
}
|
||||
|
||||
data class ExecutionProvenanceSnapshot(
|
||||
val mode: ExecutionMode,
|
||||
val providerId: String? = null,
|
||||
val model: String? = null,
|
||||
val promptVersion: String? = null,
|
||||
val schemaVersion: Int? = null,
|
||||
val referenceImageSha256: String
|
||||
)
|
||||
|
||||
enum class ExecutionOutboxType {
|
||||
EVENTS,
|
||||
EVIDENCE,
|
||||
CANDIDATES,
|
||||
COMPLETE,
|
||||
FAIL
|
||||
}
|
||||
|
||||
data class ExecutionOutboxItem(
|
||||
val id: String,
|
||||
val type: ExecutionOutboxType,
|
||||
val idempotencyKey: String,
|
||||
val payload: String,
|
||||
val evidenceRelativePath: String? = null,
|
||||
val remoteResourceID: String? = null
|
||||
)
|
||||
|
||||
data class ProcurementUiState(
|
||||
|
||||
+450
-3
@@ -10,10 +10,14 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
enum class ExecutionSyncDecision {
|
||||
CONTINUE,
|
||||
@@ -131,7 +135,10 @@ class ProcurementRepository(
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun start(): Boolean = operation {
|
||||
suspend fun start(
|
||||
mode: ExecutionMode = ExecutionMode.MANUAL_FIRST,
|
||||
provenance: ExecutionProvenanceSnapshot? = null
|
||||
): Boolean = operation {
|
||||
val session = requireValidSession()
|
||||
val claim = requireNotNull(persisted.claim) { "没有可开始的任务" }
|
||||
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
|
||||
@@ -160,12 +167,20 @@ class ProcurementRepository(
|
||||
serverClockOffsetMillis = ExecutionAuthorization.serverClockOffset(
|
||||
result.serverTime,
|
||||
requestStartedAt
|
||||
)
|
||||
),
|
||||
provenance = normalizeProvenance(mode, provenance, claim.referenceImage)
|
||||
)
|
||||
persisted = persisted.copy(
|
||||
claim = persisted.claim?.copy(task = result.task),
|
||||
execution = execution
|
||||
)
|
||||
enqueueEventLocked(
|
||||
task = result.task,
|
||||
execution = execution,
|
||||
step = "PREFLIGHT",
|
||||
type = "EXECUTION_STARTED",
|
||||
message = "本地受控采购流程已开始"
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "受控采购流程已开始"
|
||||
@@ -234,6 +249,21 @@ class ProcurementRepository(
|
||||
}
|
||||
}
|
||||
return@withLock try {
|
||||
if (flushExecutionOutbox(session)) {
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "执行结果已回传,任务已结束"
|
||||
)
|
||||
return@withLock ExecutionSyncDecision.STOP
|
||||
}
|
||||
execution = persisted.execution ?: return@withLock ExecutionSyncDecision.STOP
|
||||
if (execution.safetyStopped) {
|
||||
publish(
|
||||
backendOnline = true,
|
||||
message = "授权到期后的审计结果已同步,自动化保持停止"
|
||||
)
|
||||
return@withLock ExecutionSyncDecision.STOP
|
||||
}
|
||||
val requestStartedAt = System.currentTimeMillis()
|
||||
val result = api.heartbeat(session, task, execution, claim.token)
|
||||
val updatedExecution = execution.copy(
|
||||
@@ -294,6 +324,172 @@ class ProcurementRepository(
|
||||
if (task != null && image != null) task.toProbeTask(image) else null
|
||||
}
|
||||
|
||||
fun activeExecutionMode(): ExecutionMode? =
|
||||
persisted.execution?.provenance?.mode
|
||||
|
||||
fun currentReferenceImageBytes(): ByteArray? =
|
||||
persisted.claim?.referenceImage?.let { image ->
|
||||
File(appContext.filesDir, image.relativePath)
|
||||
.takeIf { it.isFile && it.length() == image.sizeBytes }
|
||||
?.readBytes()
|
||||
}
|
||||
|
||||
suspend fun queueCandidateBatch(
|
||||
batch: ExecutionCandidateBatchDraft,
|
||||
evidence: List<ExecutionEvidenceDraft>
|
||||
): List<ExecutionCandidateDraft>? = operation {
|
||||
val task = requireCurrentTask()
|
||||
val execution = requireActiveExecution()
|
||||
require(!execution.safetyStopped && !execution.isExpired()) {
|
||||
"执行授权已到期,不能继续采集"
|
||||
}
|
||||
require(evidence.size in 1..5) { "候选证据必须为 1 至 5 张" }
|
||||
require(batch.candidates.size in 1..5) { "候选数量必须为 1 至 5 个" }
|
||||
require(batch.candidates.map { it.ordinal } == (1..batch.candidates.size).toList()) {
|
||||
"候选编号必须连续"
|
||||
}
|
||||
require(batch.mode == execution.provenance?.mode) { "执行模式不能在回传时变更" }
|
||||
if (batch.mode == ExecutionMode.AI_ASSISTED) {
|
||||
require(batch.provenance != null && batch.provenance.providerId != null) {
|
||||
"AI 辅助模式缺少本地模型出处"
|
||||
}
|
||||
} else {
|
||||
require(batch.provenance == null) { "手工优先模式不能附带模型出处" }
|
||||
}
|
||||
val evidenceIDs = evidence.sortedBy { it.ordinal }.associate { draft ->
|
||||
require(draft.ordinal in 1..batch.candidates.size)
|
||||
require(sha256(draft.pngBytes) == draft.sha256)
|
||||
val localID = UUID.randomUUID().toString()
|
||||
persistEvidenceLocked(localID, draft.pngBytes)
|
||||
appendOutboxLocked(
|
||||
ExecutionOutboxItem(
|
||||
id = localID,
|
||||
type = ExecutionOutboxType.EVIDENCE,
|
||||
idempotencyKey = newOpaqueSecret(),
|
||||
payload = "{}",
|
||||
evidenceRelativePath = "$OUTBOX_DIRECTORY/$localID.png"
|
||||
)
|
||||
)
|
||||
draft.ordinal to localID
|
||||
}
|
||||
val candidates = batch.candidates.map { candidate ->
|
||||
candidate.copy(
|
||||
evidenceLocalIDs = listOf(
|
||||
requireNotNull(evidenceIDs[candidate.ordinal])
|
||||
)
|
||||
)
|
||||
}
|
||||
val payload = JSONObject()
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put("task_content_sha256", ExecutionTaskHash.sha256(task))
|
||||
.put("execution_mode", batch.mode.name)
|
||||
.put("search_query", batch.searchQuery.trim())
|
||||
.put("candidates", JSONArray().apply {
|
||||
candidates.forEach { put(candidateJSON(it)) }
|
||||
})
|
||||
batch.provenance?.let { payload.put("provenance", provenanceJSON(it)) }
|
||||
batch.recommendation?.let { recommendation ->
|
||||
payload.put(
|
||||
"recommendation",
|
||||
JSONObject()
|
||||
.put("candidate_ordinal", recommendation.candidateOrdinal)
|
||||
.put("policy_version", recommendation.policyVersion)
|
||||
.put("reasons", JSONArray(recommendation.reasons))
|
||||
)
|
||||
}
|
||||
appendOutboxLocked(
|
||||
ExecutionOutboxItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
type = ExecutionOutboxType.CANDIDATES,
|
||||
idempotencyKey = newOpaqueSecret(),
|
||||
payload = payload.toString()
|
||||
)
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = _uiState.value.backendOnline,
|
||||
message = "候选和截图已加入加密回传队列"
|
||||
)
|
||||
candidates
|
||||
}
|
||||
|
||||
suspend fun completeExecution(
|
||||
outcome: String,
|
||||
operatorReason: String,
|
||||
candidate: ExecutionCandidateDraft? = null
|
||||
): Boolean = operation {
|
||||
val task = requireCurrentTask()
|
||||
val execution = requireActiveExecution()
|
||||
require(operatorReason.trim().isNotEmpty()) { "请填写人工确认理由" }
|
||||
require(outcome in COMPLETE_OUTCOMES) { "采购结论无效" }
|
||||
if (outcome == "CANDIDATE_ACCEPTED") {
|
||||
require(candidate != null) { "接受候选时必须保留候选证据" }
|
||||
} else {
|
||||
require(candidate == null) { "当前结论不能附带候选" }
|
||||
}
|
||||
val payload = JSONObject()
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put("task_content_sha256", ExecutionTaskHash.sha256(task))
|
||||
.put("execution_mode", requireNotNull(execution.provenance).mode.name)
|
||||
.put("outcome", outcome)
|
||||
.put("operator_reason", operatorReason.trim())
|
||||
.put("order_submitted", false)
|
||||
candidate?.let { payload.put("candidate", candidateJSON(it)) }
|
||||
appendTerminalOutboxLocked(
|
||||
ExecutionOutboxItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
type = ExecutionOutboxType.COMPLETE,
|
||||
idempotencyKey = newOpaqueSecret(),
|
||||
payload = payload.toString()
|
||||
)
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = _uiState.value.backendOnline,
|
||||
message = "人工结论已加入加密回传队列"
|
||||
)
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun failExecution(
|
||||
code: String,
|
||||
message: String,
|
||||
step: String,
|
||||
retryable: Boolean,
|
||||
evidenceLocalIDs: List<String> = emptyList()
|
||||
): Boolean = operation {
|
||||
val task = requireCurrentTask()
|
||||
val execution = requireActiveExecution()
|
||||
require(code.isNotBlank() && message.isNotBlank() && step.isNotBlank()) {
|
||||
"失败信息不完整"
|
||||
}
|
||||
val payload = JSONObject()
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put(
|
||||
"error",
|
||||
JSONObject()
|
||||
.put("code", code.trim())
|
||||
.put("message", message.trim())
|
||||
.put("step", step.trim())
|
||||
.put("retryable", retryable)
|
||||
)
|
||||
.put("evidence_asset_ids", JSONArray(evidenceLocalIDs))
|
||||
appendTerminalOutboxLocked(
|
||||
ExecutionOutboxItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
type = ExecutionOutboxType.FAIL,
|
||||
idempotencyKey = newOpaqueSecret(),
|
||||
payload = payload.toString()
|
||||
)
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = _uiState.value.backendOnline,
|
||||
message = "失败信息已加入加密回传队列"
|
||||
)
|
||||
true
|
||||
} ?: false
|
||||
|
||||
private suspend fun acknowledgeCancellation(session: ProcurementSession) {
|
||||
val claim = requireNotNull(persisted.claim)
|
||||
val task = requireNotNull(claim.task)
|
||||
@@ -363,9 +559,252 @@ class ProcurementRepository(
|
||||
persisted.claim?.referenceImage?.let {
|
||||
File(appContext.filesDir, it.relativePath).delete()
|
||||
}
|
||||
persisted = persisted.copy(claim = null, execution = null)
|
||||
persisted.outbox.forEach { item ->
|
||||
item.evidenceRelativePath?.let { relativePath ->
|
||||
File(appContext.filesDir, relativePath).delete()
|
||||
}
|
||||
}
|
||||
persisted = persisted.copy(
|
||||
claim = null,
|
||||
execution = null,
|
||||
outbox = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun requireCurrentTask(): RemotePurchaseTask =
|
||||
requireNotNull(persisted.claim?.task) { "没有可回传的采购任务" }
|
||||
|
||||
private fun requireActiveExecution(): RunningExecution =
|
||||
requireNotNull(persisted.execution) { "采购执行尚未开始" }
|
||||
|
||||
private fun appendOutboxLocked(item: ExecutionOutboxItem) {
|
||||
var resolvedPayload = item.payload
|
||||
persisted.outbox.forEach { knownEvidence ->
|
||||
if (knownEvidence.type == ExecutionOutboxType.EVIDENCE &&
|
||||
knownEvidence.remoteResourceID != null
|
||||
) {
|
||||
resolvedPayload = replaceEvidenceReference(
|
||||
resolvedPayload,
|
||||
knownEvidence.id,
|
||||
knownEvidence.remoteResourceID
|
||||
)
|
||||
}
|
||||
}
|
||||
persisted = persisted.copy(
|
||||
outbox = persisted.outbox + item.copy(payload = resolvedPayload)
|
||||
)
|
||||
}
|
||||
|
||||
private fun appendTerminalOutboxLocked(item: ExecutionOutboxItem) {
|
||||
require(persisted.outbox.none {
|
||||
it.type == ExecutionOutboxType.COMPLETE || it.type == ExecutionOutboxType.FAIL
|
||||
}) { "当前执行已有待回传终态" }
|
||||
appendOutboxLocked(item)
|
||||
}
|
||||
|
||||
private fun enqueueEventLocked(
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
step: String,
|
||||
type: String,
|
||||
message: String
|
||||
) {
|
||||
val payload = JSONObject()
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put(
|
||||
"events",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("event_id", UUID.randomUUID().toString())
|
||||
.put("step", step)
|
||||
.put("type", type)
|
||||
.put("message", message)
|
||||
.put("occurred_at", Instant.now().toString())
|
||||
)
|
||||
)
|
||||
appendOutboxLocked(
|
||||
ExecutionOutboxItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
type = ExecutionOutboxType.EVENTS,
|
||||
idempotencyKey = newOpaqueSecret(),
|
||||
payload = payload.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeProvenance(
|
||||
mode: ExecutionMode,
|
||||
candidate: ExecutionProvenanceSnapshot?,
|
||||
referenceImage: ReferenceImageRecord?
|
||||
): ExecutionProvenanceSnapshot {
|
||||
val referenceHash = requireNotNull(referenceImage?.sha256) {
|
||||
"参考图哈希缺失"
|
||||
}
|
||||
if (mode == ExecutionMode.MANUAL_FIRST) {
|
||||
require(candidate == null) { "手工优先模式不能配置模型出处" }
|
||||
return ExecutionProvenanceSnapshot(
|
||||
mode = mode,
|
||||
referenceImageSha256 = referenceHash
|
||||
)
|
||||
}
|
||||
val provenance = requireNotNull(candidate) { "AI 辅助模式缺少模型出处" }
|
||||
require(
|
||||
provenance.mode == mode &&
|
||||
!provenance.providerId.isNullOrBlank() &&
|
||||
!provenance.model.isNullOrBlank() &&
|
||||
!provenance.promptVersion.isNullOrBlank() &&
|
||||
provenance.schemaVersion != null
|
||||
) { "AI 辅助模式模型出处无效" }
|
||||
return provenance.copy(referenceImageSha256 = referenceHash)
|
||||
}
|
||||
|
||||
private fun persistEvidenceLocked(localID: String, bytes: ByteArray) {
|
||||
require(bytes.isNotEmpty() && bytes.size <= MAX_OUTBOX_EVIDENCE_BYTES) {
|
||||
"候选截图无效"
|
||||
}
|
||||
val directory = File(appContext.filesDir, OUTBOX_DIRECTORY)
|
||||
check(directory.exists() || directory.mkdirs()) { "无法创建离线证据目录" }
|
||||
val target = File(directory, "$localID.png")
|
||||
val temporary = File(directory, ".$localID.tmp")
|
||||
temporary.outputStream().use { output ->
|
||||
output.write(bytes)
|
||||
output.fd.sync()
|
||||
}
|
||||
check(!target.exists() || target.delete()) { "无法替换离线证据" }
|
||||
check(temporary.renameTo(target)) { "无法保存离线证据" }
|
||||
}
|
||||
|
||||
private suspend fun flushExecutionOutbox(session: ProcurementSession): Boolean {
|
||||
while (true) {
|
||||
val task = requireCurrentTask()
|
||||
val execution = requireActiveExecution()
|
||||
val item = persisted.outbox.firstOrNull { pending ->
|
||||
pending.type != ExecutionOutboxType.EVIDENCE ||
|
||||
pending.remoteResourceID == null
|
||||
} ?: return false
|
||||
val evidenceBytes = item.evidenceRelativePath?.let { relativePath ->
|
||||
val file = File(appContext.filesDir, relativePath)
|
||||
require(file.isFile && file.length() in 1..MAX_OUTBOX_EVIDENCE_BYTES) {
|
||||
"离线证据文件缺失"
|
||||
}
|
||||
file.readBytes()
|
||||
}
|
||||
val uploaded = api.uploadExecutionOutboxItem(
|
||||
session = session,
|
||||
task = task,
|
||||
execution = execution,
|
||||
claimToken = requireNotNull(persisted.claim).token,
|
||||
item = item,
|
||||
evidenceBytes = evidenceBytes
|
||||
)
|
||||
if (item.type == ExecutionOutboxType.EVIDENCE) {
|
||||
val evidenceID = requireNotNull(uploaded.evidenceAssetId) {
|
||||
"后台未返回证据编号"
|
||||
}
|
||||
persisted = persisted.copy(
|
||||
outbox = persisted.outbox.map { pending ->
|
||||
if (pending.id == item.id) {
|
||||
pending.copy(remoteResourceID = evidenceID)
|
||||
} else {
|
||||
pending.copy(
|
||||
payload = replaceEvidenceReference(
|
||||
pending.payload,
|
||||
item.id,
|
||||
evidenceID
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
item.evidenceRelativePath?.let {
|
||||
File(appContext.filesDir, it).delete()
|
||||
}
|
||||
} else {
|
||||
persisted = persisted.copy(
|
||||
outbox = persisted.outbox.filterNot { it.id == item.id }
|
||||
)
|
||||
}
|
||||
if (item.type == ExecutionOutboxType.COMPLETE ||
|
||||
item.type == ExecutionOutboxType.FAIL
|
||||
) {
|
||||
clearCurrentTask()
|
||||
store.save(persisted)
|
||||
return true
|
||||
}
|
||||
store.save(persisted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceEvidenceReference(
|
||||
payload: String,
|
||||
localID: String,
|
||||
remoteID: String
|
||||
): String {
|
||||
val root = JSONObject(payload)
|
||||
fun replace(value: Any?) {
|
||||
when (value) {
|
||||
is JSONObject -> {
|
||||
val keys = value.keys().asSequence().toList()
|
||||
keys.forEach { key -> replace(value.opt(key)) }
|
||||
}
|
||||
is JSONArray -> {
|
||||
for (index in 0 until value.length()) {
|
||||
if (value.optString(index) == localID) {
|
||||
value.put(index, remoteID)
|
||||
} else {
|
||||
replace(value.opt(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
replace(root)
|
||||
return root.toString()
|
||||
}
|
||||
|
||||
private fun candidateJSON(candidate: ExecutionCandidateDraft): JSONObject =
|
||||
JSONObject()
|
||||
.put("ordinal", candidate.ordinal)
|
||||
.put("title", candidate.title)
|
||||
.put("sku_text", candidate.skuText)
|
||||
.put("price", candidate.price)
|
||||
.put("product_url", candidate.productUrl)
|
||||
.put("image_url", candidate.imageUrl)
|
||||
.put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs))
|
||||
.also { json ->
|
||||
candidate.evaluation?.let { evaluation ->
|
||||
json.put(
|
||||
"evaluation",
|
||||
JSONObject()
|
||||
.put("decision", evaluation.decision)
|
||||
.put("score", evaluation.score)
|
||||
.put("matched", JSONArray(evaluation.matched))
|
||||
.put(
|
||||
"missing_or_uncertain",
|
||||
JSONArray(evaluation.missingOrUncertain)
|
||||
)
|
||||
.put(
|
||||
"rejection_reasons",
|
||||
JSONArray(evaluation.rejectionReasons)
|
||||
)
|
||||
.put("confidence", evaluation.confidence)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun provenanceJSON(snapshot: ExecutionProvenanceSnapshot): JSONObject =
|
||||
JSONObject()
|
||||
.put("provider_id", snapshot.providerId)
|
||||
.put("model", snapshot.model)
|
||||
.put("prompt_version", snapshot.promptVersion)
|
||||
.put("schema_version", snapshot.schemaVersion)
|
||||
|
||||
private fun sha256(bytes: ByteArray): String =
|
||||
java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(bytes)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun requireValidSession(): ProcurementSession {
|
||||
val session = persisted.session
|
||||
require(session != null && session.isValid()) { "采购员登录已过期,请重新登录" }
|
||||
@@ -476,10 +915,18 @@ class ProcurementRepository(
|
||||
|
||||
companion object {
|
||||
private const val REFERENCE_DIRECTORY = "procurement"
|
||||
private const val OUTBOX_DIRECTORY = "procurement-outbox"
|
||||
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
|
||||
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
|
||||
private const val MAX_REFERENCE_DIMENSION = 4_096
|
||||
private const val MAX_REFERENCE_PIXELS = 20_000_000L
|
||||
private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L
|
||||
private val COMPLETE_OUTCOMES = setOf(
|
||||
"CANDIDATE_ACCEPTED",
|
||||
"CANDIDATE_REJECTED",
|
||||
"NO_MATCH",
|
||||
"MANUAL_REQUIRED"
|
||||
)
|
||||
private val SECURE_RANDOM = SecureRandom()
|
||||
|
||||
fun create(context: Context): ProcurementRepository =
|
||||
|
||||
+83
-2
@@ -3,6 +3,7 @@ package com.roubao.autopilot.procurement
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
interface ProcurementStateStore {
|
||||
@@ -89,9 +90,50 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
"cancel_acknowledgement_key",
|
||||
execution.cancelAcknowledgementKey
|
||||
)
|
||||
execution.provenance?.let { provenance ->
|
||||
put(
|
||||
"provenance",
|
||||
JSONObject().apply {
|
||||
put("mode", provenance.mode.name)
|
||||
putNullable("provider_id", provenance.providerId)
|
||||
putNullable("model", provenance.model)
|
||||
putNullable("prompt_version", provenance.promptVersion)
|
||||
provenance.schemaVersion?.let {
|
||||
put("schema_version", it)
|
||||
}
|
||||
put(
|
||||
"reference_image_sha256",
|
||||
provenance.referenceImageSha256
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
put(
|
||||
"outbox",
|
||||
JSONArray().apply {
|
||||
state.outbox.forEach { item ->
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", item.id)
|
||||
put("type", item.type.name)
|
||||
put("idempotency_key", item.idempotencyKey)
|
||||
put("payload", item.payload)
|
||||
putNullable(
|
||||
"evidence_relative_path",
|
||||
item.evidenceRelativePath
|
||||
)
|
||||
putNullable(
|
||||
"remote_resource_id",
|
||||
item.remoteResourceID
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeState(json: JSONObject): PersistedProcurementState =
|
||||
@@ -137,9 +179,43 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
serverClockOffsetMillis = it.getLong("server_clock_offset_ms"),
|
||||
safetyStopped = it.optBoolean("safety_stopped", false),
|
||||
cancelAcknowledgementKey =
|
||||
it.optionalString("cancel_acknowledgement_key")
|
||||
it.optionalString("cancel_acknowledgement_key"),
|
||||
provenance = it.optionalObject("provenance")?.let { provenance ->
|
||||
ExecutionProvenanceSnapshot(
|
||||
mode = ExecutionMode.valueOf(provenance.getString("mode")),
|
||||
providerId = provenance.optionalString("provider_id"),
|
||||
model = provenance.optionalString("model"),
|
||||
promptVersion = provenance.optionalString("prompt_version"),
|
||||
schemaVersion = if (provenance.has("schema_version")) {
|
||||
provenance.getInt("schema_version")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
referenceImageSha256 =
|
||||
provenance.getString("reference_image_sha256")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
outbox = json.optionalArray("outbox")?.let { entries ->
|
||||
buildList {
|
||||
for (index in 0 until entries.length()) {
|
||||
val item = entries.getJSONObject(index)
|
||||
add(
|
||||
ExecutionOutboxItem(
|
||||
id = item.getString("id"),
|
||||
type = ExecutionOutboxType.valueOf(item.getString("type")),
|
||||
idempotencyKey = item.getString("idempotency_key"),
|
||||
payload = item.getString("payload"),
|
||||
evidenceRelativePath =
|
||||
item.optionalString("evidence_relative_path"),
|
||||
remoteResourceID =
|
||||
item.optionalString("remote_resource_id")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
)
|
||||
|
||||
private fun encodeTask(task: RemotePurchaseTask): JSONObject =
|
||||
@@ -152,6 +228,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
put("title", task.title)
|
||||
put("description", task.description)
|
||||
put("sku", task.sku)
|
||||
put("image_asset_id", task.imageAssetId)
|
||||
put("reference_image_url", task.referenceImageUrl)
|
||||
put("quantity", task.quantity)
|
||||
putNullable("max_budget", task.maxBudget)
|
||||
@@ -168,6 +245,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
title = json.getString("title"),
|
||||
description = json.optString("description"),
|
||||
sku = json.getString("sku"),
|
||||
imageAssetId = json.getString("image_asset_id"),
|
||||
referenceImageUrl = json.getString("reference_image_url"),
|
||||
quantity = json.getInt("quantity"),
|
||||
maxBudget = json.optionalString("max_budget"),
|
||||
@@ -184,6 +262,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
private fun JSONObject.optionalObject(name: String): JSONObject? =
|
||||
if (has(name) && !isNull(name)) getJSONObject(name) else null
|
||||
|
||||
private fun JSONObject.optionalArray(name: String): JSONArray? =
|
||||
if (has(name) && !isNull(name)) getJSONArray(name) else null
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "procurement_secure_state"
|
||||
const val STATE_KEY = "state_v1"
|
||||
|
||||
+53
-2
@@ -43,6 +43,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.roubao.autopilot.procurement.LoginInput
|
||||
import com.roubao.autopilot.procurement.ExecutionMode
|
||||
import com.roubao.autopilot.procurement.ProcurementPhase
|
||||
import com.roubao.autopilot.procurement.ProcurementUiState
|
||||
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||
@@ -56,12 +57,15 @@ fun ProcurementScreen(
|
||||
readiness: DeviceReadinessSnapshot,
|
||||
onLogin: (LoginInput) -> Unit,
|
||||
onClaim: () -> Unit,
|
||||
onStart: () -> Unit,
|
||||
onStart: (ExecutionMode) -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
onSync: () -> Unit
|
||||
) {
|
||||
val colors = BaoziTheme.colors
|
||||
var confirmStart by remember(state.task?.id) { mutableStateOf(false) }
|
||||
var executionMode by remember(state.task?.id) {
|
||||
mutableStateOf(ExecutionMode.MANUAL_FIRST)
|
||||
}
|
||||
|
||||
if (confirmStart) {
|
||||
AlertDialog(
|
||||
@@ -77,7 +81,7 @@ fun ProcurementScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
confirmStart = false
|
||||
onStart()
|
||||
onStart(executionMode)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||
@@ -154,6 +158,12 @@ fun ProcurementScreen(
|
||||
item { LoginSection(state = state, onLogin = onLogin) }
|
||||
}
|
||||
item { TaskDetails(state) }
|
||||
item {
|
||||
ExecutionModeSelector(
|
||||
selected = executionMode,
|
||||
onSelect = { executionMode = it }
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -206,6 +216,47 @@ fun ProcurementScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExecutionModeSelector(
|
||||
selected: ExecutionMode,
|
||||
onSelect: (ExecutionMode) -> Unit
|
||||
) {
|
||||
val colors = BaoziTheme.colors
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"执行模式",
|
||||
color = colors.textPrimary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ExecutionMode.values().forEach { mode ->
|
||||
OutlinedButton(
|
||||
onClick = { onSelect(mode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = mode != selected
|
||||
) {
|
||||
Text(
|
||||
if (mode == ExecutionMode.MANUAL_FIRST) "人工优先" else "AI 辅助"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
if (selected == ExecutionMode.MANUAL_FIRST) {
|
||||
"直接采集候选并由人员确认"
|
||||
} else {
|
||||
"仅使用本机已配置的 VLM;后台不会读取模型配置"
|
||||
},
|
||||
color = colors.textSecondary,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginSection(
|
||||
state: ProcurementUiState,
|
||||
|
||||
+42
-7
@@ -25,7 +25,12 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -85,8 +90,8 @@ fun SearchProbeScreen(
|
||||
onStopRequirement: () -> Unit,
|
||||
onStartCandidateEvaluation: () -> Unit,
|
||||
onStopCandidateEvaluation: () -> Unit,
|
||||
onAcceptCandidate: () -> Unit,
|
||||
onRejectCandidates: () -> Unit,
|
||||
onAcceptCandidate: (String) -> Unit,
|
||||
onRejectCandidates: (String) -> Unit,
|
||||
onStart: () -> Unit,
|
||||
onStop: () -> Unit
|
||||
) {
|
||||
@@ -279,10 +284,11 @@ private fun CandidateEvaluationSection(
|
||||
canStart: Boolean,
|
||||
onStart: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
onReject: () -> Unit
|
||||
onAccept: (String) -> Unit,
|
||||
onReject: (String) -> Unit
|
||||
) {
|
||||
val colors = BaoziTheme.colors
|
||||
var operatorReason by remember(state) { mutableStateOf("") }
|
||||
val statusColor = when (state) {
|
||||
CandidateEvaluationState.AWAITING_CONFIRMATION,
|
||||
CandidateEvaluationState.HUMAN_ACCEPTED -> colors.success
|
||||
@@ -366,6 +372,20 @@ private fun CandidateEvaluationSection(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
if (state in setOf(
|
||||
CandidateEvaluationState.AWAITING_CONFIRMATION,
|
||||
CandidateEvaluationState.MANUAL_REVIEW,
|
||||
CandidateEvaluationState.NO_MATCH
|
||||
)) {
|
||||
OutlinedTextField(
|
||||
value = operatorReason,
|
||||
onValueChange = { operatorReason = it.take(1000) },
|
||||
label = { Text("人工确认理由") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
when (state) {
|
||||
CandidateEvaluationState.RUNNING -> {
|
||||
OutlinedButton(
|
||||
@@ -379,7 +399,8 @@ private fun CandidateEvaluationSection(
|
||||
}
|
||||
CandidateEvaluationState.AWAITING_CONFIRMATION -> {
|
||||
Button(
|
||||
onClick = onAccept,
|
||||
onClick = { onAccept(operatorReason) },
|
||||
enabled = operatorReason.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colors.primary
|
||||
@@ -391,7 +412,8 @@ private fun CandidateEvaluationSection(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = onReject,
|
||||
onClick = { onReject(operatorReason) },
|
||||
enabled = operatorReason.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
@@ -401,8 +423,21 @@ private fun CandidateEvaluationSection(
|
||||
}
|
||||
CandidateEvaluationState.MANUAL_REVIEW,
|
||||
CandidateEvaluationState.NO_MATCH -> {
|
||||
if (state == CandidateEvaluationState.MANUAL_REVIEW) {
|
||||
Button(
|
||||
onClick = { onAccept(operatorReason) },
|
||||
enabled = operatorReason.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("标记候选可用")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onReject,
|
||||
onClick = { onReject(operatorReason) },
|
||||
enabled = operatorReason.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ExecutionTaskHashTest {
|
||||
@Test
|
||||
fun taskHashUsesOnlyBackendRequirementFields() {
|
||||
val task = RemotePurchaseTask(
|
||||
id = "task-id",
|
||||
status = "RUNNING",
|
||||
version = 3,
|
||||
claimGeneration = 1,
|
||||
claimExpiresAt = "2099-01-01T00:00:00Z",
|
||||
title = "测试商品",
|
||||
description = "测试描述",
|
||||
sku = "SKU-01",
|
||||
imageAssetId = "asset-id",
|
||||
referenceImageUrl = "/api/v1/tasks/task-id/reference-image?claim_generation=1",
|
||||
quantity = 2,
|
||||
maxBudget = "20.00",
|
||||
currency = "CNY"
|
||||
)
|
||||
assertEquals(
|
||||
ExecutionTaskHash.sha256(task),
|
||||
ExecutionTaskHash.sha256(task.copy(version = 99, status = "CLAIMED"))
|
||||
)
|
||||
assertNotEquals(
|
||||
ExecutionTaskHash.sha256(task),
|
||||
ExecutionTaskHash.sha256(task.copy(quantity = 3))
|
||||
)
|
||||
}
|
||||
}
|
||||
+57
@@ -172,6 +172,61 @@ class ProcurementApiClientTest {
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun executionOutboxUsesDeviceResultEndpointsWithoutProviderConfiguration() = runBlocking {
|
||||
val task = task("/api/v1/tasks/task-id/reference-image?claim_generation=1")
|
||||
val execution = RunningExecution(
|
||||
id = "execution-id",
|
||||
currentStep = "SEARCH",
|
||||
expiresAt = "2099-01-01T00:00:00Z",
|
||||
serverClockOffsetMillis = 0
|
||||
)
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""{"evidence":{"id":"evidence-id"},"replayed":false}"""
|
||||
)
|
||||
)
|
||||
val evidenceResult = api.uploadExecutionOutboxItem(
|
||||
session = session(),
|
||||
task = task,
|
||||
execution = execution,
|
||||
claimToken = "claim-token-value",
|
||||
item = ExecutionOutboxItem(
|
||||
id = "local-evidence-id",
|
||||
type = ExecutionOutboxType.EVIDENCE,
|
||||
idempotencyKey = "evidence-idempotency-key",
|
||||
payload = "{}",
|
||||
evidenceRelativePath = "procurement-outbox/local-evidence-id.png"
|
||||
),
|
||||
evidenceBytes = byteArrayOf(1, 2, 3)
|
||||
)
|
||||
assertEquals("evidence-id", evidenceResult.evidenceAssetId)
|
||||
val evidenceRequest = server.takeRequest()
|
||||
assertEquals("/api/v1/tasks/task-id/evidence", evidenceRequest.path)
|
||||
assertEquals("claim-token-value", evidenceRequest.getHeader("X-Claim-Token"))
|
||||
assertEquals("execution-id", evidenceRequest.getHeader("X-Execution-ID"))
|
||||
assertEquals("1", evidenceRequest.getHeader("X-Claim-Generation"))
|
||||
assertTrue(evidenceRequest.getHeader("Content-Type")!!.startsWith("image/png"))
|
||||
|
||||
server.enqueue(jsonResponse("""{"replayed":false}"""))
|
||||
api.uploadExecutionOutboxItem(
|
||||
session = session(),
|
||||
task = task,
|
||||
execution = execution,
|
||||
claimToken = "claim-token-value",
|
||||
item = ExecutionOutboxItem(
|
||||
id = "event-id",
|
||||
type = ExecutionOutboxType.EVENTS,
|
||||
idempotencyKey = "event-idempotency-key",
|
||||
payload = """{"execution_id":"execution-id","claim_generation":1,"events":[]}"""
|
||||
)
|
||||
)
|
||||
val eventRequest = server.takeRequest()
|
||||
assertEquals("/api/v1/tasks/task-id/events", eventRequest.path)
|
||||
assertEquals("event-idempotency-key", eventRequest.getHeader("Idempotency-Key"))
|
||||
assertTrue(eventRequest.body.readUtf8().contains("execution-id"))
|
||||
}
|
||||
|
||||
private fun session() = ProcurementSession(
|
||||
backendUrl = server.url("/").toString().trimEnd('/'),
|
||||
username = "buyer01",
|
||||
@@ -191,6 +246,7 @@ class ProcurementApiClientTest {
|
||||
"title":"测试商品",
|
||||
"description":"测试描述",
|
||||
"sku":"SKU-01",
|
||||
"image_asset_id":"00000000-0000-4000-8000-000000000001",
|
||||
"reference_image_url":"/api/v1/tasks/task-id/reference-image?claim_generation=1",
|
||||
"quantity":2,
|
||||
"max_budget":"20.00",
|
||||
@@ -207,6 +263,7 @@ class ProcurementApiClientTest {
|
||||
title = "测试商品",
|
||||
description = "测试描述",
|
||||
sku = "SKU-01",
|
||||
imageAssetId = "00000000-0000-4000-8000-000000000001",
|
||||
referenceImageUrl = referenceImageUrl,
|
||||
quantity = 2,
|
||||
maxBudget = "20.00",
|
||||
|
||||
Reference in New Issue
Block a user