diff --git a/README.md b/README.md index 48791de..6b6852c 100644 --- a/README.md +++ b/README.md @@ -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) 为准。 diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index 4635cb8..c57fa35 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -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") diff --git a/android-buyer/app/src/main/AndroidManifest.xml b/android-buyer/app/src/main/AndroidManifest.xml index 54c1d56..fab9321 100644 --- a/android-buyer/app/src/main/AndroidManifest.xml +++ b/android-buyer/app/src/main/AndroidManifest.xml @@ -109,6 +109,15 @@ android:value="automation_overlay" /> + + + + (Screen.Home) } + var currentScreen by remember { mutableStateOf(Screen.Tasks) } var selectedRecord by remember { mutableStateOf(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() }, diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/BackendEndpointPolicy.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/BackendEndpointPolicy.kt new file mode 100644 index 0000000..0ee4f9b --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/BackendEndpointPolicy.kt @@ -0,0 +1,29 @@ +package com.roubao.autopilot.procurement + +import java.net.URI + +object BackendEndpointPolicy { + fun normalize(rawUrl: String, debugBuild: Boolean): Result = 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]" +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/HttpTaskSource.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/HttpTaskSource.kt new file mode 100644 index 0000000..e5c29e7 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/HttpTaskSource.kt @@ -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() +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt new file mode 100644 index 0000000..7ae8bc2 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementApiClient.kt @@ -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() + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt new file mode 100644 index 0000000..90a69c6 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt @@ -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) + ) + } + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt new file mode 100644 index 0000000..9646c8c --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt @@ -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 + ) + ) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt new file mode 100644 index 0000000..657e0de --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt @@ -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 = _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 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("设备安全存储不可用") + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementSecureStore.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementSecureStore.kt new file mode 100644 index 0000000..5e885bf --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementSecureStore.kt @@ -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" + } +} diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt new file mode 100644 index 0000000..3d3b314 --- /dev/null +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt @@ -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 -> "授权到期,已停止" + } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/BackendEndpointPolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/BackendEndpointPolicyTest.kt new file mode 100644 index 0000000..5838a30 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/BackendEndpointPolicyTest.kt @@ -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 + ) + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ExecutionAuthorizationTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ExecutionAuthorizationTest.kt new file mode 100644 index 0000000..e916aa7 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ExecutionAuthorizationTest.kt @@ -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 + ) + ) + } +} diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt new file mode 100644 index 0000000..b643c5e --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementApiClientTest.kt @@ -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) +} diff --git a/backend-api/README.md b/backend-api/README.md index 467ffeb..6b8e229 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -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)。 diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go index a8e9997..717bdd3 100644 --- a/backend-api/cmd/api/main_test.go +++ b/backend-api/cmd/api/main_test.go @@ -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 { diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index 7ea0bca..cfbbbdb 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -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 diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index 6d82c79..1355727 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -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", }, }, { diff --git a/backend-api/internal/repository/sqlite/lifecycle_repository.go b/backend-api/internal/repository/sqlite/lifecycle_repository.go index 4f01f6d..34e176b 100644 --- a/backend-api/internal/repository/sqlite/lifecycle_repository.go +++ b/backend-api/internal/repository/sqlite/lifecycle_repository.go @@ -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 diff --git a/backend-api/internal/repository/sqlite/lifecycle_repository_test.go b/backend-api/internal/repository/sqlite/lifecycle_repository_test.go index f7ed5b7..fc92c8b 100644 --- a/backend-api/internal/repository/sqlite/lifecycle_repository_test.go +++ b/backend-api/internal/repository/sqlite/lifecycle_repository_test.go @@ -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, diff --git a/backend-api/internal/transport/httpapi/device_handlers.go b/backend-api/internal/transport/httpapi/device_handlers.go index 41e5c33..4f718ae 100644 --- a/backend-api/internal/transport/httpapi/device_handlers.go +++ b/backend-api/internal/transport/httpapi/device_handlers.go @@ -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 +} diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index 6c4bef8..0f9e30c 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -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"` diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 64ed819..7e6bbb9 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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 本地完成。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 9cc545f..3235e9d 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -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/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index c01d54d..0d9efe0 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -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 上游版本基线 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index b9a444b..d011dd8 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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 diff --git a/docs/07-user-stories.md b/docs/07-user-stories.md index 1e9f16a..52e2147 100644 --- a/docs/07-user-stories.md +++ b/docs/07-user-stories.md @@ -192,8 +192,10 @@ **范围** -- 包含:App 本地 provider 配置、Keystore 加密、手动/AI 模式、有限离线授权、加密 - outbox、结果 provenance 和恢复同步。 +- T-206 已包含:后台任务安全登录/领取、有限离线授权、前台 heartbeat、状态恢复和 + 安全停止。 +- T-207 待包含:App 本地 provider 接入后台任务、Key 迁移、手动/AI 模式、加密 + outbox、结果 provenance 和恢复补报。 - 不包含:无限离线执行、离线即时取消、后端模型代理、多设备统一模型或自动下单。 **验收场景** diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index 705e7a4..e6ead4b 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -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 登录 diff --git a/docs/api.md b/docs/api.md index 51e7ab0..a65aadb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` diff --git a/docs/current-state.md b/docs/current-state.md index 1a19191..96e3f6b 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 提前实现。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index 5bf5659..d3dec2b 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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 代理路由。 diff --git a/docs/tasks/T-206.md b/docs/tasks/T-206.md index 30bc47e..70fa752 100644 --- a/docs/tasks/T-206.md +++ b/docs/tasks/T-206.md @@ -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。 diff --git a/progress.md b/progress.md index b182ccb..9f20556 100644 --- a/progress.md +++ b/progress.md @@ -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`,回传事件、证据和终态结果。