feat(t206): connect Android procurement tasks

This commit is contained in:
QiuSW
2026-07-27 11:11:16 +08:00
parent 9e698c1b6c
commit 45b66eeea7
35 changed files with 2634 additions and 81 deletions
+10 -1
View File
@@ -66,6 +66,10 @@ go run ./cmd/migrate up
$env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节"
go run ./cmd/authctl create-user ADMIN admin
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
$env:CMROUBAO_AUTH_PASSWORD = "采购员独立强密码,至少 12 个 UTF-8 字节"
go run ./cmd/authctl create-user BUYER buyer01
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
go run ./cmd/authctl create-device buyer-phone-01
go run ./cmd/api
```
@@ -73,6 +77,11 @@ go run ./cmd/api
访问 `http://127.0.0.1:8080/healthz`。可配置项和独立验证命令见
[`backend-api/README.md`](backend-api/README.md)。
Debug App 通过 USB 连接本机后先执行
`adb reverse tcp:8080 tcp:8080`,再在“任务”页使用
`http://127.0.0.1:8080`、BUYER 账号、设备 ID 和只显示一次的设备密钥登录。
正式版后台地址只接受 HTTPS。App 每次由采购员点击“获取任务”,不会自动领单。
## 文档入口
- [AI 开发入口](docs/00-ai-start-here.md)
@@ -85,5 +94,5 @@ go run ./cmd/api
- [当前实现状态](docs/current-state.md)
- [完整文档导航](docs/README.md)
Android Phase 0/1 和后端 T-201 至 T-205 已完成。真实状态以
Android Phase 0/1、后端 T-201 至 T-205 和 App 接入 T-206 已完成。真实状态以
[`docs/current-state.md`](docs/current-state.md) 为准。
+1
View File
@@ -110,6 +110,7 @@ dependencies {
// Unit tests
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
@@ -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() },
@@ -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()
}
@@ -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()
}
}
@@ -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)
)
}
}
}
@@ -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
)
)
@@ -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("设备安全存储不可用")
}
}
@@ -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 -> "授权到期,已停止"
}
@@ -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
)
}
}
@@ -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
)
)
}
}
@@ -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)
}
+9 -1
View File
@@ -22,7 +22,7 @@ start/运行续租/release 和取消安全确认。
| `CMROUBAO_TLS_CERT_FILE` | 无 | TLS certificate;必须和 private key 同时设置 |
| `CMROUBAO_TLS_KEY_FILE` | 无 | TLS private key;非 loopback 监听必须设置 |
| `CMROUBAO_CLAIM_LEASE` | `10m` | CLAIMED 租约;允许 `1m` 至 `30m` |
| `CMROUBAO_RUNNING_LEASE` | `90s` | RUNNING/等待确认租约;允许 `30s` 至 `10m` |
| `CMROUBAO_RUNNING_LEASE` | `30m` | RUNNING/等待确认的离线执行授权;允许 `5m` 至 `120m` |
| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` |
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
@@ -43,6 +43,9 @@ $env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节"
go run ./cmd/authctl create-user ADMIN admin
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
$env:CMROUBAO_AUTH_PASSWORD = "采购员独立强密码,至少 12 个 UTF-8 字节"
go run ./cmd/authctl create-user BUYER buyer01
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
go run ./cmd/authctl create-device buyer-phone-01
# 发生人员离岗或设备风险时,现有凭证会随禁用立即失效
go run ./cmd/authctl disable-user buyer01
@@ -78,6 +81,11 @@ Cookie 与 BUYER Bearer token 不能互换。两类登录在凭证校验前按
和迁移都受当前用户、设备、claim generation、`X-Claim-Token` 与服务端租约约束;
原 claim token 只由 App 生成和保存,服务端数据库只存 SHA-256。
start 和任务 heartbeat 显式返回 `execution_expires_at`。默认运行授权为 30 分钟,
App 可每 30 秒 best-effort 滑动续期;过期后原设备 heartbeat 只同步状态且不延长
旧授权,并只接受 `SAFE_STOPPED` step;`RUNNING/WAITING_CONFIRMATION` 不自动
回队列。匹配原 execution/claim 的设备仍可确认已存在的管理取消请求。
默认 loopback 可使用 HTTP 开发。局域网监听必须同时设置 certificate/private key,
服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见
[`../docs/api.md`](../docs/api.md)。
+1 -1
View File
@@ -138,7 +138,7 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
router, err := buildRouter(ctx, config.Config{
AssetDirectory: filepath.Join(t.TempDir(), "assets"),
ClaimLease: 10 * time.Minute,
RunningLease: 90 * time.Second,
RunningLease: 30 * time.Minute,
ReadinessTTL: 2 * time.Minute,
}, db)
if err != nil {
+3 -3
View File
@@ -23,7 +23,7 @@ const (
defaultDatabasePath = "var/cmroubao.db"
defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute
defaultRunningLease = 90 * time.Second
defaultRunningLease = 30 * time.Minute
defaultReadinessTTL = 2 * time.Minute
)
@@ -121,8 +121,8 @@ func Load(lookup LookupEnvironment) (Config, error) {
lookup,
RunningLeaseEnvironment,
defaultRunningLease,
30*time.Second,
10*time.Minute,
5*time.Minute,
120*time.Minute,
)
if err != nil {
return Config{}, err
+10 -4
View File
@@ -30,7 +30,7 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
t.Fatal("server safety limits must all be positive")
}
if cfg.ClaimLease != 10*time.Minute ||
cfg.RunningLease != 90*time.Second ||
cfg.RunningLease != 30*time.Minute ||
cfg.ReadinessTTL != 2*time.Minute {
t.Fatalf(
"lifecycle durations = %s / %s / %s",
@@ -52,7 +52,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
TLSCertificateEnvironment: "tmp/server.crt",
TLSPrivateKeyEnvironment: "tmp/server.key",
ClaimLeaseEnvironment: "15m",
RunningLeaseEnvironment: "2m",
RunningLeaseEnvironment: "45m",
ReadinessTTLEnvironment: "3m",
}
@@ -79,7 +79,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
)
}
if cfg.ClaimLease != 15*time.Minute ||
cfg.RunningLease != 2*time.Minute ||
cfg.RunningLease != 45*time.Minute ||
cfg.ReadinessTTL != 3*time.Minute {
t.Fatalf(
"lifecycle durations = %s / %s / %s",
@@ -174,10 +174,16 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
ClaimLeaseEnvironment: "59s",
},
},
{
name: "running lease below minimum",
values: map[string]string{
RunningLeaseEnvironment: "4m59s",
},
},
{
name: "running lease above maximum",
values: map[string]string{
RunningLeaseEnvironment: "11m",
RunningLeaseEnvironment: "121m",
},
},
{
@@ -498,13 +498,12 @@ func (s *Store) HeartbeatTask(
if err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, err
}
if err := validateClaim(
if err := validateClaimOwner(
task,
request.UserID,
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
request.Now,
); err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, err
}
@@ -512,6 +511,11 @@ func (s *Store) HeartbeatTask(
return usecase.TaskHeartbeatRepositoryResult{},
usecase.ErrTaskStateConflict
}
expired := !task.ClaimExpiresAt.After(request.Now)
if expired && request.Step != "SAFE_STOPPED" {
return usecase.TaskHeartbeatRepositoryResult{},
usecase.ErrClaimExpired
}
execution, err := getExecutionByID(ctx, tx, request.ExecutionID)
if err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, err
@@ -525,7 +529,8 @@ func (s *Store) HeartbeatTask(
usecase.ErrExecutionMismatch
}
expiresAt := *task.ClaimExpiresAt
if request.MinimumExpiry.After(expiresAt) {
if !expired &&
request.MinimumExpiry.After(expiresAt) {
expiresAt = request.MinimumExpiry
}
result, err := tx.ExecContext(
@@ -561,8 +566,7 @@ func (s *Store) HeartbeatTask(
AND claimed_by_user_id = ?
AND claimed_by_device_id = ?
AND claim_generation = ?
AND claim_token_hash = ?
AND claim_expires_at > ?`,
AND claim_token_hash = ?`,
formatTimestamp(expiresAt),
formatTimestamp(request.Now),
request.TaskID,
@@ -570,7 +574,6 @@ func (s *Store) HeartbeatTask(
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
formatTimestamp(request.Now),
)
if err != nil {
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
@@ -581,7 +584,7 @@ func (s *Store) HeartbeatTask(
}
if affected != 1 {
return usecase.TaskHeartbeatRepositoryResult{},
usecase.ErrClaimExpired
usecase.ErrTaskVersionConflict
}
_, err = tx.ExecContext(
ctx,
@@ -797,13 +800,12 @@ func (s *Store) AcknowledgeTaskCancellation(
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateClaim(
if err := validateClaimOwner(
task,
request.UserID,
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
request.Now,
); err != nil {
return domain.PurchaseTask{}, false, err
}
@@ -846,8 +848,7 @@ func (s *Store) AcknowledgeTaskCancellation(
AND claimed_by_user_id = ?
AND claimed_by_device_id = ?
AND claim_generation = ?
AND claim_token_hash = ?
AND claim_expires_at > ?`,
AND claim_token_hash = ?`,
formatTimestamp(request.Now),
formatTimestamp(request.Now),
request.TaskID,
@@ -856,7 +857,6 @@ func (s *Store) AcknowledgeTaskCancellation(
request.DeviceID,
request.ClaimGeneration,
request.ClaimTokenHash,
formatTimestamp(request.Now),
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
@@ -1041,6 +1041,28 @@ func validateClaim(
generation int64,
tokenHash string,
now time.Time,
) error {
if err := validateClaimOwner(
task,
userID,
deviceID,
generation,
tokenHash,
); err != nil {
return err
}
if task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(now) {
return usecase.ErrClaimExpired
}
return nil
}
func validateClaimOwner(
task domain.PurchaseTask,
userID string,
deviceID string,
generation int64,
tokenHash string,
) error {
if task.ClaimedByUserID == nil ||
*task.ClaimedByUserID != userID ||
@@ -1051,7 +1073,7 @@ func validateClaim(
*task.ClaimTokenHash != tokenHash {
return usecase.ErrClaimInvalid
}
if task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(now) {
if task.ClaimExpiresAt == nil {
return usecase.ErrClaimExpired
}
return nil
@@ -748,6 +748,29 @@ func TestLifecycleRepositoryHeartbeatExtendsLeaseWithoutEvent(
},
)
assertLifecycleError(t, err, usecase.ErrClaimExpired)
expiredHeartbeat, err := fixture.store.HeartbeatTask(
ctx,
usecase.TaskHeartbeatRepositoryRequest{
UserID: fixture.buyerOneID,
DeviceID: fixture.deviceOneID,
TaskID: claimed.ID,
ExecutionID: started.Execution.ID,
ClaimGeneration: claimed.ClaimGeneration,
ClaimTokenHash: tokenHash,
Step: "SAFE_STOPPED",
Now: expiredAt,
MinimumExpiry: expiredAt.Add(90 * time.Second),
},
)
if err != nil {
t.Fatalf("expired HeartbeatTask() error = %v", err)
}
if expiredHeartbeat.Task.ClaimExpiresAt == nil ||
!expiredHeartbeat.Task.ClaimExpiresAt.Equal(expiredAt) ||
expiredHeartbeat.Execution.CurrentStep != "SAFE_STOPPED" {
t.Fatalf("expired heartbeat = %+v", expiredHeartbeat)
}
}
func TestLifecycleRepositoryReleaseIsIdempotentAndInvalidatesClaim(
@@ -870,6 +893,7 @@ func TestLifecycleRepositoryCancelAcknowledgementEndsExecution(
if err != nil {
t.Fatalf("StartTask() error = %v", err)
}
expiredAt := *started.Task.ClaimExpiresAt
cancelAt := startRequest.Now.Add(10 * time.Second)
adminID := fixture.adminUserID
cancelRequested, err := fixture.store.CancelTask(
@@ -940,16 +964,16 @@ func TestLifecycleRepositoryCancelAcknowledgementEndsExecution(
ExecutionID: started.Execution.ID,
ClaimGeneration: claimed.ClaimGeneration,
ClaimTokenHash: tokenHash,
Step: "STOPPING",
Now: cancelAt.Add(time.Second),
MinimumExpiry: cancelAt.Add(91 * time.Second),
Step: "SAFE_STOPPED",
Now: expiredAt,
MinimumExpiry: expiredAt.Add(90 * time.Second),
},
)
if err != nil || !heartbeat.CancelRequested {
t.Fatalf("cancel heartbeat = %+v, error = %v", heartbeat, err)
}
ackAt := cancelAt.Add(2 * time.Second)
ackAt := expiredAt.Add(time.Second)
request := usecase.CancelAcknowledgementRepositoryRequest{
UserID: fixture.buyerOneID,
DeviceID: fixture.deviceOneID,
@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
@@ -235,8 +236,11 @@ func (handler *deviceHandlers) startTask(ctx *gin.Context) {
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"execution": executionResponse(result.Execution),
"task": deviceTaskResponse(result.Task),
"execution": deviceExecutionResponse(
result.Execution,
result.Task.ClaimExpiresAt,
),
"replayed": result.Replayed,
"server_time": formatTime(result.ServerTime),
})
@@ -275,8 +279,11 @@ func (handler *deviceHandlers) heartbeatTask(ctx *gin.Context) {
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result.Task),
"execution": executionResponse(result.Execution),
"task": deviceTaskResponse(result.Task),
"execution": deviceExecutionResponse(
result.Execution,
result.Task.ClaimExpiresAt,
),
"cancel_requested": result.CancelRequested,
"server_time": formatTime(result.ServerTime),
})
@@ -453,3 +460,12 @@ func deviceTaskResponse(task domain.PurchaseTask) gin.H {
"currency": task.Currency,
}
}
func deviceExecutionResponse(
execution domain.TaskExecution,
expiresAt *time.Time,
) gin.H {
response := executionResponse(execution)
response["execution_expires_at"] = formatOptionalTime(expiresAt)
return response
}
@@ -432,9 +432,14 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
if started.Task.Status != string(domain.TaskStatusRunning) ||
started.Execution.ID == "" ||
started.Execution.CurrentStep != "PREFLIGHT" ||
started.Execution.ExpiresAt.IsZero() ||
started.Execution.OrderSubmitted {
t.Fatalf("start response = %+v", started)
}
if remaining := time.Until(started.Execution.ExpiresAt); remaining < 9*time.Minute ||
remaining > 11*time.Minute {
t.Fatalf("start execution expiry remaining = %s", remaining)
}
assertNoClaimSecret(t, startedResponse, testOpaqueToken)
heartbeatBody := fmt.Sprintf(
@@ -464,6 +469,8 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
if heartbeat.Task.Status != string(domain.TaskStatusRunning) ||
heartbeat.Execution.ID != started.Execution.ID ||
heartbeat.Execution.CurrentStep != "SEARCH_RESULTS" ||
heartbeat.Execution.ExpiresAt.IsZero() ||
heartbeat.Execution.ExpiresAt.Before(started.Execution.ExpiresAt) ||
heartbeat.Execution.OrderSubmitted ||
heartbeat.CancelRequested {
t.Fatalf("task heartbeat response = %+v", heartbeat)
@@ -918,9 +925,10 @@ type deviceLifecycleResponse struct {
ReferenceImageURL string `json:"reference_image_url"`
} `json:"task"`
Execution struct {
ID string `json:"id"`
CurrentStep string `json:"current_step"`
OrderSubmitted bool `json:"order_submitted"`
ID string `json:"id"`
CurrentStep string `json:"current_step"`
OrderSubmitted bool `json:"order_submitted"`
ExpiresAt time.Time `json:"execution_expires_at"`
} `json:"execution"`
Replayed bool `json:"replayed"`
CancelRequested bool `json:"cancel_requested"`
+3 -2
View File
@@ -54,8 +54,9 @@
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
和 T-205 原子领取/租约状态机均已完成。下一步按编号开始 T-206,把 Android
`HttpTaskSource`、前台服务和已确认的任务页面接到设备 API。
T-205 原子领取/租约状态机和 T-206 Android 登录、手动领取、参考图预览、前台服务、
有限离线与恢复均已完成。下一步按编号开始 T-207,把现有端上 VLM/拼多多候选流程
接入已领取任务并实现事件、证据和终态结果回传。
候选优化数据集已经登记为 T-208;不得跳过 T-206/T-207 的第一版端到端闭环,提前
建设报表、训练管线或外部商品抓取。
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
+2 -1
View File
@@ -167,7 +167,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
或结构化错误;无权限用户不可访问。
- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
不得领取第二条或把同一任务分配两次。T-205 已完成后端 heartbeat、原子 claim、
client-generated token 安全重放、参考图授权和租约;Android 接入属于 T-206。
client-generated token 安全重放、参考图授权和租约;T-206 已完成 Android 手动
领取、安全存储、预览、start/release、前台 heartbeat 和恢复。
- F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告;
原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、
冲突转人工以及 SKU/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。
+4 -4
View File
@@ -14,7 +14,7 @@
| Android 自动化 | `AccessibilityService` 语义节点动作;Shizuku 保留为上游兼容路径 | 搜索与 5 个候选已真机验证 | T-101/T-102 已完成精确输入、结果页确认、候选卡识别、详情截图和验证返回;没有坐标或 shell 降级。上游 `main` 仍保留 Shizuku 13.1.5。 |
| Android 候选证据 | API 30+ `AccessibilityService.takeScreenshot` + App cache JSON/PNG | 已真机验证 | 匿名 PNG 与只含 SHA-256、计数、尺寸的 manifest;转换/压缩使用独立 executor,文件 IO 使用 `Dispatchers.IO`。API 26-29 明确不支持该截图探针。 |
| 第一层任务源 | UTF-8 无 BOM 四行蝦皮订单文本 + 同订单号 JPEG | 已实现 | `task-contract` 共享 `ProbeTask/TaskSource`;CLI 输出到 `.local/`,只有显式 Debug 属性才注入 APK,默认构建会清除私有资产。 |
| Android 长任务 | 前台服务 + 持续通知 | 计划采用 | 降低执行中被系统挂起的风险,仍需处理进程死亡恢复。 |
| Android 长任务 | 前台服务 + 持续通知 | T-206 已实现 | 30 秒 heartbeat、加密状态恢复、离线截止和 `SAFE_STOPPED`;真实候选工作流接入属于 T-207。 |
| 后端语言 | Go 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
| 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | 已接入并收敛 | 只作为一次性脚手架输入;演示路由、默认 CORS、`.env` 自动加载、单例和 fatal 行为均已删除。 |
| 后端框架 | Gin v1.11.0 | MVP 已定 | 这是 `go.mod` 明确支持 Go 1.23.0 的最高已核实 Gin 版本。 |
@@ -26,10 +26,10 @@
| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
| App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 |
| VLM 接入 | Android 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | 手机直连 provider;后端不代理模型。T-207 把 Key 迁移到 Keystore-backed 加密存储,并回传非秘密 provenance。 |
| 离线执行 | 服务端运行授权 + Android 加密本地状态/outbox | T-206 待实现 | 默认 30 分钟、5-120 分钟可配;30 秒 best-effort heartbeat,过期安全停止且 RUNNING 不自动重分配。 |
| 离线执行 | 服务端运行授权 + Android 加密本地状态 | T-206 已实现 | 默认 30 分钟、5-120 分钟可配;30 秒 best-effort heartbeat,过期持久安全停止且 RUNNING 不自动重分配;完整 outbox 属于 T-207。 |
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
| 后端测试 | 标准库 `testing` + `httptest` | T-205 已验证 | 当前 192 个测试覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、租约状态机、取消确认、跨连接与真实 TCP 并发。 |
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 |
| 后端测试 | 标准库 `testing` + `httptest` | T-206 已验证 | 当前含子测试 193 次覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、有限离线租约、取消确认、跨连接与真实 TCP 并发。 |
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + MockWebServer 4.12.0 + 真实设备 smoke | T-206 已验证 | 184 次测试覆盖 runner、页面分类、VLM schema、人工确认、后台端点/JSON/图片同源契约和离线时钟;PKG110 完成私有 fixture/VLM mock 及后台登录、领取、95 秒断线、恢复、取消真机 smoke。 |
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
## Roubao 上游版本基线
+13 -5
View File
@@ -314,20 +314,28 @@ CLAIMED/RUNNING/WAITING_CONFIRMATION
规则:
- `SUCCEEDED` 是验证结果成功,不代表已下单;`order_submitted=false`。
- `CLAIMED` 默认租约为 10 分钟;T-206 将 `RUNNING/WAITING_CONFIRMATION` 默认
授权从当前 90 秒扩展为 30 分钟,允许范围 5 至 120 分钟。App 每 30 秒
best-effort heartbeat,成功后按服务端时间滑动续期。
- `CLAIMED` 默认租约为 10 分钟;`RUNNING/WAITING_CONFIRMATION` 默认授权为
30 分钟,允许范围 5 至 120 分钟。App 每 30 秒 best-effort heartbeat,成功后
按服务端时间滑动续期。
- 只有当前设备和有效 claim token 能启动、续租、上报或结束任务。
- 终态不可回退;重试创建新的 execution attempt,不篡改历史证据。
- App 在 claim 前生成并安全保存 256 bit Raw URL token;后端只存 SHA-256,
token 与 `Idempotency-Key` 相互独立。
- 过期 `CLAIMED` 可以原子回收;过期 `RUNNING/WAITING_CONFIRMATION` 保持原状态,
不自动回队列。App 到达服务端授权截止时间必须安全停止,但原设备可以在重连后
补报已产生的终态结果并留下过期补报审计标志。
不自动回队列。App 到达服务端授权截止时间持久化 `SAFE_STOPPED` 并停止前台
执行;原设备重连 heartbeat 只同步状态且不延长已过期授权。T-207 才允许原设备
补报授权内已产生的终态结果并留下过期补报审计标志。
- 管理取消 `PENDING/CLAIMED` 立即终态;执行中只设置停止请求,App 在下一个安全
检查点停止并调用 `cancel-ack` 后才进入 `CANCELED`。离线期间不能保证即时取消,
App 恢复连接后必须先同步取消状态再继续页面动作。
T-206 Android 使用独立 `ProcurementSecureStore` 保存 backend URL、BUYER access
token、设备 secret、claim token、各操作幂等 key、任务快照和 execution。存储创建
失败时整个后台采购入口 fail closed,不回退普通 `SharedPreferences`。登录密码只
存在于当前 Compose 输入和一次请求中;后台任务不能读取或修改 Roubao provider。
`HttpTaskSource` 只把已领取任务和经过 MIME/JPEG/大小/SHA-256 校验的内部参考图
映射为现有 `ProbeTask`,不参与领取策略。
### 4.2 Android 工作流状态
```text
+4 -2
View File
@@ -192,8 +192,10 @@
**范围**
- 包含:App 本地 provider 配置、Keystore 加密、手动/AI 模式、有限离线授权、加密
outbox、结果 provenance 和恢复同步。
- T-206 已包含:后台任务安全登录/领取、有限离线授权、前台 heartbeat、状态恢复和
安全停止。
- T-207 待包含:App 本地 provider 接入后台任务、Key 迁移、手动/AI 模式、加密
outbox、结果 provenance 和恢复补报。
- 不包含:无限离线执行、离线即时取消、后端模型代理、多设备统一模型或自动下单。
**验收场景**
+3 -3
View File
@@ -10,13 +10,13 @@
| IX-001 | US-007 | 管理 Web 登录 | 提交账号密码 | 建立管理会话或显示通用错误 | P0 | 已定 |
| IX-002 | US-001 | 新建任务表单 | 填写并提交 | 创建 `PENDING` 任务 | P0 | 已定 |
| IX-003 | US-002 | 任务列表/详情 | 打开、刷新、取消 | 查看最新状态、证据和允许动作 | P0 | 已定 |
| IX-004 | US-007 | App 登录/设备绑定 | 登录或会话恢复 | 建立人员+设备身份 | P0 | 已定 |
| IX-005 | US-003 | App 任务页 | 点击“获取任务” | 就绪检查后领取一条任务或显示原因 | P0 | 已定 |
| IX-004 | US-007 | App 登录/设备绑定 | 登录或会话恢复 | 建立人员+设备身份 | P0 | T-206 已实现 |
| IX-005 | US-003 | App 任务页 | 点击“获取任务” | 就绪检查后领取一条任务或显示原因 | P0 | T-206 已实现 |
| IX-006 | US-004 | App 执行页 | 确认开始/自动步骤 | 显示步骤并有界执行搜索与候选判断 | P0 | 已定 |
| IX-007 | US-005 | App 候选确认 | 接受/拒绝/转人工 | 停止自动化并回传人员结论 | P0 | 已定 |
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | 已定,T-208 后置 |
| IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | 已定,T-206/T-207 |
| IX-010 | US-009 | App 独立执行设置/同步状态 | 配置模式、离线执行或补报 | 授权内独立执行并可审计同步 | P0 | T-206 离线控制已实现,结果补报待 T-207 |
## IX-001 管理 Web 登录
+7 -4
View File
@@ -353,9 +353,9 @@ generation/token 且租约未过期的活跃任务可以读取。成功返回匿
同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、
状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed`
和 `server_time`。T-206 将默认 running lease 从当前 90 秒改为 30 分钟(允许配置
5 至 120 分钟),并显式返回 `execution_expires_at`。该时间是 App 可以离线继续
自动化的上限,不是后台自动重分配时间。
和 `server_time`。默认 running lease 为 30 分钟(允许配置 5 至 120 分钟),
`execution.execution_expires_at` 是 App 可以离线继续自动化的上限,不是后台自动
重分配时间。
### `POST /api/v1/tasks/{task_id}/heartbeat`
@@ -392,7 +392,10 @@ generation/token 且租约未过期的活跃任务可以读取。成功返回匿
只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、
设备最近在线时间、任务 version 和 `execution_expires_at`,不写高频任务事件。
App 每 30 秒 best-effort 调用;网络失败时可执行到上一次服务端截止时间。截止时间
过期后拒绝续作,任务保持原非终态且绝不回到领取队列。
到达后 App 持久化 `SAFE_STOPPED` 并停止外部动作,任务保持原非终态且绝不回到
领取队列。原设备重连后可以用同一 execution/claim heartbeat 同步状态;后端不延长
已经过期的授权,且此时只接受 `step=SAFE_STOPPED`。若响应包含取消请求,App 可以
继续调用 `cancel-ack`;没有取消时仍保持安全停止,不自动恢复采购。
### `POST /api/v1/tasks/{task_id}/release`
+19 -13
View File
@@ -5,9 +5,9 @@
## 当前快照
- 日期:2026-07-27
- 阶段:T-205 原子领取、租约和状态机完成,下一步 T-206
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-205
均已纳入 Git 历史;T-205 提交为 `ce875af`
- 阶段:T-206 App 领取、有限离线和恢复完成,下一步 T-207
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-206
均已纳入 Git 历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
@@ -16,9 +16,9 @@
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 192 个测试通过;
- 测试:`test assembleDebug` 成功;App 两个变体、task contract 和导入器
共 32 份报告、184 次测试,0 failure、0 error、0 skipped
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 含子测试共 193 次通过;
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
均通过
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
@@ -32,9 +32,14 @@
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
- 生命周期:设备 heartbeat 记录三个版本、两个就绪位和服务端活跃任务;claim 使用
client-generated 256 bit token + 独立幂等 key、SQLite immediate transaction、
10 分钟 CLAIMED/90 秒运行租约和 2 分钟 readiness TTL。start/heartbeat/release/
10 分钟 CLAIMED/默认 30 分钟运行授权和 2 分钟 readiness TTL。start/heartbeat/release/
cancel-ack、过期回收、跨连接并发唯一领取、管理安全停止与 task-scoped 参考图均
已实现;原 claim token/hash 不进入响应或日志
已实现;运行授权允许配置 5-120 分钟并显式返回截止时间,过期状态同步不延长旧
授权;原 claim token/hash 不进入响应或日志
- Android 后台采购:新增独立“任务”入口、BUYER/设备登录、Keystore-backed 加密
状态、严格 HTTPS/Debug loopback 地址策略、`HttpTaskSource`、参考图校验、手动领取、
预览/start 二次确认、release、30 秒前台 heartbeat、有限离线、`SAFE_STOPPED`、
取消确认和进程恢复;后台任务不读取或覆盖本地 VLM provider
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -46,8 +51,9 @@
- VLM 部署决策:手机本地调用已配置的 OpenAI 兼容 provider;管理后端只负责身份、
任务控制和结果审计,不保存/代理模型。T-207 保留 Roubao 独立模式并把端上 Key
迁移到 Keystore-backed 加密存储
- 离线执行决策:T-206 将当前 90 秒运行租约扩展为默认 30 分钟有限授权;heartbeat
best-effort,授权内可离线执行,到期安全停止,RUNNING 不自动重新分配
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
RUNNING 不自动重新分配
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
`8.17.0 (81700)`
- 设备就绪:肉包采购无障碍已启用并连接;拼多多首页、搜索输入、固定词结果页、
@@ -79,7 +85,7 @@
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
| `docs/tasks/T-205.md` | DONE | 原子 claim、租约、execution 和取消安全确认 |
| `docs/tasks/T-206.md` | TODO | App 领取、默认 30 分钟有限离线执行、恢复和安全停止 |
| `docs/tasks/T-206.md` | DONE | App 领取、默认 30 分钟有限离线执行、恢复和安全停止 |
| `docs/tasks/T-207.md` | TODO | App 本地 VLM、Key 加密、候选、事件、截图和结果回传 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
@@ -91,9 +97,9 @@
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-205。
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-206。
- 正在进行:无。
- 下一个可领取任务:T-206 App 接入手动领取和执行进度。
- 下一个可领取任务:T-207 App 本地 VLM、候选、事件、截图和结果回传。
- 后置任务:T-208 候选决策数据与人工理由闭环;不得跳过 T-206/T-207 提前实现。
## 当前可运行内容
+2
View File
@@ -50,6 +50,8 @@ Android 导航名称是逻辑目的地,具体 Compose/Fragment 形式待接入
claim/start/release/cancel-ack 使用相应幂等规则;参考图、start、task heartbeat、
release 和 cancel-ack 都必须匹配当前 `X-Claim-Token` 与 `claim_generation`。
运行授权过期后只有 task heartbeat 状态同步和已请求取消的 cancel-ack 允许原设备
继续调用;它们不赋予 App 恢复外部动作的权限。
T-207 的 events/candidates/complete/fail 接受原设备对授权内已产生结果的离线补报,
并由服务端记录是否在执行授权过期后收到;后端不提供 VLM 代理路由。
+46 -11
View File
@@ -5,10 +5,10 @@ phase: 2
deps:
- T-202
- T-205
status: TODO
status: DONE
created: 2026-07-27
context_ref: 45d1436
work_branch: null
work_branch: main
write_paths:
- README.md
- android-buyer/**
@@ -93,18 +93,18 @@ Base URL、model、prompt 或 API Key。
## 验收要点
- [ ] 预授权 BUYER 在真机登录成功;错误账号、错误设备、禁用和过期均被拒绝,秘密
- [x] 预授权 BUYER 在真机登录成功;错误账号、错误设备、禁用和过期均被拒绝,秘密
不出现在日志和普通存储。
- [ ] 真机 heartbeat 后点击“获取任务”只领取管理 Web 创建的一条 `PENDING` 任务,
- [x] 真机 heartbeat 后点击“获取任务”只领取管理 Web 创建的一条 `PENDING` 任务,
显示标题、SKU、数量、描述和参考图;无任务显示空状态。
- [ ] 重复点击和模拟响应丢失不会重复领取;App 重启能恢复同一 `CLAIMED`/RUNNING。
- [ ] start 返回默认约 30 分钟 `execution_expires_at`;断开管理后端超过 90 秒后,
- [x] 重复点击和模拟响应丢失不会重复领取;App 重启能恢复同一 `CLAIMED`/RUNNING。
- [x] start 返回默认约 30 分钟 `execution_expires_at`;断开管理后端超过 90 秒后,
受控 fake workflow 仍能在授权内继续,任务不会分配给第二台设备。
- [ ] 离线授权到期后 App 安全停止;恢复网络先同步服务端状态,不擅自续作。
- [ ] 管理取消在在线时及时处理;离线时恢复连接后的首个同步点处理并确认。
- [ ] Roubao 原独立模式、端上 provider 选择和本地 fixture 探针回归通过;后台任务
- [x] 离线授权到期后 App 安全停止;恢复网络先同步服务端状态,不擅自续作。
- [x] 管理取消在在线时及时处理;离线时恢复连接后的首个同步点处理并确认。
- [x] Roubao 原独立模式、端上 provider 选择和本地 fixture 探针回归通过;后台任务
不下发、覆盖或读取来自任务内容的 provider 配置。
- [ ] Android/后端测试、根 `init.ps1` 和 OnePlus PKG110 Android 16 真机 smoke
- [x] Android/后端测试、根 `init.ps1` 和 OnePlus PKG110 Android 16 真机 smoke
通过,验证过程中不提交订单。
## 边界
@@ -116,4 +116,39 @@ Base URL、model、prompt 或 API Key。
## 执行记录
- 尚未开始。
### 2026-07-27:任务开始
- 基于提交 `9e698c1` 开始,工作区干净;T-202/T-205 依赖均已完成。
- codebase-memory MCP 本轮未暴露 graph 工具,按仓库规则回退到 `rg` 和定点读取。
- 开工基线 `.\init.ps1` 通过:Android Debug/Release 测试与 Debug APK 构建成功,
Go 全包测试、vet、gofmt 检查和 API/migration/authctl 构建成功。
- 先固定并实现后端默认 30 分钟运行授权和 `execution_expires_at`,再让 Android
登录、领取、恢复和前台服务依赖该契约。
### 2026-07-27:实现与验证完成
- 后端默认运行授权改为 30 分钟、允许 5-120 分钟;start/task heartbeat 的
`execution` 显式返回 `execution_expires_at`。授权过期后原设备 heartbeat 只同步
状态、不延长旧授权;匹配原 execution/claim 的取消确认仍可完成,RUNNING 不回队列。
- Android 新增独立“任务”入口、严格 backend 地址策略、无明文 fallback 的
`ProcurementSecureStore`、`ProcurementApiClient`、`HttpTaskSource`、参考图
MIME/JPEG/20 MiB/SHA-256 校验和加密 task/execution 恢复。密码不存储;设备
secret、access/claim token 和各操作幂等 key 只进加密存储。
- 前台服务每 30 秒 best-effort heartbeat;使用保守的服务端时钟偏移和持久
`SAFE_STOPPED` 判断离线截止。登录 token 过期时只允许同后台、同 BUYER、同设备
重新认证;过期恢复同步不会自动继续外部动作。
- 领取前先持久化 256 bit claim token/独立幂等 key,响应不确定时复用;参考图和
task 快照落盘后才可预览。start 增加二次确认和至少 2 秒稳定预览门禁,避免页面
重组或自动化注入导致误启动;T-206 受控步骤不打开拼多多、不调用 VLM、不提交订单。
- MockWebServer 覆盖 Android 登录、幂等领单、参考图 hash/同源限制和 start 到期契约;
端点策略与服务端时钟/预览门禁有纯 Kotlin 单测。最终根 `.\init.ps1` 通过:
Android 32 份报告、184 次测试零失败,Go 含子测试 193 次通过,vet、gofmt 和
API/migration/authctl Windows 构建成功。
- PKG110 Android 16 真机使用临时 18080 后端、一次性 DPAPI 保护凭证和脱敏任务
完成:BUYER 登录、readiness、领取、字段/参考图预览、start、前台通知、95 秒
后端断线、恢复滑动续期、管理取消/`cancel-ack`、release 和进程重启恢复。断线
后仍为同一 `CONTROLLED_WORKFLOW`,倒计时从约 29:38 到 27:54;重连恢复到约
29:41。验证未打开拼多多、未调用 VLM、未提交订单。
- 最终 Debug APK 已覆盖安装;一次性 API/数据库/DPAPI 凭证和 App 临时后台登录
状态已清理,Roubao 其他设置保留,采购无障碍恢复启用。T-207 接入真实端上
VLM/候选并实现事件、证据、结果和完整加密 outbox。
+10
View File
@@ -154,3 +154,13 @@
模型预测、确定性推荐、受控截图和逐候选人工理由;四层数据不得混用。
- 影响:第一版领取与结果回传不被训练数据建设阻塞;20 条真实任务试验开始前完成
T-208,人工接受、拒绝、改选和无匹配才形成可信优化标签。
## 2026-07-27 App 手动领取与有限离线
- 类型:阶段完成
- 内容:完成 T-206;Android 接入 BUYER/设备登录、手动 claim、参考图预览、
start/release、前台 heartbeat、默认 30 分钟有限离线、`SAFE_STOPPED`、取消确认
和进程恢复,秘密与任务状态使用 Keystore-backed 加密存储。
- 影响:管理员创建的任务已能在真机领取并受控运行,断开后台超过旧 90 秒窗口仍由
同一手机持有,重连优先同步;下一步 T-207 把现有端上 VLM/拼多多候选流程接入
`HttpTaskSource`,回传事件、证据和终态结果。