feat(t206): connect Android procurement tasks
This commit is contained in:
@@ -109,6 +109,15 @@
|
||||
android:value="automation_overlay" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".procurement.ProcurementExecutionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="procurement_task_control" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".accessibility.BuyerAccessibilityService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Application
|
||||
import android.content.pm.PackageManager
|
||||
import com.roubao.autopilot.controller.AppScanner
|
||||
import com.roubao.autopilot.controller.DeviceController
|
||||
import com.roubao.autopilot.procurement.ProcurementExecutionService
|
||||
import com.roubao.autopilot.procurement.ProcurementRepository
|
||||
import com.roubao.autopilot.skills.SkillManager
|
||||
import com.roubao.autopilot.tools.ToolManager
|
||||
import com.roubao.autopilot.utils.CrashHandler
|
||||
@@ -15,6 +17,8 @@ class App : Application() {
|
||||
private set
|
||||
lateinit var appScanner: AppScanner
|
||||
private set
|
||||
lateinit var procurementRepository: ProcurementRepository
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -38,6 +42,11 @@ class App : Application() {
|
||||
// 初始化应用扫描器
|
||||
appScanner = AppScanner(this)
|
||||
|
||||
procurementRepository = ProcurementRepository.create(this)
|
||||
if (procurementRepository.hasRunningExecution) {
|
||||
ProcurementExecutionService.start(this)
|
||||
}
|
||||
|
||||
// 初始化 Tools 层
|
||||
val toolManager = ToolManager.init(this, deviceController, appScanner)
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ import com.roubao.autopilot.pinduoduo.PinduoduoProbeAutomation
|
||||
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.ProcurementRepository
|
||||
import com.roubao.autopilot.workflow.WorkflowReport
|
||||
import com.roubao.autopilot.workflow.WorkflowRunner
|
||||
import com.roubao.autopilot.workflow.WorkflowState
|
||||
@@ -84,6 +86,7 @@ import com.roubao.autopilot.workflow.WorkflowState
|
||||
private const val TAG = "MainActivity"
|
||||
|
||||
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||
object Tasks : Screen("tasks", "任务", Icons.Outlined.ShoppingCart, Icons.Filled.ShoppingCart)
|
||||
object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build)
|
||||
object Home : Screen("home", "探针", Icons.Outlined.Search, Icons.Filled.Search)
|
||||
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
|
||||
@@ -96,6 +99,7 @@ class MainActivity : ComponentActivity() {
|
||||
private lateinit var settingsManager: SettingsManager
|
||||
private lateinit var executionRepository: ExecutionRepository
|
||||
private lateinit var readinessChecker: DeviceReadinessChecker
|
||||
private lateinit var procurementRepository: ProcurementRepository
|
||||
private lateinit var requirementProbeSource: RequirementProbeSource
|
||||
private lateinit var candidateEvidenceSource: CandidateEvidenceSource
|
||||
|
||||
@@ -178,6 +182,7 @@ class MainActivity : ComponentActivity() {
|
||||
settingsManager = SettingsManager(this)
|
||||
executionRepository = ExecutionRepository(this)
|
||||
readinessChecker = DeviceReadinessChecker(this)
|
||||
procurementRepository = (application as App).procurementRepository
|
||||
requirementProbeSource = RequirementProbeSource(this)
|
||||
candidateEvidenceSource = CandidateEvidenceSource(this)
|
||||
refreshReadiness()
|
||||
@@ -232,7 +237,7 @@ class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainApp() {
|
||||
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
|
||||
var currentScreen by remember { mutableStateOf<Screen>(Screen.Tasks) }
|
||||
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
|
||||
|
||||
val settings by settingsManager.settings.collectAsState()
|
||||
@@ -254,6 +259,7 @@ class MainActivity : ComponentActivity() {
|
||||
val evaluationState by remember { candidateEvaluationState }
|
||||
val reviewBatch by remember { candidateReviewBatch }
|
||||
val evaluationFailure by remember { candidateEvaluationFailureCode }
|
||||
val procurementState by procurementRepository.uiState.collectAsState()
|
||||
|
||||
// 监听跳转事件
|
||||
LaunchedEffect(navigateToRecord, recordId) {
|
||||
@@ -278,7 +284,13 @@ class MainActivity : ComponentActivity() {
|
||||
contentColor = colors.textPrimary,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
listOf(Screen.Home, Screen.Device, Screen.History, Screen.Settings).forEach { screen ->
|
||||
listOf(
|
||||
Screen.Tasks,
|
||||
Screen.Home,
|
||||
Screen.Device,
|
||||
Screen.History,
|
||||
Screen.Settings
|
||||
).forEach { screen ->
|
||||
val selected = currentScreen == screen
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
@@ -329,6 +341,35 @@ class MainActivity : ComponentActivity() {
|
||||
label = "screen"
|
||||
) { screen ->
|
||||
when (screen) {
|
||||
Screen.Tasks -> ProcurementScreen(
|
||||
state = procurementState,
|
||||
readiness = readiness,
|
||||
onLogin = { input: LoginInput ->
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.login(input)
|
||||
}
|
||||
},
|
||||
onClaim = {
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.claimNext(readiness)
|
||||
}
|
||||
},
|
||||
onStart = {
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.start()
|
||||
}
|
||||
},
|
||||
onRelease = {
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.release()
|
||||
}
|
||||
},
|
||||
onSync = {
|
||||
lifecycleScope.launch {
|
||||
procurementRepository.synchronizeNow()
|
||||
}
|
||||
}
|
||||
)
|
||||
Screen.Device -> DeviceReadinessScreen(
|
||||
snapshot = readiness,
|
||||
onRefresh = { refreshReadiness() },
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import java.net.URI
|
||||
|
||||
object BackendEndpointPolicy {
|
||||
fun normalize(rawUrl: String, debugBuild: Boolean): Result<String> = runCatching {
|
||||
val value = rawUrl.trim().trimEnd('/')
|
||||
val uri = URI(value)
|
||||
require(uri.host != null && uri.port != 0) { "后台地址必须包含主机" }
|
||||
require(uri.userInfo == null && uri.query == null && uri.fragment == null) {
|
||||
"后台地址不能包含账号、查询参数或片段"
|
||||
}
|
||||
require(uri.path.isNullOrEmpty() || uri.path == "/") { "后台地址不能包含路径" }
|
||||
when (uri.scheme?.lowercase()) {
|
||||
"https" -> Unit
|
||||
"http" -> require(debugBuild && uri.host.isLoopbackHost()) {
|
||||
"正式版只允许 HTTPS;调试版 HTTP 仅允许本机回环地址"
|
||||
}
|
||||
else -> error("后台地址必须使用 HTTPS")
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
private fun String.isLoopbackHost(): Boolean =
|
||||
equals("localhost", ignoreCase = true) ||
|
||||
this == "127.0.0.1" ||
|
||||
this == "::1" ||
|
||||
this == "[::1]"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import com.roubao.task.TaskSource
|
||||
|
||||
class HttpTaskSource(
|
||||
private val repository: ProcurementRepository
|
||||
) : TaskSource {
|
||||
override suspend fun nextTask() = repository.currentProbeTask()
|
||||
}
|
||||
+522
@@ -0,0 +1,522 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.time.Duration
|
||||
|
||||
class ProcurementApiException(
|
||||
val code: String,
|
||||
val statusCode: Int,
|
||||
val retryable: Boolean,
|
||||
message: String
|
||||
) : Exception(message)
|
||||
|
||||
data class DownloadedReferenceImage(
|
||||
val bytes: ByteArray,
|
||||
val sha256: String
|
||||
)
|
||||
|
||||
interface ProcurementRemoteApi {
|
||||
suspend fun login(
|
||||
baseUrl: String,
|
||||
input: LoginInput,
|
||||
appVersion: String,
|
||||
androidVersion: String
|
||||
): ProcurementSession
|
||||
|
||||
suspend fun deviceHeartbeat(
|
||||
session: ProcurementSession,
|
||||
input: DeviceHeartbeatInput
|
||||
): DeviceHeartbeatResult
|
||||
|
||||
suspend fun claimNext(
|
||||
session: ProcurementSession,
|
||||
deviceId: String,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): ClaimResult
|
||||
|
||||
suspend fun downloadReferenceImage(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
claimToken: String
|
||||
): DownloadedReferenceImage
|
||||
|
||||
suspend fun start(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): StartResult
|
||||
|
||||
suspend fun heartbeat(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String
|
||||
): TaskHeartbeatResult
|
||||
|
||||
suspend fun release(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): RemotePurchaseTask
|
||||
|
||||
suspend fun acknowledgeCancellation(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): RemotePurchaseTask
|
||||
}
|
||||
|
||||
class ProcurementApiClient(
|
||||
private val client: OkHttpClient = defaultClient()
|
||||
) : ProcurementRemoteApi {
|
||||
override suspend fun login(
|
||||
baseUrl: String,
|
||||
input: LoginInput,
|
||||
appVersion: String,
|
||||
androidVersion: String
|
||||
): ProcurementSession {
|
||||
val localNow = System.currentTimeMillis()
|
||||
val payload = JSONObject()
|
||||
.put("username", input.username.trim())
|
||||
.put("password", input.password)
|
||||
.put("device_id", input.deviceId.trim())
|
||||
.put("device_token", input.deviceToken)
|
||||
.put("app_version", appVersion)
|
||||
.put("android_version", androidVersion)
|
||||
val json = executeJson(
|
||||
Request.Builder()
|
||||
.url("$baseUrl/api/v1/auth/token")
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
)
|
||||
return ProcurementSession(
|
||||
backendUrl = baseUrl,
|
||||
username = input.username.trim(),
|
||||
deviceId = json.getJSONObject("device").getString("id"),
|
||||
accessToken = json.getString("access_token"),
|
||||
expiresAtEpochMillis =
|
||||
localNow + json.getLong("expires_in") * 1_000L
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun deviceHeartbeat(
|
||||
session: ProcurementSession,
|
||||
input: DeviceHeartbeatInput
|
||||
): DeviceHeartbeatResult {
|
||||
val payload = JSONObject()
|
||||
.put("device_id", session.deviceId)
|
||||
.put("app_version", input.appVersion)
|
||||
.put("android_version", input.androidVersion)
|
||||
.put("pdd_version", input.pddVersion)
|
||||
.put(
|
||||
"readiness",
|
||||
JSONObject()
|
||||
.put("accessibility_enabled", input.accessibilityEnabled)
|
||||
.put("pdd_installed", input.pddInstalled)
|
||||
.put(
|
||||
"active_task_id",
|
||||
input.activeTaskId ?: JSONObject.NULL
|
||||
)
|
||||
)
|
||||
val json = executeJson(
|
||||
authorizedRequest(session, "/api/v1/devices/heartbeat")
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
)
|
||||
return DeviceHeartbeatResult(
|
||||
activeTaskId = json.optionalString("active_task_id"),
|
||||
clientStateMatches = json.getBoolean("client_state_matches"),
|
||||
serverTime = json.getString("server_time")
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun claimNext(
|
||||
session: ProcurementSession,
|
||||
deviceId: String,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): ClaimResult = withContext(Dispatchers.IO) {
|
||||
val response = client.newCall(
|
||||
authorizedRequest(session, "/api/v1/tasks/claim-next")
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||
.post(JSONObject().put("device_id", deviceId).jsonBody())
|
||||
.build()
|
||||
).execute()
|
||||
response.use {
|
||||
if (it.code == 204) {
|
||||
return@withContext ClaimResult(null, null)
|
||||
}
|
||||
val json = responseJsonOrThrow(
|
||||
it.code,
|
||||
it.body.readBoundedJson(it.code)
|
||||
)
|
||||
ClaimResult(
|
||||
task = parseTask(json.getJSONObject("task")),
|
||||
serverTime = json.getString("server_time")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadReferenceImage(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
claimToken: String
|
||||
): DownloadedReferenceImage = withContext(Dispatchers.IO) {
|
||||
val target = referenceImageTarget(session, task)
|
||||
val request = Request.Builder()
|
||||
.url(target)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.get()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
responseJsonOrThrow(
|
||||
response.code,
|
||||
response.body.readBoundedJson(response.code)
|
||||
)
|
||||
}
|
||||
val contentType = response.header("Content-Type")
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
if (contentType != JPEG_MEDIA_TYPE) {
|
||||
throw ProcurementApiException(
|
||||
"REFERENCE_IMAGE_INVALID",
|
||||
response.code,
|
||||
false,
|
||||
"参考图不是 JPEG"
|
||||
)
|
||||
}
|
||||
val declaredLength = response.body?.contentLength() ?: -1L
|
||||
if (declaredLength > MAX_REFERENCE_IMAGE_BYTES) {
|
||||
throw imageTooLarge()
|
||||
}
|
||||
val bytes = response.body?.byteStream()?.use {
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var total = 0L
|
||||
while (true) {
|
||||
val read = it.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > MAX_REFERENCE_IMAGE_BYTES) throw imageTooLarge()
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
} ?: ByteArray(0)
|
||||
if (bytes.size < 3 ||
|
||||
bytes[0] != 0xFF.toByte() ||
|
||||
bytes[1] != 0xD8.toByte() ||
|
||||
bytes[2] != 0xFF.toByte()
|
||||
) {
|
||||
throw ProcurementApiException(
|
||||
"REFERENCE_IMAGE_INVALID",
|
||||
response.code,
|
||||
false,
|
||||
"参考图内容无效"
|
||||
)
|
||||
}
|
||||
val sha256 = bytes.sha256()
|
||||
val expectedHash = response.header("ETag")
|
||||
?.trim()
|
||||
?.removePrefix("W/")
|
||||
?.trim('"')
|
||||
?.takeIf { SHA256_PATTERN.matches(it) }
|
||||
if (expectedHash != null && expectedHash != sha256) {
|
||||
throw ProcurementApiException(
|
||||
"REFERENCE_IMAGE_HASH_MISMATCH",
|
||||
response.code,
|
||||
false,
|
||||
"参考图校验失败"
|
||||
)
|
||||
}
|
||||
DownloadedReferenceImage(bytes, sha256)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): StartResult {
|
||||
val payload = transitionPayload(session.deviceId, task)
|
||||
val json = executeJson(
|
||||
authorizedRequest(session, "/api/v1/tasks/${task.id}/start")
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
)
|
||||
val execution = json.getJSONObject("execution")
|
||||
return StartResult(
|
||||
task = parseTask(json.getJSONObject("task")),
|
||||
executionId = execution.getString("id"),
|
||||
currentStep = execution.getString("current_step"),
|
||||
executionExpiresAt = execution.getString("execution_expires_at"),
|
||||
serverTime = json.getString("server_time")
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun heartbeat(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String
|
||||
): TaskHeartbeatResult {
|
||||
val payload = JSONObject()
|
||||
.put("device_id", session.deviceId)
|
||||
.put("execution_id", execution.id)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put("step", execution.currentStep)
|
||||
val json = executeJson(
|
||||
authorizedRequest(session, "/api/v1/tasks/${task.id}/heartbeat")
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
)
|
||||
val responseExecution = json.getJSONObject("execution")
|
||||
return TaskHeartbeatResult(
|
||||
task = parseTask(json.getJSONObject("task")),
|
||||
executionId = responseExecution.getString("id"),
|
||||
currentStep = responseExecution.getString("current_step"),
|
||||
executionExpiresAt =
|
||||
responseExecution.getString("execution_expires_at"),
|
||||
cancelRequested = json.getBoolean("cancel_requested"),
|
||||
serverTime = json.getString("server_time")
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun release(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): RemotePurchaseTask =
|
||||
executeTransition(
|
||||
session,
|
||||
task,
|
||||
claimToken,
|
||||
idempotencyKey,
|
||||
"release"
|
||||
)
|
||||
|
||||
override suspend fun acknowledgeCancellation(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
execution: RunningExecution,
|
||||
claimToken: String,
|
||||
idempotencyKey: String
|
||||
): RemotePurchaseTask {
|
||||
val payload = transitionPayload(session.deviceId, task)
|
||||
.put("execution_id", execution.id)
|
||||
val json = executeJson(
|
||||
authorizedRequest(session, "/api/v1/tasks/${task.id}/cancel-ack")
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||
.post(payload.jsonBody())
|
||||
.build()
|
||||
)
|
||||
return parseTask(json.getJSONObject("task"))
|
||||
}
|
||||
|
||||
private suspend fun executeTransition(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask,
|
||||
claimToken: String,
|
||||
idempotencyKey: String,
|
||||
operation: String
|
||||
): RemotePurchaseTask {
|
||||
val json = executeJson(
|
||||
authorizedRequest(session, "/api/v1/tasks/${task.id}/$operation")
|
||||
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||
.post(transitionPayload(session.deviceId, task).jsonBody())
|
||||
.build()
|
||||
)
|
||||
return parseTask(json.getJSONObject("task"))
|
||||
}
|
||||
|
||||
private fun authorizedRequest(
|
||||
session: ProcurementSession,
|
||||
path: String
|
||||
): Request.Builder =
|
||||
Request.Builder()
|
||||
.url(session.backendUrl + path)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
|
||||
private suspend fun executeJson(request: Request): JSONObject =
|
||||
withContext(Dispatchers.IO) {
|
||||
client.newCall(request).execute().use { response ->
|
||||
responseJsonOrThrow(
|
||||
response.code,
|
||||
response.body.readBoundedJson(response.code)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun responseJsonOrThrow(statusCode: Int, rawBody: String?): JSONObject {
|
||||
val json = runCatching { JSONObject(rawBody ?: "") }.getOrElse {
|
||||
throw ProcurementApiException(
|
||||
"INVALID_RESPONSE",
|
||||
statusCode,
|
||||
statusCode >= 500,
|
||||
"后台响应格式无效"
|
||||
)
|
||||
}
|
||||
if (statusCode !in 200..299) {
|
||||
val error = json.optJSONObject("error")
|
||||
throw ProcurementApiException(
|
||||
code = error?.optString("code")?.takeIf(String::isNotBlank)
|
||||
?: "HTTP_$statusCode",
|
||||
statusCode = statusCode,
|
||||
retryable = error?.optBoolean("retryable", statusCode >= 500)
|
||||
?: (statusCode >= 500),
|
||||
message = stableErrorMessage(
|
||||
error?.optString("code"),
|
||||
statusCode
|
||||
)
|
||||
)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private fun stableErrorMessage(code: String?, statusCode: Int): String =
|
||||
when (code) {
|
||||
"AUTH_INVALID_CREDENTIALS" -> "账号、密码或设备凭证错误"
|
||||
"AUTH_ACCOUNT_OR_DEVICE_DISABLED" -> "账号或设备已停用"
|
||||
"AUTH_RATE_LIMITED" -> "登录尝试过多,请稍后重试"
|
||||
"DEVICE_NOT_READY" -> "设备未就绪,无法领取任务"
|
||||
"DEVICE_HAS_ACTIVE_TASK" -> "设备已有进行中的任务"
|
||||
"TASK_CLAIM_INVALID" -> "任务归属校验失败"
|
||||
"TASK_CLAIM_EXPIRED" -> "任务授权已过期"
|
||||
"TASK_VERSION_CONFLICT" -> "任务已被后台更新,请先同步"
|
||||
else -> if (statusCode >= 500) "后台暂时不可用" else "后台拒绝了请求"
|
||||
}
|
||||
|
||||
private fun transitionPayload(
|
||||
deviceId: String,
|
||||
task: RemotePurchaseTask
|
||||
): JSONObject =
|
||||
JSONObject()
|
||||
.put("device_id", deviceId)
|
||||
.put("claim_generation", task.claimGeneration)
|
||||
.put("expected_version", task.version)
|
||||
|
||||
private fun referenceImageTarget(
|
||||
session: ProcurementSession,
|
||||
task: RemotePurchaseTask
|
||||
): String {
|
||||
val backend = URI(session.backendUrl)
|
||||
val target = backend.resolve(task.referenceImageUrl)
|
||||
val expectedPath = "/api/v1/tasks/${task.id}/reference-image"
|
||||
val sameOrigin =
|
||||
target.scheme.equals(backend.scheme, ignoreCase = true) &&
|
||||
target.host.equals(backend.host, ignoreCase = true) &&
|
||||
target.port == backend.port
|
||||
if (!sameOrigin ||
|
||||
target.userInfo != null ||
|
||||
target.fragment != null ||
|
||||
target.path != expectedPath
|
||||
) {
|
||||
throw ProcurementApiException(
|
||||
"REFERENCE_IMAGE_ORIGIN_INVALID",
|
||||
0,
|
||||
false,
|
||||
"参考图地址不可信"
|
||||
)
|
||||
}
|
||||
return target.toString()
|
||||
}
|
||||
|
||||
private fun parseTask(json: JSONObject): RemotePurchaseTask =
|
||||
RemotePurchaseTask(
|
||||
id = json.getString("id"),
|
||||
status = json.getString("status"),
|
||||
version = json.getLong("version"),
|
||||
claimGeneration = json.getLong("claim_generation"),
|
||||
claimExpiresAt = json.optionalString("claim_expires_at"),
|
||||
title = json.getString("title"),
|
||||
description = json.optString("description"),
|
||||
sku = json.getString("sku"),
|
||||
referenceImageUrl = json.getString("reference_image_url"),
|
||||
quantity = json.getInt("quantity"),
|
||||
maxBudget = json.optionalString("max_budget"),
|
||||
currency = json.optString("currency", "CNY")
|
||||
)
|
||||
|
||||
private fun JSONObject.jsonBody() =
|
||||
toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||
|
||||
private fun JSONObject.optionalString(name: String): String? =
|
||||
if (has(name) && !isNull(name)) getString(name) else null
|
||||
|
||||
private fun ResponseBody?.readBoundedJson(statusCode: Int): String? {
|
||||
this ?: return null
|
||||
if (contentLength() > MAX_JSON_BYTES) {
|
||||
throw responseTooLarge(statusCode)
|
||||
}
|
||||
val source = source()
|
||||
source.request(MAX_JSON_BYTES + 1L)
|
||||
if (source.buffer.size > MAX_JSON_BYTES) {
|
||||
throw responseTooLarge(statusCode)
|
||||
}
|
||||
return source.readUtf8()
|
||||
}
|
||||
|
||||
private fun ByteArray.sha256(): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(this)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun imageTooLarge() = ProcurementApiException(
|
||||
"REFERENCE_IMAGE_TOO_LARGE",
|
||||
200,
|
||||
false,
|
||||
"参考图超过 20 MiB"
|
||||
)
|
||||
|
||||
private fun responseTooLarge(statusCode: Int) = ProcurementApiException(
|
||||
"RESPONSE_TOO_LARGE",
|
||||
statusCode,
|
||||
false,
|
||||
"后台响应过大"
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".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_REFERENCE_IMAGE_BYTES = 20L * 1024L * 1024L
|
||||
private val SHA256_PATTERN = Regex("[0-9a-f]{64}")
|
||||
|
||||
private fun defaultClient(): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.readTimeout(Duration.ofSeconds(30))
|
||||
.writeTimeout(Duration.ofSeconds(30))
|
||||
.callTimeout(Duration.ofSeconds(45))
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.roubao.autopilot.App
|
||||
import com.roubao.autopilot.MainActivity
|
||||
import com.roubao.autopilot.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProcurementExecutionService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var heartbeatJob: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, notification())
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (heartbeatJob?.isActive != true) {
|
||||
heartbeatJob = scope.launch {
|
||||
val repository = (application as App).procurementRepository
|
||||
while (isActive) {
|
||||
val decision = repository.synchronizeRunning()
|
||||
if (decision == ExecutionSyncDecision.STOP) {
|
||||
stopSelf()
|
||||
break
|
||||
}
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun notification(): Notification {
|
||||
val openApp = Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
openApp,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("肉包采购任务运行中")
|
||||
.setContentText("正在同步任务进度和后台取消请求")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"采购任务",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "保持采购任务运行并同步进度"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "procurement_execution"
|
||||
private const val NOTIFICATION_ID = 206
|
||||
private const val HEARTBEAT_INTERVAL_MS = 30_000L
|
||||
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, ProcurementExecutionService::class.java)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import com.roubao.task.ProbeReferenceImage
|
||||
import com.roubao.task.ProbeTask
|
||||
import java.time.Instant
|
||||
|
||||
enum class ProcurementPhase {
|
||||
SIGNED_OUT,
|
||||
IDLE,
|
||||
CLAIMED,
|
||||
RUNNING,
|
||||
AUTHORIZATION_EXPIRED
|
||||
}
|
||||
|
||||
data class ConnectionProfile(
|
||||
val backendUrl: String = "http://127.0.0.1:8080",
|
||||
val username: String = "",
|
||||
val deviceId: String = "",
|
||||
val hasDeviceToken: Boolean = false
|
||||
)
|
||||
|
||||
data class ProcurementSession(
|
||||
val backendUrl: String,
|
||||
val username: String,
|
||||
val deviceId: String,
|
||||
val accessToken: String,
|
||||
val expiresAtEpochMillis: Long
|
||||
) {
|
||||
fun isValid(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
|
||||
accessToken.isNotBlank() && expiresAtEpochMillis > nowEpochMillis
|
||||
}
|
||||
|
||||
data class RemotePurchaseTask(
|
||||
val id: String,
|
||||
val status: String,
|
||||
val version: Long,
|
||||
val claimGeneration: Long,
|
||||
val claimExpiresAt: String?,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val sku: String,
|
||||
val referenceImageUrl: String,
|
||||
val quantity: Int,
|
||||
val maxBudget: String?,
|
||||
val currency: String
|
||||
)
|
||||
|
||||
data class ReferenceImageRecord(
|
||||
val relativePath: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String
|
||||
)
|
||||
|
||||
data class ClaimContext(
|
||||
val token: String,
|
||||
val idempotencyKey: String,
|
||||
val task: RemotePurchaseTask? = null,
|
||||
val referenceImage: ReferenceImageRecord? = null,
|
||||
val previewReadyAtEpochMillis: Long? = null,
|
||||
val startIdempotencyKey: String? = null,
|
||||
val releaseIdempotencyKey: String? = null
|
||||
)
|
||||
|
||||
data class RunningExecution(
|
||||
val id: String,
|
||||
val currentStep: String,
|
||||
val expiresAt: String,
|
||||
val serverClockOffsetMillis: Long,
|
||||
val safetyStopped: Boolean = false,
|
||||
val cancelAcknowledgementKey: String? = null
|
||||
) {
|
||||
fun isExpired(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
|
||||
ExecutionAuthorization.isExpired(
|
||||
expiresAt = expiresAt,
|
||||
serverClockOffsetMillis = serverClockOffsetMillis,
|
||||
nowEpochMillis = nowEpochMillis
|
||||
)
|
||||
}
|
||||
|
||||
data class PersistedProcurementState(
|
||||
val session: ProcurementSession? = null,
|
||||
val deviceToken: String? = null,
|
||||
val claim: ClaimContext? = null,
|
||||
val execution: RunningExecution? = null
|
||||
)
|
||||
|
||||
data class ProcurementUiState(
|
||||
val phase: ProcurementPhase = ProcurementPhase.SIGNED_OUT,
|
||||
val profile: ConnectionProfile = ConnectionProfile(),
|
||||
val task: RemotePurchaseTask? = null,
|
||||
val referenceImagePath: String? = null,
|
||||
val execution: RunningExecution? = null,
|
||||
val authenticationRequired: Boolean = false,
|
||||
val busy: Boolean = false,
|
||||
val backendOnline: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class LoginInput(
|
||||
val backendUrl: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val deviceId: String,
|
||||
val deviceToken: String
|
||||
)
|
||||
|
||||
data class DeviceHeartbeatInput(
|
||||
val appVersion: String,
|
||||
val androidVersion: String,
|
||||
val pddVersion: String,
|
||||
val accessibilityEnabled: Boolean,
|
||||
val pddInstalled: Boolean,
|
||||
val activeTaskId: String?
|
||||
)
|
||||
|
||||
data class DeviceHeartbeatResult(
|
||||
val activeTaskId: String?,
|
||||
val clientStateMatches: Boolean,
|
||||
val serverTime: String
|
||||
)
|
||||
|
||||
data class ClaimResult(
|
||||
val task: RemotePurchaseTask?,
|
||||
val serverTime: String?
|
||||
)
|
||||
|
||||
data class StartResult(
|
||||
val task: RemotePurchaseTask,
|
||||
val executionId: String,
|
||||
val currentStep: String,
|
||||
val executionExpiresAt: String,
|
||||
val serverTime: String
|
||||
)
|
||||
|
||||
data class TaskHeartbeatResult(
|
||||
val task: RemotePurchaseTask,
|
||||
val executionId: String,
|
||||
val currentStep: String,
|
||||
val executionExpiresAt: String,
|
||||
val cancelRequested: Boolean,
|
||||
val serverTime: String
|
||||
)
|
||||
|
||||
object ExecutionAuthorization {
|
||||
fun isExpired(
|
||||
expiresAt: String,
|
||||
serverClockOffsetMillis: Long,
|
||||
nowEpochMillis: Long
|
||||
): Boolean {
|
||||
val serverNow = nowEpochMillis + serverClockOffsetMillis
|
||||
return runCatching { Instant.parse(expiresAt).toEpochMilli() <= serverNow }
|
||||
.getOrDefault(true)
|
||||
}
|
||||
|
||||
fun serverClockOffset(serverTime: String, localNowEpochMillis: Long): Long =
|
||||
Instant.parse(serverTime).toEpochMilli() - localNowEpochMillis
|
||||
}
|
||||
|
||||
object StartAuthorization {
|
||||
const val MINIMUM_PREVIEW_DURATION_MS = 2_000L
|
||||
|
||||
fun isPreviewConfirmed(
|
||||
previewReadyAtEpochMillis: Long,
|
||||
nowEpochMillis: Long
|
||||
): Boolean =
|
||||
nowEpochMillis - previewReadyAtEpochMillis >=
|
||||
MINIMUM_PREVIEW_DURATION_MS
|
||||
}
|
||||
|
||||
fun RemotePurchaseTask.toProbeTask(image: ReferenceImageRecord): ProbeTask =
|
||||
ProbeTask(
|
||||
probeId = id,
|
||||
sourceOrderNo = id,
|
||||
sourceStoreName = "管理后台",
|
||||
title = title,
|
||||
sku = sku,
|
||||
quantity = quantity,
|
||||
referenceImage = ProbeReferenceImage(
|
||||
relativePath = image.relativePath,
|
||||
mediaType = "image/jpeg",
|
||||
sizeBytes = image.sizeBytes,
|
||||
sha256 = image.sha256
|
||||
)
|
||||
)
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import com.roubao.autopilot.BuildConfig
|
||||
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
|
||||
enum class ExecutionSyncDecision {
|
||||
CONTINUE,
|
||||
STOP
|
||||
}
|
||||
|
||||
class ProcurementRepository(
|
||||
context: Context,
|
||||
private val store: ProcurementStateStore,
|
||||
private val api: ProcurementRemoteApi,
|
||||
private val storageFailure: String? = null
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val mutex = Mutex()
|
||||
@Volatile
|
||||
private var persisted = store.load()
|
||||
private val _uiState = MutableStateFlow(
|
||||
buildUiState().copy(error = storageFailure)
|
||||
)
|
||||
|
||||
val uiState: StateFlow<ProcurementUiState> = _uiState.asStateFlow()
|
||||
|
||||
val hasRunningExecution: Boolean
|
||||
get() = storageFailure == null &&
|
||||
persisted.execution?.safetyStopped == false
|
||||
|
||||
suspend fun login(input: LoginInput): Boolean = operation {
|
||||
require(input.password.isNotEmpty()) { "请输入采购员密码" }
|
||||
val normalizedUrl = BackendEndpointPolicy.normalize(
|
||||
input.backendUrl,
|
||||
BuildConfig.DEBUG
|
||||
).getOrElse { throw IllegalArgumentException(it.message) }
|
||||
persisted.session?.takeIf {
|
||||
persisted.claim != null || persisted.execution != null
|
||||
}?.let { current ->
|
||||
require(
|
||||
normalizedUrl == current.backendUrl &&
|
||||
input.username.trim() == current.username &&
|
||||
input.deviceId.trim() == current.deviceId
|
||||
) { "当前任务结束前只能重新认证同一采购员和设备" }
|
||||
}
|
||||
val deviceToken = input.deviceToken.ifBlank {
|
||||
persisted.deviceToken ?: ""
|
||||
}
|
||||
require(deviceToken.isNotBlank()) { "请输入预授权设备密钥" }
|
||||
val session = api.login(
|
||||
normalizedUrl,
|
||||
input.copy(deviceToken = deviceToken),
|
||||
BuildConfig.VERSION_NAME,
|
||||
Build.VERSION.RELEASE
|
||||
)
|
||||
persisted = persisted.copy(
|
||||
session = session,
|
||||
deviceToken = deviceToken
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "设备登录成功"
|
||||
)
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun claimNext(snapshot: DeviceReadinessSnapshot): Boolean = operation {
|
||||
val session = requireValidSession()
|
||||
val existing = persisted.claim
|
||||
if (existing?.task != null) {
|
||||
if (existing.referenceImage == null) {
|
||||
downloadAndPersistReference(session, existing, existing.task)
|
||||
}
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "已恢复当前领取任务"
|
||||
)
|
||||
return@operation true
|
||||
}
|
||||
val heartbeat = api.deviceHeartbeat(
|
||||
session,
|
||||
snapshot.toHeartbeatInput(persisted.claim?.task?.id)
|
||||
)
|
||||
if (heartbeat.activeTaskId != null &&
|
||||
existing == null
|
||||
) {
|
||||
throw IllegalStateException("后台显示本设备已有任务,请先恢复处理")
|
||||
}
|
||||
val claim = existing ?: ClaimContext(
|
||||
token = newOpaqueSecret(),
|
||||
idempotencyKey = newOpaqueSecret()
|
||||
).also {
|
||||
persisted = persisted.copy(claim = it)
|
||||
store.save(persisted)
|
||||
}
|
||||
val result = api.claimNext(
|
||||
session,
|
||||
session.deviceId,
|
||||
claim.token,
|
||||
claim.idempotencyKey
|
||||
)
|
||||
val task = result.task
|
||||
if (task == null) {
|
||||
persisted = persisted.copy(claim = null)
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "暂无待采购任务"
|
||||
)
|
||||
return@operation false
|
||||
}
|
||||
val claimed = claim.copy(task = task)
|
||||
persisted = persisted.copy(claim = claimed)
|
||||
store.save(persisted)
|
||||
downloadAndPersistReference(session, claimed, task)
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "任务已领取,请核对后开始"
|
||||
)
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun start(): Boolean = operation {
|
||||
val session = requireValidSession()
|
||||
val claim = requireNotNull(persisted.claim) { "没有可开始的任务" }
|
||||
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
|
||||
require(claim.referenceImage != null) { "参考图尚未安全下载" }
|
||||
val previewReadyAt = requireNotNull(claim.previewReadyAtEpochMillis) {
|
||||
"任务预览尚未准备完成"
|
||||
}
|
||||
require(
|
||||
StartAuthorization.isPreviewConfirmed(
|
||||
previewReadyAt,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
) { "请先核对任务信息,再确认开始" }
|
||||
val startKey = claim.startIdempotencyKey ?: newOpaqueSecret().also {
|
||||
persisted = persisted.copy(
|
||||
claim = claim.copy(startIdempotencyKey = it)
|
||||
)
|
||||
store.save(persisted)
|
||||
}
|
||||
val requestStartedAt = System.currentTimeMillis()
|
||||
val result = api.start(session, task, claim.token, startKey)
|
||||
val execution = RunningExecution(
|
||||
id = result.executionId,
|
||||
currentStep = CONTROLLED_WORKFLOW_STEP,
|
||||
expiresAt = result.executionExpiresAt,
|
||||
serverClockOffsetMillis = ExecutionAuthorization.serverClockOffset(
|
||||
result.serverTime,
|
||||
requestStartedAt
|
||||
)
|
||||
)
|
||||
persisted = persisted.copy(
|
||||
claim = persisted.claim?.copy(task = result.task),
|
||||
execution = execution
|
||||
)
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "受控采购流程已开始"
|
||||
)
|
||||
ProcurementExecutionService.start(appContext)
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun release(): Boolean = operation {
|
||||
val session = requireValidSession()
|
||||
val claim = requireNotNull(persisted.claim) { "没有已领取任务" }
|
||||
require(persisted.execution == null) { "运行中的任务不能直接释放" }
|
||||
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
|
||||
val releaseKey = claim.releaseIdempotencyKey ?: newOpaqueSecret().also {
|
||||
persisted = persisted.copy(
|
||||
claim = claim.copy(releaseIdempotencyKey = it)
|
||||
)
|
||||
store.save(persisted)
|
||||
}
|
||||
api.release(session, task, claim.token, releaseKey)
|
||||
clearCurrentTask()
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "任务已退回待领取队列"
|
||||
)
|
||||
true
|
||||
} ?: false
|
||||
|
||||
suspend fun synchronizeRunning(): ExecutionSyncDecision =
|
||||
synchronize(allowSafetyStoppedSync = false)
|
||||
|
||||
private suspend fun synchronize(
|
||||
allowSafetyStoppedSync: Boolean
|
||||
): ExecutionSyncDecision = mutex.withLock {
|
||||
val claim = persisted.claim
|
||||
val task = claim?.task
|
||||
var execution = persisted.execution
|
||||
if (claim == null || task == null || execution == null) {
|
||||
return@withLock ExecutionSyncDecision.STOP
|
||||
}
|
||||
if (execution.isExpired() && !execution.safetyStopped) {
|
||||
execution = execution.copy(
|
||||
currentStep = SAFE_STOPPED_STEP,
|
||||
safetyStopped = true
|
||||
)
|
||||
persisted = persisted.copy(execution = execution)
|
||||
store.save(persisted)
|
||||
publish(
|
||||
backendOnline = false,
|
||||
error = "离线执行授权已到期,自动化已安全停止"
|
||||
)
|
||||
}
|
||||
if (execution.safetyStopped && !allowSafetyStoppedSync) {
|
||||
return@withLock ExecutionSyncDecision.STOP
|
||||
}
|
||||
val session = persisted.session
|
||||
if (session == null || !session.isValid()) {
|
||||
publish(
|
||||
backendOnline = false,
|
||||
error = "登录令牌已过期;授权截止前保持停止外部动作"
|
||||
)
|
||||
return@withLock if (execution.safetyStopped) {
|
||||
ExecutionSyncDecision.STOP
|
||||
} else {
|
||||
ExecutionSyncDecision.CONTINUE
|
||||
}
|
||||
}
|
||||
return@withLock try {
|
||||
val requestStartedAt = System.currentTimeMillis()
|
||||
val result = api.heartbeat(session, task, execution, claim.token)
|
||||
val updatedExecution = execution.copy(
|
||||
id = result.executionId,
|
||||
currentStep = result.currentStep,
|
||||
expiresAt = result.executionExpiresAt,
|
||||
serverClockOffsetMillis =
|
||||
ExecutionAuthorization.serverClockOffset(
|
||||
result.serverTime,
|
||||
requestStartedAt
|
||||
),
|
||||
safetyStopped = execution.safetyStopped
|
||||
)
|
||||
persisted = persisted.copy(
|
||||
claim = claim.copy(task = result.task),
|
||||
execution = updatedExecution
|
||||
)
|
||||
store.save(persisted)
|
||||
if (result.cancelRequested) {
|
||||
acknowledgeCancellation(session)
|
||||
ExecutionSyncDecision.STOP
|
||||
} else {
|
||||
publish(
|
||||
backendOnline = true,
|
||||
message = if (updatedExecution.safetyStopped) {
|
||||
"后台状态已同步,任务仍保持安全停止"
|
||||
} else {
|
||||
"进度已同步"
|
||||
}
|
||||
)
|
||||
if (updatedExecution.safetyStopped) {
|
||||
ExecutionSyncDecision.STOP
|
||||
} else {
|
||||
ExecutionSyncDecision.CONTINUE
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
publish(
|
||||
backendOnline = false,
|
||||
message = "后台暂时离线,将在授权截止前重试"
|
||||
)
|
||||
if (execution.safetyStopped || execution.isExpired()) {
|
||||
ExecutionSyncDecision.STOP
|
||||
} else {
|
||||
ExecutionSyncDecision.CONTINUE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun synchronizeNow(): Boolean =
|
||||
synchronize(allowSafetyStoppedSync = true) ==
|
||||
ExecutionSyncDecision.CONTINUE
|
||||
|
||||
fun currentProbeTask() =
|
||||
persisted.claim?.let { claim ->
|
||||
val task = claim.task
|
||||
val image = claim.referenceImage
|
||||
if (task != null && image != null) task.toProbeTask(image) else null
|
||||
}
|
||||
|
||||
private suspend fun acknowledgeCancellation(session: ProcurementSession) {
|
||||
val claim = requireNotNull(persisted.claim)
|
||||
val task = requireNotNull(claim.task)
|
||||
val execution = requireNotNull(persisted.execution)
|
||||
val acknowledgementKey =
|
||||
execution.cancelAcknowledgementKey ?: newOpaqueSecret().also {
|
||||
persisted = persisted.copy(
|
||||
execution = execution.copy(cancelAcknowledgementKey = it)
|
||||
)
|
||||
store.save(persisted)
|
||||
}
|
||||
api.acknowledgeCancellation(
|
||||
session,
|
||||
task,
|
||||
requireNotNull(persisted.execution),
|
||||
claim.token,
|
||||
acknowledgementKey
|
||||
)
|
||||
clearCurrentTask()
|
||||
saveAndPublish(
|
||||
backendOnline = true,
|
||||
message = "后台取消已确认,任务已停止"
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun downloadAndPersistReference(
|
||||
session: ProcurementSession,
|
||||
claim: ClaimContext,
|
||||
task: RemotePurchaseTask
|
||||
) {
|
||||
val image = api.downloadReferenceImage(session, task, claim.token)
|
||||
val relativePath = "$REFERENCE_DIRECTORY/reference-${task.id}.jpg"
|
||||
val directory = File(appContext.filesDir, REFERENCE_DIRECTORY)
|
||||
check(directory.exists() || directory.mkdirs()) { "无法创建参考图目录" }
|
||||
val destination = File(appContext.filesDir, relativePath)
|
||||
val temporary = File(directory, ".reference-${task.id}.tmp")
|
||||
temporary.outputStream().use {
|
||||
it.write(image.bytes)
|
||||
it.fd.sync()
|
||||
}
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(temporary.absolutePath, options)
|
||||
check(options.outWidth > 0 && options.outHeight > 0) { "参考图无法解码" }
|
||||
check(
|
||||
options.outWidth <= MAX_REFERENCE_DIMENSION &&
|
||||
options.outHeight <= MAX_REFERENCE_DIMENSION &&
|
||||
options.outWidth.toLong() * options.outHeight <=
|
||||
MAX_REFERENCE_PIXELS
|
||||
) { "参考图尺寸超过安全限制" }
|
||||
check(!destination.exists() || destination.delete()) { "无法替换参考图" }
|
||||
check(temporary.renameTo(destination)) { "无法保存参考图" }
|
||||
persisted = persisted.copy(
|
||||
claim = claim.copy(
|
||||
task = task,
|
||||
referenceImage = ReferenceImageRecord(
|
||||
relativePath = relativePath,
|
||||
sizeBytes = image.bytes.size.toLong(),
|
||||
sha256 = image.sha256
|
||||
),
|
||||
previewReadyAtEpochMillis = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
store.save(persisted)
|
||||
}
|
||||
|
||||
private fun clearCurrentTask() {
|
||||
persisted.claim?.referenceImage?.let {
|
||||
File(appContext.filesDir, it.relativePath).delete()
|
||||
}
|
||||
persisted = persisted.copy(claim = null, execution = null)
|
||||
}
|
||||
|
||||
private fun requireValidSession(): ProcurementSession {
|
||||
val session = persisted.session
|
||||
require(session != null && session.isValid()) { "采购员登录已过期,请重新登录" }
|
||||
return session
|
||||
}
|
||||
|
||||
private suspend fun <T> operation(block: suspend () -> T): T? = mutex.withLock {
|
||||
if (storageFailure != null) {
|
||||
_uiState.value = _uiState.value.copy(error = storageFailure)
|
||||
return@withLock null
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(busy = true, error = null)
|
||||
try {
|
||||
block()
|
||||
} catch (error: Exception) {
|
||||
publish(
|
||||
backendOnline = if (error is IOException) false else _uiState.value.backendOnline,
|
||||
error = userMessage(error)
|
||||
)
|
||||
null
|
||||
} finally {
|
||||
_uiState.value = _uiState.value.copy(busy = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAndPublish(
|
||||
backendOnline: Boolean?,
|
||||
message: String? = null
|
||||
) {
|
||||
store.save(persisted)
|
||||
publish(backendOnline = backendOnline, message = message)
|
||||
}
|
||||
|
||||
private fun publish(
|
||||
backendOnline: Boolean?,
|
||||
message: String? = null,
|
||||
error: String? = null
|
||||
) {
|
||||
_uiState.value = buildUiState().copy(
|
||||
busy = _uiState.value.busy,
|
||||
backendOnline = backendOnline,
|
||||
message = message,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildUiState(): ProcurementUiState {
|
||||
val session = persisted.session
|
||||
val claim = persisted.claim
|
||||
val execution = persisted.execution
|
||||
val phase = when {
|
||||
execution != null &&
|
||||
(execution.safetyStopped || execution.isExpired()) ->
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED
|
||||
execution != null -> ProcurementPhase.RUNNING
|
||||
claim?.task != null -> ProcurementPhase.CLAIMED
|
||||
session?.isValid() == true -> ProcurementPhase.IDLE
|
||||
else -> ProcurementPhase.SIGNED_OUT
|
||||
}
|
||||
val imagePath = claim?.referenceImage?.let {
|
||||
File(appContext.filesDir, it.relativePath)
|
||||
.takeIf(File::isFile)
|
||||
?.absolutePath
|
||||
}
|
||||
return ProcurementUiState(
|
||||
phase = phase,
|
||||
profile = ConnectionProfile(
|
||||
backendUrl = session?.backendUrl ?: "http://127.0.0.1:8080",
|
||||
username = session?.username.orEmpty(),
|
||||
deviceId = session?.deviceId.orEmpty(),
|
||||
hasDeviceToken = !persisted.deviceToken.isNullOrBlank()
|
||||
),
|
||||
task = claim?.task,
|
||||
referenceImagePath = imagePath,
|
||||
execution = execution,
|
||||
authenticationRequired = session?.isValid() != true
|
||||
)
|
||||
}
|
||||
|
||||
private fun DeviceReadinessSnapshot.toHeartbeatInput(
|
||||
activeTaskId: String?
|
||||
): DeviceHeartbeatInput =
|
||||
DeviceHeartbeatInput(
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
androidVersion = androidVersion,
|
||||
pddVersion = pinduoduo.versionName
|
||||
?: pinduoduo.versionCode?.toString()
|
||||
?: "",
|
||||
accessibilityEnabled = accessibilityEnabled,
|
||||
pddInstalled = pinduoduo.installed,
|
||||
activeTaskId = activeTaskId
|
||||
)
|
||||
|
||||
private fun userMessage(error: Exception): String =
|
||||
when (error) {
|
||||
is ProcurementApiException -> error.message ?: "后台请求失败"
|
||||
is IOException -> "无法连接管理后台"
|
||||
is IllegalArgumentException,
|
||||
is IllegalStateException -> error.message ?: "本地任务状态无效"
|
||||
else -> "任务操作失败"
|
||||
}
|
||||
|
||||
private fun newOpaqueSecret(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SECURE_RANDOM.nextBytes(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REFERENCE_DIRECTORY = "procurement"
|
||||
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 val SECURE_RANDOM = SecureRandom()
|
||||
|
||||
fun create(context: Context): ProcurementRepository =
|
||||
try {
|
||||
ProcurementRepository(
|
||||
context,
|
||||
ProcurementSecureStore(context),
|
||||
ProcurementApiClient()
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ProcurementRepository(
|
||||
context,
|
||||
UnavailableProcurementStateStore,
|
||||
ProcurementApiClient(),
|
||||
storageFailure = "设备安全存储不可用,后台采购功能已停用"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object UnavailableProcurementStateStore : ProcurementStateStore {
|
||||
override fun load() = PersistedProcurementState()
|
||||
|
||||
override fun save(state: PersistedProcurementState) {
|
||||
throw IllegalStateException("设备安全存储不可用")
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.json.JSONObject
|
||||
|
||||
interface ProcurementStateStore {
|
||||
fun load(): PersistedProcurementState
|
||||
fun save(state: PersistedProcurementState)
|
||||
}
|
||||
|
||||
class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||
private val preferences = EncryptedSharedPreferences.create(
|
||||
context.applicationContext,
|
||||
FILE_NAME,
|
||||
MasterKey.Builder(context.applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
override fun load(): PersistedProcurementState {
|
||||
val raw = preferences.getString(STATE_KEY, null) ?: return PersistedProcurementState()
|
||||
return runCatching { decodeState(JSONObject(raw)) }.getOrElse {
|
||||
throw IllegalStateException("加密任务状态损坏,已停止恢复", it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun save(state: PersistedProcurementState) {
|
||||
check(
|
||||
preferences.edit()
|
||||
.putString(STATE_KEY, encodeState(state).toString())
|
||||
.commit()
|
||||
) { "无法持久化加密任务状态" }
|
||||
}
|
||||
|
||||
private fun encodeState(state: PersistedProcurementState): JSONObject =
|
||||
JSONObject().apply {
|
||||
putNullable("device_token", state.deviceToken)
|
||||
state.session?.let { session ->
|
||||
put(
|
||||
"session",
|
||||
JSONObject().apply {
|
||||
put("backend_url", session.backendUrl)
|
||||
put("username", session.username)
|
||||
put("device_id", session.deviceId)
|
||||
put("access_token", session.accessToken)
|
||||
put("expires_at_ms", session.expiresAtEpochMillis)
|
||||
}
|
||||
)
|
||||
}
|
||||
state.claim?.let { claim ->
|
||||
put(
|
||||
"claim",
|
||||
JSONObject().apply {
|
||||
put("token", claim.token)
|
||||
put("idempotency_key", claim.idempotencyKey)
|
||||
putNullable("start_idempotency_key", claim.startIdempotencyKey)
|
||||
putNullable("release_idempotency_key", claim.releaseIdempotencyKey)
|
||||
claim.previewReadyAtEpochMillis?.let {
|
||||
put("preview_ready_at_ms", it)
|
||||
}
|
||||
claim.task?.let { put("task", encodeTask(it)) }
|
||||
claim.referenceImage?.let { image ->
|
||||
put(
|
||||
"reference_image",
|
||||
JSONObject().apply {
|
||||
put("relative_path", image.relativePath)
|
||||
put("size_bytes", image.sizeBytes)
|
||||
put("sha256", image.sha256)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
state.execution?.let { execution ->
|
||||
put(
|
||||
"execution",
|
||||
JSONObject().apply {
|
||||
put("id", execution.id)
|
||||
put("current_step", execution.currentStep)
|
||||
put("expires_at", execution.expiresAt)
|
||||
put("server_clock_offset_ms", execution.serverClockOffsetMillis)
|
||||
put("safety_stopped", execution.safetyStopped)
|
||||
putNullable(
|
||||
"cancel_acknowledgement_key",
|
||||
execution.cancelAcknowledgementKey
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeState(json: JSONObject): PersistedProcurementState =
|
||||
PersistedProcurementState(
|
||||
session = json.optionalObject("session")?.let {
|
||||
ProcurementSession(
|
||||
backendUrl = it.getString("backend_url"),
|
||||
username = it.getString("username"),
|
||||
deviceId = it.getString("device_id"),
|
||||
accessToken = it.getString("access_token"),
|
||||
expiresAtEpochMillis = it.getLong("expires_at_ms")
|
||||
)
|
||||
},
|
||||
deviceToken = json.optionalString("device_token"),
|
||||
claim = json.optionalObject("claim")?.let { claim ->
|
||||
ClaimContext(
|
||||
token = claim.getString("token"),
|
||||
idempotencyKey = claim.getString("idempotency_key"),
|
||||
task = claim.optionalObject("task")?.let(::decodeTask),
|
||||
referenceImage = claim.optionalObject("reference_image")?.let { image ->
|
||||
ReferenceImageRecord(
|
||||
relativePath = image.getString("relative_path"),
|
||||
sizeBytes = image.getLong("size_bytes"),
|
||||
sha256 = image.getString("sha256")
|
||||
)
|
||||
},
|
||||
previewReadyAtEpochMillis =
|
||||
if (claim.has("preview_ready_at_ms")) {
|
||||
claim.getLong("preview_ready_at_ms")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
startIdempotencyKey = claim.optionalString("start_idempotency_key"),
|
||||
releaseIdempotencyKey =
|
||||
claim.optionalString("release_idempotency_key")
|
||||
)
|
||||
},
|
||||
execution = json.optionalObject("execution")?.let {
|
||||
RunningExecution(
|
||||
id = it.getString("id"),
|
||||
currentStep = it.getString("current_step"),
|
||||
expiresAt = it.getString("expires_at"),
|
||||
serverClockOffsetMillis = it.getLong("server_clock_offset_ms"),
|
||||
safetyStopped = it.optBoolean("safety_stopped", false),
|
||||
cancelAcknowledgementKey =
|
||||
it.optionalString("cancel_acknowledgement_key")
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private fun encodeTask(task: RemotePurchaseTask): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("id", task.id)
|
||||
put("status", task.status)
|
||||
put("version", task.version)
|
||||
put("claim_generation", task.claimGeneration)
|
||||
putNullable("claim_expires_at", task.claimExpiresAt)
|
||||
put("title", task.title)
|
||||
put("description", task.description)
|
||||
put("sku", task.sku)
|
||||
put("reference_image_url", task.referenceImageUrl)
|
||||
put("quantity", task.quantity)
|
||||
putNullable("max_budget", task.maxBudget)
|
||||
put("currency", task.currency)
|
||||
}
|
||||
|
||||
private fun decodeTask(json: JSONObject): RemotePurchaseTask =
|
||||
RemotePurchaseTask(
|
||||
id = json.getString("id"),
|
||||
status = json.getString("status"),
|
||||
version = json.getLong("version"),
|
||||
claimGeneration = json.getLong("claim_generation"),
|
||||
claimExpiresAt = json.optionalString("claim_expires_at"),
|
||||
title = json.getString("title"),
|
||||
description = json.optString("description"),
|
||||
sku = json.getString("sku"),
|
||||
referenceImageUrl = json.getString("reference_image_url"),
|
||||
quantity = json.getInt("quantity"),
|
||||
maxBudget = json.optionalString("max_budget"),
|
||||
currency = json.optString("currency", "CNY")
|
||||
)
|
||||
|
||||
private fun JSONObject.putNullable(name: String, value: String?) {
|
||||
put(name, value ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
private fun JSONObject.optionalString(name: String): String? =
|
||||
if (has(name) && !isNull(name)) getString(name) else null
|
||||
|
||||
private fun JSONObject.optionalObject(name: String): JSONObject? =
|
||||
if (has(name) && !isNull(name)) getJSONObject(name) else null
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "procurement_secure_state"
|
||||
const val STATE_KEY = "state_v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package com.roubao.autopilot.ui.screens
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.ProcurementPhase
|
||||
import com.roubao.autopilot.procurement.ProcurementUiState
|
||||
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||
import com.roubao.autopilot.ui.theme.BaoziTheme
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ProcurementScreen(
|
||||
state: ProcurementUiState,
|
||||
readiness: DeviceReadinessSnapshot,
|
||||
onLogin: (LoginInput) -> Unit,
|
||||
onClaim: () -> Unit,
|
||||
onStart: () -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
onSync: () -> Unit
|
||||
) {
|
||||
val colors = BaoziTheme.colors
|
||||
var confirmStart by remember(state.task?.id) { mutableStateOf(false) }
|
||||
|
||||
if (confirmStart) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmStart = false },
|
||||
title = { Text("确认开始采购任务?") },
|
||||
text = { Text("开始后任务将由本设备持有,直到完成、取消或人工结束。") },
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmStart = false }) {
|
||||
Text("返回核对")
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
confirmStart = false
|
||||
onStart()
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("确认开始")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "采购任务",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colors.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = phaseLabel(state),
|
||||
fontSize = 14.sp,
|
||||
color = phaseColor(state)
|
||||
)
|
||||
}
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { error ->
|
||||
item {
|
||||
Text(error, color = colors.error, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
state.message?.let { message ->
|
||||
item {
|
||||
Text(message, color = colors.textSecondary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.phase) {
|
||||
ProcurementPhase.SIGNED_OUT -> item {
|
||||
LoginSection(
|
||||
state = state,
|
||||
onLogin = onLogin
|
||||
)
|
||||
}
|
||||
ProcurementPhase.IDLE -> item {
|
||||
IdleSection(
|
||||
state = state,
|
||||
readiness = readiness,
|
||||
onClaim = onClaim
|
||||
)
|
||||
}
|
||||
ProcurementPhase.CLAIMED -> {
|
||||
if (state.authenticationRequired) {
|
||||
item { LoginSection(state = state, onLogin = onLogin) }
|
||||
}
|
||||
item { TaskDetails(state) }
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onRelease,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("退回")
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmStart = true },
|
||||
enabled = !state.busy &&
|
||||
state.referenceImagePath != null,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("开始")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ProcurementPhase.RUNNING,
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
|
||||
if (state.authenticationRequired) {
|
||||
item { LoginSection(state = state, onLogin = onLogin) }
|
||||
}
|
||||
item { TaskDetails(state) }
|
||||
item {
|
||||
ExecutionDetails(state)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = onSync,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("立即同步")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginSection(
|
||||
state: ProcurementUiState,
|
||||
onLogin: (LoginInput) -> Unit
|
||||
) {
|
||||
val colors = BaoziTheme.colors
|
||||
var backendUrl by remember(state.profile.backendUrl) {
|
||||
mutableStateOf(state.profile.backendUrl)
|
||||
}
|
||||
var username by remember(state.profile.username) {
|
||||
mutableStateOf(state.profile.username)
|
||||
}
|
||||
var password by remember { mutableStateOf("") }
|
||||
var deviceId by remember(state.profile.deviceId) {
|
||||
mutableStateOf(state.profile.deviceId)
|
||||
}
|
||||
var deviceToken by remember { mutableStateOf("") }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"采购员登录",
|
||||
color = colors.textPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = backendUrl,
|
||||
onValueChange = { backendUrl = it },
|
||||
label = { Text("后台地址") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("采购员账号") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = deviceId,
|
||||
onValueChange = { deviceId = it },
|
||||
label = { Text("设备 ID") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = deviceToken,
|
||||
onValueChange = { deviceToken = it },
|
||||
label = {
|
||||
Text(
|
||||
if (state.profile.hasDeviceToken) {
|
||||
"设备密钥(已安全保存)"
|
||||
} else {
|
||||
"设备密钥"
|
||||
}
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
onLogin(
|
||||
LoginInput(
|
||||
backendUrl = backendUrl,
|
||||
username = username,
|
||||
password = password,
|
||||
deviceId = deviceId,
|
||||
deviceToken = deviceToken
|
||||
)
|
||||
)
|
||||
password = ""
|
||||
deviceToken = ""
|
||||
},
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("登录")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdleSection(
|
||||
state: ProcurementUiState,
|
||||
readiness: DeviceReadinessSnapshot,
|
||||
onClaim: () -> Unit
|
||||
) {
|
||||
val colors = BaoziTheme.colors
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
DetailRow("采购员", state.profile.username)
|
||||
DetailRow("设备", state.profile.deviceId)
|
||||
DetailRow(
|
||||
"设备状态",
|
||||
if (readiness.canStartProbe) "已就绪" else "存在阻塞项"
|
||||
)
|
||||
Divider(color = colors.surfaceVariant)
|
||||
Button(
|
||||
onClick = onClaim,
|
||||
enabled = !state.busy && readiness.canStartProbe,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Filled.ShoppingCart, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("获取任务")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskDetails(state: ProcurementUiState) {
|
||||
val colors = BaoziTheme.colors
|
||||
val task = state.task ?: return
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
task.title,
|
||||
color = colors.textPrimary,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
state.referenceImagePath?.let { path ->
|
||||
val bitmap = remember(path) {
|
||||
BitmapFactory.decodeFile(path)?.asImageBitmap()
|
||||
}
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = "任务参考图",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
DetailRow("SKU", task.sku)
|
||||
DetailRow("数量", task.quantity.toString())
|
||||
task.maxBudget?.let {
|
||||
DetailRow("最高预算", "$it ${task.currency}")
|
||||
}
|
||||
if (task.description.isNotBlank()) {
|
||||
DetailRow("描述", task.description)
|
||||
}
|
||||
DetailRow("任务 ID", task.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExecutionDetails(state: ProcurementUiState) {
|
||||
val colors = BaoziTheme.colors
|
||||
val execution = state.execution ?: return
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(execution.expiresAt) {
|
||||
while (true) {
|
||||
now = System.currentTimeMillis()
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
val remainingSeconds = runCatching {
|
||||
val deadline = Instant.parse(execution.expiresAt).toEpochMilli() -
|
||||
execution.serverClockOffsetMillis
|
||||
((deadline - now).coerceAtLeast(0L) / 1_000L)
|
||||
}.getOrDefault(0)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Divider(color = colors.surfaceVariant)
|
||||
DetailRow("当前步骤", execution.currentStep)
|
||||
DetailRow(
|
||||
"后台连接",
|
||||
when (state.backendOnline) {
|
||||
true -> "在线"
|
||||
false -> "离线"
|
||||
null -> "检查中"
|
||||
}
|
||||
)
|
||||
DetailRow(
|
||||
"剩余授权",
|
||||
if (state.phase == ProcurementPhase.AUTHORIZATION_EXPIRED) {
|
||||
"已到期,自动化已停止"
|
||||
} else {
|
||||
"%02d:%02d".format(
|
||||
remainingSeconds / 60,
|
||||
remainingSeconds % 60
|
||||
)
|
||||
}
|
||||
)
|
||||
Text(
|
||||
"订单提交保持禁用",
|
||||
color = colors.warning,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(label: String, value: String) {
|
||||
val colors = BaoziTheme.colors
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = colors.textSecondary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(0.28f)
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
color = colors.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(0.72f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun phaseColor(state: ProcurementUiState) =
|
||||
when (state.phase) {
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> BaoziTheme.colors.error
|
||||
ProcurementPhase.RUNNING -> BaoziTheme.colors.success
|
||||
else -> BaoziTheme.colors.textSecondary
|
||||
}
|
||||
|
||||
private fun phaseLabel(state: ProcurementUiState): String =
|
||||
when (state.phase) {
|
||||
ProcurementPhase.SIGNED_OUT -> "未登录管理后台"
|
||||
ProcurementPhase.IDLE -> "等待领取"
|
||||
ProcurementPhase.CLAIMED -> "已领取,等待开始"
|
||||
ProcurementPhase.RUNNING -> "受控流程运行中"
|
||||
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BackendEndpointPolicyTest {
|
||||
@Test
|
||||
fun releaseAcceptsOnlyHttps() {
|
||||
assertEquals(
|
||||
"https://admin.example.com:8443",
|
||||
BackendEndpointPolicy.normalize(
|
||||
"https://admin.example.com:8443/",
|
||||
debugBuild = false
|
||||
).getOrThrow()
|
||||
)
|
||||
assertFalse(
|
||||
BackendEndpointPolicy.normalize(
|
||||
"http://admin.example.com:8080",
|
||||
debugBuild = false
|
||||
).isSuccess
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun debugHttpIsLimitedToLoopback() {
|
||||
assertTrue(
|
||||
BackendEndpointPolicy.normalize(
|
||||
"http://127.0.0.1:8080",
|
||||
debugBuild = true
|
||||
).isSuccess
|
||||
)
|
||||
assertFalse(
|
||||
BackendEndpointPolicy.normalize(
|
||||
"http://192.168.1.20:8080",
|
||||
debugBuild = true
|
||||
).isSuccess
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsEmbeddedCredentialsAndPaths() {
|
||||
assertFalse(
|
||||
BackendEndpointPolicy.normalize(
|
||||
"https://user:pass@example.com/api",
|
||||
debugBuild = false
|
||||
).isSuccess
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExecutionAuthorizationTest {
|
||||
@Test
|
||||
fun usesServerClockOffsetForOfflineDeadline() {
|
||||
val expiry = "2026-07-27T01:30:00Z"
|
||||
val localAtServerOneOClock = 1_000L
|
||||
val offset = ExecutionAuthorization.serverClockOffset(
|
||||
"2026-07-27T01:00:00Z",
|
||||
localAtServerOneOClock
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
ExecutionAuthorization.isExpired(
|
||||
expiry,
|
||||
offset,
|
||||
localAtServerOneOClock + 29 * 60_000L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
ExecutionAuthorization.isExpired(
|
||||
expiry,
|
||||
offset,
|
||||
localAtServerOneOClock + 30 * 60_000L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedExpiryFailsClosed() {
|
||||
assertTrue(
|
||||
ExecutionAuthorization.isExpired(
|
||||
"not-a-time",
|
||||
serverClockOffsetMillis = 0,
|
||||
nowEpochMillis = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startRequiresAStablePreviewWindow() {
|
||||
assertFalse(
|
||||
StartAuthorization.isPreviewConfirmed(
|
||||
previewReadyAtEpochMillis = 10_000L,
|
||||
nowEpochMillis = 11_999L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
StartAuthorization.isPreviewConfirmed(
|
||||
previewReadyAtEpochMillis = 10_000L,
|
||||
nowEpochMillis = 12_000L
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package com.roubao.autopilot.procurement
|
||||
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ProcurementApiClientTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var api: ProcurementApiClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
api = ProcurementApiClient()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginClaimStartAndReferenceImageFollowDeviceContract() = runBlocking {
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"access_token":"access-token-value",
|
||||
"expires_in":3600,
|
||||
"device":{"id":"device-id","enabled":true}
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
val baseUrl = server.url("/").toString().trimEnd('/')
|
||||
val session = api.login(
|
||||
baseUrl,
|
||||
LoginInput(
|
||||
backendUrl = baseUrl,
|
||||
username = "buyer01",
|
||||
password = "private-password",
|
||||
deviceId = "device-id",
|
||||
deviceToken = "private-device-token"
|
||||
),
|
||||
appVersion = "1.4.2",
|
||||
androidVersion = "16"
|
||||
)
|
||||
val loginRequest = server.takeRequest()
|
||||
assertEquals("/api/v1/auth/token", loginRequest.path)
|
||||
assertTrue(loginRequest.body.readUtf8().contains("\"username\":\"buyer01\""))
|
||||
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"task":${taskJson("CLAIMED", 2)},
|
||||
"replayed":false,
|
||||
"server_time":"2026-07-27T01:00:00Z"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
val claim = api.claimNext(
|
||||
session,
|
||||
"device-id",
|
||||
"claim-token-value",
|
||||
"claim-idempotency-key"
|
||||
)
|
||||
val claimRequest = server.takeRequest()
|
||||
assertEquals("/api/v1/tasks/claim-next", claimRequest.path)
|
||||
assertEquals("claim-token-value", claimRequest.getHeader("X-Claim-Token"))
|
||||
assertEquals(
|
||||
"claim-idempotency-key",
|
||||
claimRequest.getHeader("Idempotency-Key")
|
||||
)
|
||||
val task = assertNotNull(claim.task).let { claim.task!! }
|
||||
assertEquals("测试商品", task.title)
|
||||
assertEquals(2, task.quantity)
|
||||
|
||||
val jpeg = byteArrayOf(
|
||||
0xFF.toByte(),
|
||||
0xD8.toByte(),
|
||||
0xFF.toByte(),
|
||||
0xD9.toByte()
|
||||
)
|
||||
val hash = MessageDigest.getInstance("SHA-256")
|
||||
.digest(jpeg)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "image/jpeg")
|
||||
.setHeader("ETag", "\"$hash\"")
|
||||
.setBody(Buffer().write(jpeg))
|
||||
)
|
||||
val image = api.downloadReferenceImage(
|
||||
session,
|
||||
task,
|
||||
"claim-token-value"
|
||||
)
|
||||
val imageRequest = server.takeRequest()
|
||||
assertTrue(imageRequest.path!!.startsWith("/api/v1/tasks/task-id/reference-image"))
|
||||
assertEquals(hash, image.sha256)
|
||||
|
||||
server.enqueue(
|
||||
jsonResponse(
|
||||
"""
|
||||
{
|
||||
"task":${taskJson("RUNNING", 3)},
|
||||
"execution":{
|
||||
"id":"execution-id",
|
||||
"current_step":"PREFLIGHT",
|
||||
"order_submitted":false,
|
||||
"execution_expires_at":"2026-07-27T01:30:00Z"
|
||||
},
|
||||
"replayed":false,
|
||||
"server_time":"2026-07-27T01:00:00Z"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
val started = api.start(
|
||||
session,
|
||||
task,
|
||||
"claim-token-value",
|
||||
"start-idempotency-key"
|
||||
)
|
||||
assertEquals("execution-id", started.executionId)
|
||||
assertEquals("2026-07-27T01:30:00Z", started.executionExpiresAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noTaskIsAStableEmptyResult() = runBlocking {
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
val result = api.claimNext(
|
||||
session(),
|
||||
"device-id",
|
||||
"claim-token-value",
|
||||
"claim-idempotency-key"
|
||||
)
|
||||
|
||||
assertEquals(null, result.task)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun referenceImageMustStayOnTheBackendOrigin() = runBlocking {
|
||||
val failure = runCatching {
|
||||
api.downloadReferenceImage(
|
||||
session(),
|
||||
task(
|
||||
referenceImageUrl =
|
||||
"https://credentials.example.invalid/reference.jpg"
|
||||
),
|
||||
"claim-token-value"
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(failure is ProcurementApiException)
|
||||
assertEquals(
|
||||
"REFERENCE_IMAGE_ORIGIN_INVALID",
|
||||
(failure as ProcurementApiException).code
|
||||
)
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
private fun session() = ProcurementSession(
|
||||
backendUrl = server.url("/").toString().trimEnd('/'),
|
||||
username = "buyer01",
|
||||
deviceId = "device-id",
|
||||
accessToken = "access-token-value",
|
||||
expiresAtEpochMillis = Long.MAX_VALUE
|
||||
)
|
||||
|
||||
private fun taskJson(status: String, version: Int): String =
|
||||
"""
|
||||
{
|
||||
"id":"task-id",
|
||||
"status":"$status",
|
||||
"version":$version,
|
||||
"claim_generation":1,
|
||||
"claim_expires_at":"2026-07-27T01:30:00Z",
|
||||
"title":"测试商品",
|
||||
"description":"测试描述",
|
||||
"sku":"SKU-01",
|
||||
"reference_image_url":"/api/v1/tasks/task-id/reference-image?claim_generation=1",
|
||||
"quantity":2,
|
||||
"max_budget":"20.00",
|
||||
"currency":"CNY"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun task(referenceImageUrl: String) = RemotePurchaseTask(
|
||||
id = "task-id",
|
||||
status = "CLAIMED",
|
||||
version = 2,
|
||||
claimGeneration = 1,
|
||||
claimExpiresAt = "2026-07-27T01:30:00Z",
|
||||
title = "测试商品",
|
||||
description = "测试描述",
|
||||
sku = "SKU-01",
|
||||
referenceImageUrl = referenceImageUrl,
|
||||
quantity = 2,
|
||||
maxBudget = "20.00",
|
||||
currency = "CNY"
|
||||
)
|
||||
|
||||
private fun jsonResponse(body: String) =
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
}
|
||||
Reference in New Issue
Block a user