feat(t206): connect Android procurement tasks
This commit is contained in:
@@ -66,6 +66,10 @@ go run ./cmd/migrate up
|
|||||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节"
|
$env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节"
|
||||||
go run ./cmd/authctl create-user ADMIN admin
|
go run ./cmd/authctl create-user ADMIN admin
|
||||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
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
|
go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -73,6 +77,11 @@ go run ./cmd/api
|
|||||||
访问 `http://127.0.0.1:8080/healthz`。可配置项和独立验证命令见
|
访问 `http://127.0.0.1:8080/healthz`。可配置项和独立验证命令见
|
||||||
[`backend-api/README.md`](backend-api/README.md)。
|
[`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)
|
- [AI 开发入口](docs/00-ai-start-here.md)
|
||||||
@@ -85,5 +94,5 @@ go run ./cmd/api
|
|||||||
- [当前实现状态](docs/current-state.md)
|
- [当前实现状态](docs/current-state.md)
|
||||||
- [完整文档导航](docs/README.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) 为准。
|
[`docs/current-state.md`](docs/current-state.md) 为准。
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ dependencies {
|
|||||||
// Unit tests
|
// Unit tests
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|||||||
@@ -109,6 +109,15 @@
|
|||||||
android:value="automation_overlay" />
|
android:value="automation_overlay" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".procurement.ProcurementExecutionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="procurement_task_control" />
|
||||||
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".accessibility.BuyerAccessibilityService"
|
android:name=".accessibility.BuyerAccessibilityService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.app.Application
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import com.roubao.autopilot.controller.AppScanner
|
import com.roubao.autopilot.controller.AppScanner
|
||||||
import com.roubao.autopilot.controller.DeviceController
|
import com.roubao.autopilot.controller.DeviceController
|
||||||
|
import com.roubao.autopilot.procurement.ProcurementExecutionService
|
||||||
|
import com.roubao.autopilot.procurement.ProcurementRepository
|
||||||
import com.roubao.autopilot.skills.SkillManager
|
import com.roubao.autopilot.skills.SkillManager
|
||||||
import com.roubao.autopilot.tools.ToolManager
|
import com.roubao.autopilot.tools.ToolManager
|
||||||
import com.roubao.autopilot.utils.CrashHandler
|
import com.roubao.autopilot.utils.CrashHandler
|
||||||
@@ -15,6 +17,8 @@ class App : Application() {
|
|||||||
private set
|
private set
|
||||||
lateinit var appScanner: AppScanner
|
lateinit var appScanner: AppScanner
|
||||||
private set
|
private set
|
||||||
|
lateinit var procurementRepository: ProcurementRepository
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -38,6 +42,11 @@ class App : Application() {
|
|||||||
// 初始化应用扫描器
|
// 初始化应用扫描器
|
||||||
appScanner = AppScanner(this)
|
appScanner = AppScanner(this)
|
||||||
|
|
||||||
|
procurementRepository = ProcurementRepository.create(this)
|
||||||
|
if (procurementRepository.hasRunningExecution) {
|
||||||
|
ProcurementExecutionService.start(this)
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化 Tools 层
|
// 初始化 Tools 层
|
||||||
val toolManager = ToolManager.init(this, deviceController, appScanner)
|
val toolManager = ToolManager.init(this, deviceController, appScanner)
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ import com.roubao.autopilot.pinduoduo.PinduoduoProbeAutomation
|
|||||||
import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
|
import com.roubao.autopilot.pinduoduo.PinduoduoSearchAutomation
|
||||||
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
|
import com.roubao.autopilot.pinduoduo.CandidateEvidenceSource
|
||||||
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
|
import com.roubao.autopilot.pinduoduo.SEARCH_PROBE_KEYWORD
|
||||||
|
import com.roubao.autopilot.procurement.LoginInput
|
||||||
|
import com.roubao.autopilot.procurement.ProcurementRepository
|
||||||
import com.roubao.autopilot.workflow.WorkflowReport
|
import com.roubao.autopilot.workflow.WorkflowReport
|
||||||
import com.roubao.autopilot.workflow.WorkflowRunner
|
import com.roubao.autopilot.workflow.WorkflowRunner
|
||||||
import com.roubao.autopilot.workflow.WorkflowState
|
import com.roubao.autopilot.workflow.WorkflowState
|
||||||
@@ -84,6 +86,7 @@ import com.roubao.autopilot.workflow.WorkflowState
|
|||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
|
|
||||||
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
|
||||||
|
object Tasks : Screen("tasks", "任务", Icons.Outlined.ShoppingCart, Icons.Filled.ShoppingCart)
|
||||||
object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build)
|
object Device : Screen("device", "设备", Icons.Outlined.Build, Icons.Filled.Build)
|
||||||
object Home : Screen("home", "探针", Icons.Outlined.Search, Icons.Filled.Search)
|
object Home : Screen("home", "探针", Icons.Outlined.Search, Icons.Filled.Search)
|
||||||
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
|
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
|
||||||
@@ -96,6 +99,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private lateinit var settingsManager: SettingsManager
|
private lateinit var settingsManager: SettingsManager
|
||||||
private lateinit var executionRepository: ExecutionRepository
|
private lateinit var executionRepository: ExecutionRepository
|
||||||
private lateinit var readinessChecker: DeviceReadinessChecker
|
private lateinit var readinessChecker: DeviceReadinessChecker
|
||||||
|
private lateinit var procurementRepository: ProcurementRepository
|
||||||
private lateinit var requirementProbeSource: RequirementProbeSource
|
private lateinit var requirementProbeSource: RequirementProbeSource
|
||||||
private lateinit var candidateEvidenceSource: CandidateEvidenceSource
|
private lateinit var candidateEvidenceSource: CandidateEvidenceSource
|
||||||
|
|
||||||
@@ -178,6 +182,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
settingsManager = SettingsManager(this)
|
settingsManager = SettingsManager(this)
|
||||||
executionRepository = ExecutionRepository(this)
|
executionRepository = ExecutionRepository(this)
|
||||||
readinessChecker = DeviceReadinessChecker(this)
|
readinessChecker = DeviceReadinessChecker(this)
|
||||||
|
procurementRepository = (application as App).procurementRepository
|
||||||
requirementProbeSource = RequirementProbeSource(this)
|
requirementProbeSource = RequirementProbeSource(this)
|
||||||
candidateEvidenceSource = CandidateEvidenceSource(this)
|
candidateEvidenceSource = CandidateEvidenceSource(this)
|
||||||
refreshReadiness()
|
refreshReadiness()
|
||||||
@@ -232,7 +237,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainApp() {
|
fun MainApp() {
|
||||||
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
|
var currentScreen by remember { mutableStateOf<Screen>(Screen.Tasks) }
|
||||||
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
|
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
|
||||||
|
|
||||||
val settings by settingsManager.settings.collectAsState()
|
val settings by settingsManager.settings.collectAsState()
|
||||||
@@ -254,6 +259,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val evaluationState by remember { candidateEvaluationState }
|
val evaluationState by remember { candidateEvaluationState }
|
||||||
val reviewBatch by remember { candidateReviewBatch }
|
val reviewBatch by remember { candidateReviewBatch }
|
||||||
val evaluationFailure by remember { candidateEvaluationFailureCode }
|
val evaluationFailure by remember { candidateEvaluationFailureCode }
|
||||||
|
val procurementState by procurementRepository.uiState.collectAsState()
|
||||||
|
|
||||||
// 监听跳转事件
|
// 监听跳转事件
|
||||||
LaunchedEffect(navigateToRecord, recordId) {
|
LaunchedEffect(navigateToRecord, recordId) {
|
||||||
@@ -278,7 +284,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
contentColor = colors.textPrimary,
|
contentColor = colors.textPrimary,
|
||||||
tonalElevation = 0.dp
|
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
|
val selected = currentScreen == screen
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = {
|
icon = {
|
||||||
@@ -329,6 +341,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
label = "screen"
|
label = "screen"
|
||||||
) { screen ->
|
) { screen ->
|
||||||
when (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(
|
Screen.Device -> DeviceReadinessScreen(
|
||||||
snapshot = readiness,
|
snapshot = readiness,
|
||||||
onRefresh = { refreshReadiness() },
|
onRefresh = { refreshReadiness() },
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
object BackendEndpointPolicy {
|
||||||
|
fun normalize(rawUrl: String, debugBuild: Boolean): Result<String> = runCatching {
|
||||||
|
val value = rawUrl.trim().trimEnd('/')
|
||||||
|
val uri = URI(value)
|
||||||
|
require(uri.host != null && uri.port != 0) { "后台地址必须包含主机" }
|
||||||
|
require(uri.userInfo == null && uri.query == null && uri.fragment == null) {
|
||||||
|
"后台地址不能包含账号、查询参数或片段"
|
||||||
|
}
|
||||||
|
require(uri.path.isNullOrEmpty() || uri.path == "/") { "后台地址不能包含路径" }
|
||||||
|
when (uri.scheme?.lowercase()) {
|
||||||
|
"https" -> Unit
|
||||||
|
"http" -> require(debugBuild && uri.host.isLoopbackHost()) {
|
||||||
|
"正式版只允许 HTTPS;调试版 HTTP 仅允许本机回环地址"
|
||||||
|
}
|
||||||
|
else -> error("后台地址必须使用 HTTPS")
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isLoopbackHost(): Boolean =
|
||||||
|
equals("localhost", ignoreCase = true) ||
|
||||||
|
this == "127.0.0.1" ||
|
||||||
|
this == "::1" ||
|
||||||
|
this == "[::1]"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import com.roubao.task.TaskSource
|
||||||
|
|
||||||
|
class HttpTaskSource(
|
||||||
|
private val repository: ProcurementRepository
|
||||||
|
) : TaskSource {
|
||||||
|
override suspend fun nextTask() = repository.currentProbeTask()
|
||||||
|
}
|
||||||
+522
@@ -0,0 +1,522 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.URI
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
class ProcurementApiException(
|
||||||
|
val code: String,
|
||||||
|
val statusCode: Int,
|
||||||
|
val retryable: Boolean,
|
||||||
|
message: String
|
||||||
|
) : Exception(message)
|
||||||
|
|
||||||
|
data class DownloadedReferenceImage(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val sha256: String
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ProcurementRemoteApi {
|
||||||
|
suspend fun login(
|
||||||
|
baseUrl: String,
|
||||||
|
input: LoginInput,
|
||||||
|
appVersion: String,
|
||||||
|
androidVersion: String
|
||||||
|
): ProcurementSession
|
||||||
|
|
||||||
|
suspend fun deviceHeartbeat(
|
||||||
|
session: ProcurementSession,
|
||||||
|
input: DeviceHeartbeatInput
|
||||||
|
): DeviceHeartbeatResult
|
||||||
|
|
||||||
|
suspend fun claimNext(
|
||||||
|
session: ProcurementSession,
|
||||||
|
deviceId: String,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): ClaimResult
|
||||||
|
|
||||||
|
suspend fun downloadReferenceImage(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
claimToken: String
|
||||||
|
): DownloadedReferenceImage
|
||||||
|
|
||||||
|
suspend fun start(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): StartResult
|
||||||
|
|
||||||
|
suspend fun heartbeat(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
execution: RunningExecution,
|
||||||
|
claimToken: String
|
||||||
|
): TaskHeartbeatResult
|
||||||
|
|
||||||
|
suspend fun release(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): RemotePurchaseTask
|
||||||
|
|
||||||
|
suspend fun acknowledgeCancellation(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
execution: RunningExecution,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): RemotePurchaseTask
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProcurementApiClient(
|
||||||
|
private val client: OkHttpClient = defaultClient()
|
||||||
|
) : ProcurementRemoteApi {
|
||||||
|
override suspend fun login(
|
||||||
|
baseUrl: String,
|
||||||
|
input: LoginInput,
|
||||||
|
appVersion: String,
|
||||||
|
androidVersion: String
|
||||||
|
): ProcurementSession {
|
||||||
|
val localNow = System.currentTimeMillis()
|
||||||
|
val payload = JSONObject()
|
||||||
|
.put("username", input.username.trim())
|
||||||
|
.put("password", input.password)
|
||||||
|
.put("device_id", input.deviceId.trim())
|
||||||
|
.put("device_token", input.deviceToken)
|
||||||
|
.put("app_version", appVersion)
|
||||||
|
.put("android_version", androidVersion)
|
||||||
|
val json = executeJson(
|
||||||
|
Request.Builder()
|
||||||
|
.url("$baseUrl/api/v1/auth/token")
|
||||||
|
.post(payload.jsonBody())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return ProcurementSession(
|
||||||
|
backendUrl = baseUrl,
|
||||||
|
username = input.username.trim(),
|
||||||
|
deviceId = json.getJSONObject("device").getString("id"),
|
||||||
|
accessToken = json.getString("access_token"),
|
||||||
|
expiresAtEpochMillis =
|
||||||
|
localNow + json.getLong("expires_in") * 1_000L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deviceHeartbeat(
|
||||||
|
session: ProcurementSession,
|
||||||
|
input: DeviceHeartbeatInput
|
||||||
|
): DeviceHeartbeatResult {
|
||||||
|
val payload = JSONObject()
|
||||||
|
.put("device_id", session.deviceId)
|
||||||
|
.put("app_version", input.appVersion)
|
||||||
|
.put("android_version", input.androidVersion)
|
||||||
|
.put("pdd_version", input.pddVersion)
|
||||||
|
.put(
|
||||||
|
"readiness",
|
||||||
|
JSONObject()
|
||||||
|
.put("accessibility_enabled", input.accessibilityEnabled)
|
||||||
|
.put("pdd_installed", input.pddInstalled)
|
||||||
|
.put(
|
||||||
|
"active_task_id",
|
||||||
|
input.activeTaskId ?: JSONObject.NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val json = executeJson(
|
||||||
|
authorizedRequest(session, "/api/v1/devices/heartbeat")
|
||||||
|
.post(payload.jsonBody())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return DeviceHeartbeatResult(
|
||||||
|
activeTaskId = json.optionalString("active_task_id"),
|
||||||
|
clientStateMatches = json.getBoolean("client_state_matches"),
|
||||||
|
serverTime = json.getString("server_time")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun claimNext(
|
||||||
|
session: ProcurementSession,
|
||||||
|
deviceId: String,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): ClaimResult = withContext(Dispatchers.IO) {
|
||||||
|
val response = client.newCall(
|
||||||
|
authorizedRequest(session, "/api/v1/tasks/claim-next")
|
||||||
|
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||||
|
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||||
|
.post(JSONObject().put("device_id", deviceId).jsonBody())
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
response.use {
|
||||||
|
if (it.code == 204) {
|
||||||
|
return@withContext ClaimResult(null, null)
|
||||||
|
}
|
||||||
|
val json = responseJsonOrThrow(
|
||||||
|
it.code,
|
||||||
|
it.body.readBoundedJson(it.code)
|
||||||
|
)
|
||||||
|
ClaimResult(
|
||||||
|
task = parseTask(json.getJSONObject("task")),
|
||||||
|
serverTime = json.getString("server_time")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadReferenceImage(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
claimToken: String
|
||||||
|
): DownloadedReferenceImage = withContext(Dispatchers.IO) {
|
||||||
|
val target = referenceImageTarget(session, task)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(target)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
responseJsonOrThrow(
|
||||||
|
response.code,
|
||||||
|
response.body.readBoundedJson(response.code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val contentType = response.header("Content-Type")
|
||||||
|
?.substringBefore(';')
|
||||||
|
?.trim()
|
||||||
|
if (contentType != JPEG_MEDIA_TYPE) {
|
||||||
|
throw ProcurementApiException(
|
||||||
|
"REFERENCE_IMAGE_INVALID",
|
||||||
|
response.code,
|
||||||
|
false,
|
||||||
|
"参考图不是 JPEG"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val declaredLength = response.body?.contentLength() ?: -1L
|
||||||
|
if (declaredLength > MAX_REFERENCE_IMAGE_BYTES) {
|
||||||
|
throw imageTooLarge()
|
||||||
|
}
|
||||||
|
val bytes = response.body?.byteStream()?.use {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var total = 0L
|
||||||
|
while (true) {
|
||||||
|
val read = it.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
total += read
|
||||||
|
if (total > MAX_REFERENCE_IMAGE_BYTES) throw imageTooLarge()
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
output.toByteArray()
|
||||||
|
} ?: ByteArray(0)
|
||||||
|
if (bytes.size < 3 ||
|
||||||
|
bytes[0] != 0xFF.toByte() ||
|
||||||
|
bytes[1] != 0xD8.toByte() ||
|
||||||
|
bytes[2] != 0xFF.toByte()
|
||||||
|
) {
|
||||||
|
throw ProcurementApiException(
|
||||||
|
"REFERENCE_IMAGE_INVALID",
|
||||||
|
response.code,
|
||||||
|
false,
|
||||||
|
"参考图内容无效"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val sha256 = bytes.sha256()
|
||||||
|
val expectedHash = response.header("ETag")
|
||||||
|
?.trim()
|
||||||
|
?.removePrefix("W/")
|
||||||
|
?.trim('"')
|
||||||
|
?.takeIf { SHA256_PATTERN.matches(it) }
|
||||||
|
if (expectedHash != null && expectedHash != sha256) {
|
||||||
|
throw ProcurementApiException(
|
||||||
|
"REFERENCE_IMAGE_HASH_MISMATCH",
|
||||||
|
response.code,
|
||||||
|
false,
|
||||||
|
"参考图校验失败"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DownloadedReferenceImage(bytes, sha256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun start(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): StartResult {
|
||||||
|
val payload = transitionPayload(session.deviceId, task)
|
||||||
|
val json = executeJson(
|
||||||
|
authorizedRequest(session, "/api/v1/tasks/${task.id}/start")
|
||||||
|
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||||
|
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||||
|
.post(payload.jsonBody())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val execution = json.getJSONObject("execution")
|
||||||
|
return StartResult(
|
||||||
|
task = parseTask(json.getJSONObject("task")),
|
||||||
|
executionId = execution.getString("id"),
|
||||||
|
currentStep = execution.getString("current_step"),
|
||||||
|
executionExpiresAt = execution.getString("execution_expires_at"),
|
||||||
|
serverTime = json.getString("server_time")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun heartbeat(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
execution: RunningExecution,
|
||||||
|
claimToken: String
|
||||||
|
): TaskHeartbeatResult {
|
||||||
|
val payload = JSONObject()
|
||||||
|
.put("device_id", session.deviceId)
|
||||||
|
.put("execution_id", execution.id)
|
||||||
|
.put("claim_generation", task.claimGeneration)
|
||||||
|
.put("step", execution.currentStep)
|
||||||
|
val json = executeJson(
|
||||||
|
authorizedRequest(session, "/api/v1/tasks/${task.id}/heartbeat")
|
||||||
|
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||||
|
.post(payload.jsonBody())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val responseExecution = json.getJSONObject("execution")
|
||||||
|
return TaskHeartbeatResult(
|
||||||
|
task = parseTask(json.getJSONObject("task")),
|
||||||
|
executionId = responseExecution.getString("id"),
|
||||||
|
currentStep = responseExecution.getString("current_step"),
|
||||||
|
executionExpiresAt =
|
||||||
|
responseExecution.getString("execution_expires_at"),
|
||||||
|
cancelRequested = json.getBoolean("cancel_requested"),
|
||||||
|
serverTime = json.getString("server_time")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun release(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): RemotePurchaseTask =
|
||||||
|
executeTransition(
|
||||||
|
session,
|
||||||
|
task,
|
||||||
|
claimToken,
|
||||||
|
idempotencyKey,
|
||||||
|
"release"
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun acknowledgeCancellation(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
execution: RunningExecution,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String
|
||||||
|
): RemotePurchaseTask {
|
||||||
|
val payload = transitionPayload(session.deviceId, task)
|
||||||
|
.put("execution_id", execution.id)
|
||||||
|
val json = executeJson(
|
||||||
|
authorizedRequest(session, "/api/v1/tasks/${task.id}/cancel-ack")
|
||||||
|
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||||
|
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||||
|
.post(payload.jsonBody())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return parseTask(json.getJSONObject("task"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeTransition(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask,
|
||||||
|
claimToken: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
operation: String
|
||||||
|
): RemotePurchaseTask {
|
||||||
|
val json = executeJson(
|
||||||
|
authorizedRequest(session, "/api/v1/tasks/${task.id}/$operation")
|
||||||
|
.header(CLAIM_TOKEN_HEADER, claimToken)
|
||||||
|
.header(IDEMPOTENCY_HEADER, idempotencyKey)
|
||||||
|
.post(transitionPayload(session.deviceId, task).jsonBody())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return parseTask(json.getJSONObject("task"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authorizedRequest(
|
||||||
|
session: ProcurementSession,
|
||||||
|
path: String
|
||||||
|
): Request.Builder =
|
||||||
|
Request.Builder()
|
||||||
|
.url(session.backendUrl + path)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
|
||||||
|
private suspend fun executeJson(request: Request): JSONObject =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
responseJsonOrThrow(
|
||||||
|
response.code,
|
||||||
|
response.body.readBoundedJson(response.code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun responseJsonOrThrow(statusCode: Int, rawBody: String?): JSONObject {
|
||||||
|
val json = runCatching { JSONObject(rawBody ?: "") }.getOrElse {
|
||||||
|
throw ProcurementApiException(
|
||||||
|
"INVALID_RESPONSE",
|
||||||
|
statusCode,
|
||||||
|
statusCode >= 500,
|
||||||
|
"后台响应格式无效"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (statusCode !in 200..299) {
|
||||||
|
val error = json.optJSONObject("error")
|
||||||
|
throw ProcurementApiException(
|
||||||
|
code = error?.optString("code")?.takeIf(String::isNotBlank)
|
||||||
|
?: "HTTP_$statusCode",
|
||||||
|
statusCode = statusCode,
|
||||||
|
retryable = error?.optBoolean("retryable", statusCode >= 500)
|
||||||
|
?: (statusCode >= 500),
|
||||||
|
message = stableErrorMessage(
|
||||||
|
error?.optString("code"),
|
||||||
|
statusCode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stableErrorMessage(code: String?, statusCode: Int): String =
|
||||||
|
when (code) {
|
||||||
|
"AUTH_INVALID_CREDENTIALS" -> "账号、密码或设备凭证错误"
|
||||||
|
"AUTH_ACCOUNT_OR_DEVICE_DISABLED" -> "账号或设备已停用"
|
||||||
|
"AUTH_RATE_LIMITED" -> "登录尝试过多,请稍后重试"
|
||||||
|
"DEVICE_NOT_READY" -> "设备未就绪,无法领取任务"
|
||||||
|
"DEVICE_HAS_ACTIVE_TASK" -> "设备已有进行中的任务"
|
||||||
|
"TASK_CLAIM_INVALID" -> "任务归属校验失败"
|
||||||
|
"TASK_CLAIM_EXPIRED" -> "任务授权已过期"
|
||||||
|
"TASK_VERSION_CONFLICT" -> "任务已被后台更新,请先同步"
|
||||||
|
else -> if (statusCode >= 500) "后台暂时不可用" else "后台拒绝了请求"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transitionPayload(
|
||||||
|
deviceId: String,
|
||||||
|
task: RemotePurchaseTask
|
||||||
|
): JSONObject =
|
||||||
|
JSONObject()
|
||||||
|
.put("device_id", deviceId)
|
||||||
|
.put("claim_generation", task.claimGeneration)
|
||||||
|
.put("expected_version", task.version)
|
||||||
|
|
||||||
|
private fun referenceImageTarget(
|
||||||
|
session: ProcurementSession,
|
||||||
|
task: RemotePurchaseTask
|
||||||
|
): String {
|
||||||
|
val backend = URI(session.backendUrl)
|
||||||
|
val target = backend.resolve(task.referenceImageUrl)
|
||||||
|
val expectedPath = "/api/v1/tasks/${task.id}/reference-image"
|
||||||
|
val sameOrigin =
|
||||||
|
target.scheme.equals(backend.scheme, ignoreCase = true) &&
|
||||||
|
target.host.equals(backend.host, ignoreCase = true) &&
|
||||||
|
target.port == backend.port
|
||||||
|
if (!sameOrigin ||
|
||||||
|
target.userInfo != null ||
|
||||||
|
target.fragment != null ||
|
||||||
|
target.path != expectedPath
|
||||||
|
) {
|
||||||
|
throw ProcurementApiException(
|
||||||
|
"REFERENCE_IMAGE_ORIGIN_INVALID",
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
"参考图地址不可信"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return target.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTask(json: JSONObject): RemotePurchaseTask =
|
||||||
|
RemotePurchaseTask(
|
||||||
|
id = json.getString("id"),
|
||||||
|
status = json.getString("status"),
|
||||||
|
version = json.getLong("version"),
|
||||||
|
claimGeneration = json.getLong("claim_generation"),
|
||||||
|
claimExpiresAt = json.optionalString("claim_expires_at"),
|
||||||
|
title = json.getString("title"),
|
||||||
|
description = json.optString("description"),
|
||||||
|
sku = json.getString("sku"),
|
||||||
|
referenceImageUrl = json.getString("reference_image_url"),
|
||||||
|
quantity = json.getInt("quantity"),
|
||||||
|
maxBudget = json.optionalString("max_budget"),
|
||||||
|
currency = json.optString("currency", "CNY")
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.jsonBody() =
|
||||||
|
toString().toRequestBody(JSON_MEDIA_TYPE)
|
||||||
|
|
||||||
|
private fun JSONObject.optionalString(name: String): String? =
|
||||||
|
if (has(name) && !isNull(name)) getString(name) else null
|
||||||
|
|
||||||
|
private fun ResponseBody?.readBoundedJson(statusCode: Int): String? {
|
||||||
|
this ?: return null
|
||||||
|
if (contentLength() > MAX_JSON_BYTES) {
|
||||||
|
throw responseTooLarge(statusCode)
|
||||||
|
}
|
||||||
|
val source = source()
|
||||||
|
source.request(MAX_JSON_BYTES + 1L)
|
||||||
|
if (source.buffer.size > MAX_JSON_BYTES) {
|
||||||
|
throw responseTooLarge(statusCode)
|
||||||
|
}
|
||||||
|
return source.readUtf8()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.sha256(): String =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(this)
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun imageTooLarge() = ProcurementApiException(
|
||||||
|
"REFERENCE_IMAGE_TOO_LARGE",
|
||||||
|
200,
|
||||||
|
false,
|
||||||
|
"参考图超过 20 MiB"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun responseTooLarge(statusCode: Int) = ProcurementApiException(
|
||||||
|
"RESPONSE_TOO_LARGE",
|
||||||
|
statusCode,
|
||||||
|
false,
|
||||||
|
"后台响应过大"
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private const val JPEG_MEDIA_TYPE = "image/jpeg"
|
||||||
|
private const val CLAIM_TOKEN_HEADER = "X-Claim-Token"
|
||||||
|
private const val IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||||
|
private const val MAX_JSON_BYTES = 1_048_576L
|
||||||
|
private const val MAX_REFERENCE_IMAGE_BYTES = 20L * 1024L * 1024L
|
||||||
|
private val SHA256_PATTERN = Regex("[0-9a-f]{64}")
|
||||||
|
|
||||||
|
private fun defaultClient(): OkHttpClient =
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.readTimeout(Duration.ofSeconds(30))
|
||||||
|
.writeTimeout(Duration.ofSeconds(30))
|
||||||
|
.callTimeout(Duration.ofSeconds(45))
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
|
.retryOnConnectionFailure(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.roubao.autopilot.App
|
||||||
|
import com.roubao.autopilot.MainActivity
|
||||||
|
import com.roubao.autopilot.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ProcurementExecutionService : Service() {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
startForeground(NOTIFICATION_ID, notification())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (heartbeatJob?.isActive != true) {
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
val repository = (application as App).procurementRepository
|
||||||
|
while (isActive) {
|
||||||
|
val decision = repository.synchronizeRunning()
|
||||||
|
if (decision == ExecutionSyncDecision.STOP) {
|
||||||
|
stopSelf()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(HEARTBEAT_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun notification(): Notification {
|
||||||
|
val openApp = Intent(this, MainActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
openApp,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle("肉包采购任务运行中")
|
||||||
|
.setContentText("正在同步任务进度和后台取消请求")
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"采购任务",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "保持采购任务运行并同步进度"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "procurement_execution"
|
||||||
|
private const val NOTIFICATION_ID = 206
|
||||||
|
private const val HEARTBEAT_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
context,
|
||||||
|
Intent(context, ProcurementExecutionService::class.java)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import com.roubao.task.ProbeReferenceImage
|
||||||
|
import com.roubao.task.ProbeTask
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
enum class ProcurementPhase {
|
||||||
|
SIGNED_OUT,
|
||||||
|
IDLE,
|
||||||
|
CLAIMED,
|
||||||
|
RUNNING,
|
||||||
|
AUTHORIZATION_EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ConnectionProfile(
|
||||||
|
val backendUrl: String = "http://127.0.0.1:8080",
|
||||||
|
val username: String = "",
|
||||||
|
val deviceId: String = "",
|
||||||
|
val hasDeviceToken: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProcurementSession(
|
||||||
|
val backendUrl: String,
|
||||||
|
val username: String,
|
||||||
|
val deviceId: String,
|
||||||
|
val accessToken: String,
|
||||||
|
val expiresAtEpochMillis: Long
|
||||||
|
) {
|
||||||
|
fun isValid(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
|
||||||
|
accessToken.isNotBlank() && expiresAtEpochMillis > nowEpochMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RemotePurchaseTask(
|
||||||
|
val id: String,
|
||||||
|
val status: String,
|
||||||
|
val version: Long,
|
||||||
|
val claimGeneration: Long,
|
||||||
|
val claimExpiresAt: String?,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val sku: String,
|
||||||
|
val referenceImageUrl: String,
|
||||||
|
val quantity: Int,
|
||||||
|
val maxBudget: String?,
|
||||||
|
val currency: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReferenceImageRecord(
|
||||||
|
val relativePath: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val sha256: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ClaimContext(
|
||||||
|
val token: String,
|
||||||
|
val idempotencyKey: String,
|
||||||
|
val task: RemotePurchaseTask? = null,
|
||||||
|
val referenceImage: ReferenceImageRecord? = null,
|
||||||
|
val previewReadyAtEpochMillis: Long? = null,
|
||||||
|
val startIdempotencyKey: String? = null,
|
||||||
|
val releaseIdempotencyKey: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RunningExecution(
|
||||||
|
val id: String,
|
||||||
|
val currentStep: String,
|
||||||
|
val expiresAt: String,
|
||||||
|
val serverClockOffsetMillis: Long,
|
||||||
|
val safetyStopped: Boolean = false,
|
||||||
|
val cancelAcknowledgementKey: String? = null
|
||||||
|
) {
|
||||||
|
fun isExpired(nowEpochMillis: Long = System.currentTimeMillis()): Boolean =
|
||||||
|
ExecutionAuthorization.isExpired(
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
serverClockOffsetMillis = serverClockOffsetMillis,
|
||||||
|
nowEpochMillis = nowEpochMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PersistedProcurementState(
|
||||||
|
val session: ProcurementSession? = null,
|
||||||
|
val deviceToken: String? = null,
|
||||||
|
val claim: ClaimContext? = null,
|
||||||
|
val execution: RunningExecution? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProcurementUiState(
|
||||||
|
val phase: ProcurementPhase = ProcurementPhase.SIGNED_OUT,
|
||||||
|
val profile: ConnectionProfile = ConnectionProfile(),
|
||||||
|
val task: RemotePurchaseTask? = null,
|
||||||
|
val referenceImagePath: String? = null,
|
||||||
|
val execution: RunningExecution? = null,
|
||||||
|
val authenticationRequired: Boolean = false,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val backendOnline: Boolean? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LoginInput(
|
||||||
|
val backendUrl: String,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
val deviceId: String,
|
||||||
|
val deviceToken: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeviceHeartbeatInput(
|
||||||
|
val appVersion: String,
|
||||||
|
val androidVersion: String,
|
||||||
|
val pddVersion: String,
|
||||||
|
val accessibilityEnabled: Boolean,
|
||||||
|
val pddInstalled: Boolean,
|
||||||
|
val activeTaskId: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeviceHeartbeatResult(
|
||||||
|
val activeTaskId: String?,
|
||||||
|
val clientStateMatches: Boolean,
|
||||||
|
val serverTime: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ClaimResult(
|
||||||
|
val task: RemotePurchaseTask?,
|
||||||
|
val serverTime: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StartResult(
|
||||||
|
val task: RemotePurchaseTask,
|
||||||
|
val executionId: String,
|
||||||
|
val currentStep: String,
|
||||||
|
val executionExpiresAt: String,
|
||||||
|
val serverTime: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TaskHeartbeatResult(
|
||||||
|
val task: RemotePurchaseTask,
|
||||||
|
val executionId: String,
|
||||||
|
val currentStep: String,
|
||||||
|
val executionExpiresAt: String,
|
||||||
|
val cancelRequested: Boolean,
|
||||||
|
val serverTime: String
|
||||||
|
)
|
||||||
|
|
||||||
|
object ExecutionAuthorization {
|
||||||
|
fun isExpired(
|
||||||
|
expiresAt: String,
|
||||||
|
serverClockOffsetMillis: Long,
|
||||||
|
nowEpochMillis: Long
|
||||||
|
): Boolean {
|
||||||
|
val serverNow = nowEpochMillis + serverClockOffsetMillis
|
||||||
|
return runCatching { Instant.parse(expiresAt).toEpochMilli() <= serverNow }
|
||||||
|
.getOrDefault(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serverClockOffset(serverTime: String, localNowEpochMillis: Long): Long =
|
||||||
|
Instant.parse(serverTime).toEpochMilli() - localNowEpochMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
object StartAuthorization {
|
||||||
|
const val MINIMUM_PREVIEW_DURATION_MS = 2_000L
|
||||||
|
|
||||||
|
fun isPreviewConfirmed(
|
||||||
|
previewReadyAtEpochMillis: Long,
|
||||||
|
nowEpochMillis: Long
|
||||||
|
): Boolean =
|
||||||
|
nowEpochMillis - previewReadyAtEpochMillis >=
|
||||||
|
MINIMUM_PREVIEW_DURATION_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RemotePurchaseTask.toProbeTask(image: ReferenceImageRecord): ProbeTask =
|
||||||
|
ProbeTask(
|
||||||
|
probeId = id,
|
||||||
|
sourceOrderNo = id,
|
||||||
|
sourceStoreName = "管理后台",
|
||||||
|
title = title,
|
||||||
|
sku = sku,
|
||||||
|
quantity = quantity,
|
||||||
|
referenceImage = ProbeReferenceImage(
|
||||||
|
relativePath = image.relativePath,
|
||||||
|
mediaType = "image/jpeg",
|
||||||
|
sizeBytes = image.sizeBytes,
|
||||||
|
sha256 = image.sha256
|
||||||
|
)
|
||||||
|
)
|
||||||
+509
@@ -0,0 +1,509 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Build
|
||||||
|
import com.roubao.autopilot.BuildConfig
|
||||||
|
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
enum class ExecutionSyncDecision {
|
||||||
|
CONTINUE,
|
||||||
|
STOP
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProcurementRepository(
|
||||||
|
context: Context,
|
||||||
|
private val store: ProcurementStateStore,
|
||||||
|
private val api: ProcurementRemoteApi,
|
||||||
|
private val storageFailure: String? = null
|
||||||
|
) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val mutex = Mutex()
|
||||||
|
@Volatile
|
||||||
|
private var persisted = store.load()
|
||||||
|
private val _uiState = MutableStateFlow(
|
||||||
|
buildUiState().copy(error = storageFailure)
|
||||||
|
)
|
||||||
|
|
||||||
|
val uiState: StateFlow<ProcurementUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
val hasRunningExecution: Boolean
|
||||||
|
get() = storageFailure == null &&
|
||||||
|
persisted.execution?.safetyStopped == false
|
||||||
|
|
||||||
|
suspend fun login(input: LoginInput): Boolean = operation {
|
||||||
|
require(input.password.isNotEmpty()) { "请输入采购员密码" }
|
||||||
|
val normalizedUrl = BackendEndpointPolicy.normalize(
|
||||||
|
input.backendUrl,
|
||||||
|
BuildConfig.DEBUG
|
||||||
|
).getOrElse { throw IllegalArgumentException(it.message) }
|
||||||
|
persisted.session?.takeIf {
|
||||||
|
persisted.claim != null || persisted.execution != null
|
||||||
|
}?.let { current ->
|
||||||
|
require(
|
||||||
|
normalizedUrl == current.backendUrl &&
|
||||||
|
input.username.trim() == current.username &&
|
||||||
|
input.deviceId.trim() == current.deviceId
|
||||||
|
) { "当前任务结束前只能重新认证同一采购员和设备" }
|
||||||
|
}
|
||||||
|
val deviceToken = input.deviceToken.ifBlank {
|
||||||
|
persisted.deviceToken ?: ""
|
||||||
|
}
|
||||||
|
require(deviceToken.isNotBlank()) { "请输入预授权设备密钥" }
|
||||||
|
val session = api.login(
|
||||||
|
normalizedUrl,
|
||||||
|
input.copy(deviceToken = deviceToken),
|
||||||
|
BuildConfig.VERSION_NAME,
|
||||||
|
Build.VERSION.RELEASE
|
||||||
|
)
|
||||||
|
persisted = persisted.copy(
|
||||||
|
session = session,
|
||||||
|
deviceToken = deviceToken
|
||||||
|
)
|
||||||
|
saveAndPublish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = "设备登录成功"
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
suspend fun claimNext(snapshot: DeviceReadinessSnapshot): Boolean = operation {
|
||||||
|
val session = requireValidSession()
|
||||||
|
val existing = persisted.claim
|
||||||
|
if (existing?.task != null) {
|
||||||
|
if (existing.referenceImage == null) {
|
||||||
|
downloadAndPersistReference(session, existing, existing.task)
|
||||||
|
}
|
||||||
|
saveAndPublish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = "已恢复当前领取任务"
|
||||||
|
)
|
||||||
|
return@operation true
|
||||||
|
}
|
||||||
|
val heartbeat = api.deviceHeartbeat(
|
||||||
|
session,
|
||||||
|
snapshot.toHeartbeatInput(persisted.claim?.task?.id)
|
||||||
|
)
|
||||||
|
if (heartbeat.activeTaskId != null &&
|
||||||
|
existing == null
|
||||||
|
) {
|
||||||
|
throw IllegalStateException("后台显示本设备已有任务,请先恢复处理")
|
||||||
|
}
|
||||||
|
val claim = existing ?: ClaimContext(
|
||||||
|
token = newOpaqueSecret(),
|
||||||
|
idempotencyKey = newOpaqueSecret()
|
||||||
|
).also {
|
||||||
|
persisted = persisted.copy(claim = it)
|
||||||
|
store.save(persisted)
|
||||||
|
}
|
||||||
|
val result = api.claimNext(
|
||||||
|
session,
|
||||||
|
session.deviceId,
|
||||||
|
claim.token,
|
||||||
|
claim.idempotencyKey
|
||||||
|
)
|
||||||
|
val task = result.task
|
||||||
|
if (task == null) {
|
||||||
|
persisted = persisted.copy(claim = null)
|
||||||
|
saveAndPublish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = "暂无待采购任务"
|
||||||
|
)
|
||||||
|
return@operation false
|
||||||
|
}
|
||||||
|
val claimed = claim.copy(task = task)
|
||||||
|
persisted = persisted.copy(claim = claimed)
|
||||||
|
store.save(persisted)
|
||||||
|
downloadAndPersistReference(session, claimed, task)
|
||||||
|
saveAndPublish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = "任务已领取,请核对后开始"
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
suspend fun start(): Boolean = operation {
|
||||||
|
val session = requireValidSession()
|
||||||
|
val claim = requireNotNull(persisted.claim) { "没有可开始的任务" }
|
||||||
|
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
|
||||||
|
require(claim.referenceImage != null) { "参考图尚未安全下载" }
|
||||||
|
val previewReadyAt = requireNotNull(claim.previewReadyAtEpochMillis) {
|
||||||
|
"任务预览尚未准备完成"
|
||||||
|
}
|
||||||
|
require(
|
||||||
|
StartAuthorization.isPreviewConfirmed(
|
||||||
|
previewReadyAt,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
) { "请先核对任务信息,再确认开始" }
|
||||||
|
val startKey = claim.startIdempotencyKey ?: newOpaqueSecret().also {
|
||||||
|
persisted = persisted.copy(
|
||||||
|
claim = claim.copy(startIdempotencyKey = it)
|
||||||
|
)
|
||||||
|
store.save(persisted)
|
||||||
|
}
|
||||||
|
val requestStartedAt = System.currentTimeMillis()
|
||||||
|
val result = api.start(session, task, claim.token, startKey)
|
||||||
|
val execution = RunningExecution(
|
||||||
|
id = result.executionId,
|
||||||
|
currentStep = CONTROLLED_WORKFLOW_STEP,
|
||||||
|
expiresAt = result.executionExpiresAt,
|
||||||
|
serverClockOffsetMillis = ExecutionAuthorization.serverClockOffset(
|
||||||
|
result.serverTime,
|
||||||
|
requestStartedAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
persisted = persisted.copy(
|
||||||
|
claim = persisted.claim?.copy(task = result.task),
|
||||||
|
execution = execution
|
||||||
|
)
|
||||||
|
saveAndPublish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = "受控采购流程已开始"
|
||||||
|
)
|
||||||
|
ProcurementExecutionService.start(appContext)
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
suspend fun release(): Boolean = operation {
|
||||||
|
val session = requireValidSession()
|
||||||
|
val claim = requireNotNull(persisted.claim) { "没有已领取任务" }
|
||||||
|
require(persisted.execution == null) { "运行中的任务不能直接释放" }
|
||||||
|
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
|
||||||
|
val releaseKey = claim.releaseIdempotencyKey ?: newOpaqueSecret().also {
|
||||||
|
persisted = persisted.copy(
|
||||||
|
claim = claim.copy(releaseIdempotencyKey = it)
|
||||||
|
)
|
||||||
|
store.save(persisted)
|
||||||
|
}
|
||||||
|
api.release(session, task, claim.token, releaseKey)
|
||||||
|
clearCurrentTask()
|
||||||
|
saveAndPublish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = "任务已退回待领取队列"
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
suspend fun synchronizeRunning(): ExecutionSyncDecision =
|
||||||
|
synchronize(allowSafetyStoppedSync = false)
|
||||||
|
|
||||||
|
private suspend fun synchronize(
|
||||||
|
allowSafetyStoppedSync: Boolean
|
||||||
|
): ExecutionSyncDecision = mutex.withLock {
|
||||||
|
val claim = persisted.claim
|
||||||
|
val task = claim?.task
|
||||||
|
var execution = persisted.execution
|
||||||
|
if (claim == null || task == null || execution == null) {
|
||||||
|
return@withLock ExecutionSyncDecision.STOP
|
||||||
|
}
|
||||||
|
if (execution.isExpired() && !execution.safetyStopped) {
|
||||||
|
execution = execution.copy(
|
||||||
|
currentStep = SAFE_STOPPED_STEP,
|
||||||
|
safetyStopped = true
|
||||||
|
)
|
||||||
|
persisted = persisted.copy(execution = execution)
|
||||||
|
store.save(persisted)
|
||||||
|
publish(
|
||||||
|
backendOnline = false,
|
||||||
|
error = "离线执行授权已到期,自动化已安全停止"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (execution.safetyStopped && !allowSafetyStoppedSync) {
|
||||||
|
return@withLock ExecutionSyncDecision.STOP
|
||||||
|
}
|
||||||
|
val session = persisted.session
|
||||||
|
if (session == null || !session.isValid()) {
|
||||||
|
publish(
|
||||||
|
backendOnline = false,
|
||||||
|
error = "登录令牌已过期;授权截止前保持停止外部动作"
|
||||||
|
)
|
||||||
|
return@withLock if (execution.safetyStopped) {
|
||||||
|
ExecutionSyncDecision.STOP
|
||||||
|
} else {
|
||||||
|
ExecutionSyncDecision.CONTINUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@withLock try {
|
||||||
|
val requestStartedAt = System.currentTimeMillis()
|
||||||
|
val result = api.heartbeat(session, task, execution, claim.token)
|
||||||
|
val updatedExecution = execution.copy(
|
||||||
|
id = result.executionId,
|
||||||
|
currentStep = result.currentStep,
|
||||||
|
expiresAt = result.executionExpiresAt,
|
||||||
|
serverClockOffsetMillis =
|
||||||
|
ExecutionAuthorization.serverClockOffset(
|
||||||
|
result.serverTime,
|
||||||
|
requestStartedAt
|
||||||
|
),
|
||||||
|
safetyStopped = execution.safetyStopped
|
||||||
|
)
|
||||||
|
persisted = persisted.copy(
|
||||||
|
claim = claim.copy(task = result.task),
|
||||||
|
execution = updatedExecution
|
||||||
|
)
|
||||||
|
store.save(persisted)
|
||||||
|
if (result.cancelRequested) {
|
||||||
|
acknowledgeCancellation(session)
|
||||||
|
ExecutionSyncDecision.STOP
|
||||||
|
} else {
|
||||||
|
publish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = if (updatedExecution.safetyStopped) {
|
||||||
|
"后台状态已同步,任务仍保持安全停止"
|
||||||
|
} else {
|
||||||
|
"进度已同步"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (updatedExecution.safetyStopped) {
|
||||||
|
ExecutionSyncDecision.STOP
|
||||||
|
} else {
|
||||||
|
ExecutionSyncDecision.CONTINUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
publish(
|
||||||
|
backendOnline = false,
|
||||||
|
message = "后台暂时离线,将在授权截止前重试"
|
||||||
|
)
|
||||||
|
if (execution.safetyStopped || execution.isExpired()) {
|
||||||
|
ExecutionSyncDecision.STOP
|
||||||
|
} else {
|
||||||
|
ExecutionSyncDecision.CONTINUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun synchronizeNow(): Boolean =
|
||||||
|
synchronize(allowSafetyStoppedSync = true) ==
|
||||||
|
ExecutionSyncDecision.CONTINUE
|
||||||
|
|
||||||
|
fun currentProbeTask() =
|
||||||
|
persisted.claim?.let { claim ->
|
||||||
|
val task = claim.task
|
||||||
|
val image = claim.referenceImage
|
||||||
|
if (task != null && image != null) task.toProbeTask(image) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acknowledgeCancellation(session: ProcurementSession) {
|
||||||
|
val claim = requireNotNull(persisted.claim)
|
||||||
|
val task = requireNotNull(claim.task)
|
||||||
|
val execution = requireNotNull(persisted.execution)
|
||||||
|
val acknowledgementKey =
|
||||||
|
execution.cancelAcknowledgementKey ?: newOpaqueSecret().also {
|
||||||
|
persisted = persisted.copy(
|
||||||
|
execution = execution.copy(cancelAcknowledgementKey = it)
|
||||||
|
)
|
||||||
|
store.save(persisted)
|
||||||
|
}
|
||||||
|
api.acknowledgeCancellation(
|
||||||
|
session,
|
||||||
|
task,
|
||||||
|
requireNotNull(persisted.execution),
|
||||||
|
claim.token,
|
||||||
|
acknowledgementKey
|
||||||
|
)
|
||||||
|
clearCurrentTask()
|
||||||
|
saveAndPublish(
|
||||||
|
backendOnline = true,
|
||||||
|
message = "后台取消已确认,任务已停止"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadAndPersistReference(
|
||||||
|
session: ProcurementSession,
|
||||||
|
claim: ClaimContext,
|
||||||
|
task: RemotePurchaseTask
|
||||||
|
) {
|
||||||
|
val image = api.downloadReferenceImage(session, task, claim.token)
|
||||||
|
val relativePath = "$REFERENCE_DIRECTORY/reference-${task.id}.jpg"
|
||||||
|
val directory = File(appContext.filesDir, REFERENCE_DIRECTORY)
|
||||||
|
check(directory.exists() || directory.mkdirs()) { "无法创建参考图目录" }
|
||||||
|
val destination = File(appContext.filesDir, relativePath)
|
||||||
|
val temporary = File(directory, ".reference-${task.id}.tmp")
|
||||||
|
temporary.outputStream().use {
|
||||||
|
it.write(image.bytes)
|
||||||
|
it.fd.sync()
|
||||||
|
}
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(temporary.absolutePath, options)
|
||||||
|
check(options.outWidth > 0 && options.outHeight > 0) { "参考图无法解码" }
|
||||||
|
check(
|
||||||
|
options.outWidth <= MAX_REFERENCE_DIMENSION &&
|
||||||
|
options.outHeight <= MAX_REFERENCE_DIMENSION &&
|
||||||
|
options.outWidth.toLong() * options.outHeight <=
|
||||||
|
MAX_REFERENCE_PIXELS
|
||||||
|
) { "参考图尺寸超过安全限制" }
|
||||||
|
check(!destination.exists() || destination.delete()) { "无法替换参考图" }
|
||||||
|
check(temporary.renameTo(destination)) { "无法保存参考图" }
|
||||||
|
persisted = persisted.copy(
|
||||||
|
claim = claim.copy(
|
||||||
|
task = task,
|
||||||
|
referenceImage = ReferenceImageRecord(
|
||||||
|
relativePath = relativePath,
|
||||||
|
sizeBytes = image.bytes.size.toLong(),
|
||||||
|
sha256 = image.sha256
|
||||||
|
),
|
||||||
|
previewReadyAtEpochMillis = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.save(persisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearCurrentTask() {
|
||||||
|
persisted.claim?.referenceImage?.let {
|
||||||
|
File(appContext.filesDir, it.relativePath).delete()
|
||||||
|
}
|
||||||
|
persisted = persisted.copy(claim = null, execution = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireValidSession(): ProcurementSession {
|
||||||
|
val session = persisted.session
|
||||||
|
require(session != null && session.isValid()) { "采购员登录已过期,请重新登录" }
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> operation(block: suspend () -> T): T? = mutex.withLock {
|
||||||
|
if (storageFailure != null) {
|
||||||
|
_uiState.value = _uiState.value.copy(error = storageFailure)
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(busy = true, error = null)
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
publish(
|
||||||
|
backendOnline = if (error is IOException) false else _uiState.value.backendOnline,
|
||||||
|
error = userMessage(error)
|
||||||
|
)
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
_uiState.value = _uiState.value.copy(busy = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAndPublish(
|
||||||
|
backendOnline: Boolean?,
|
||||||
|
message: String? = null
|
||||||
|
) {
|
||||||
|
store.save(persisted)
|
||||||
|
publish(backendOnline = backendOnline, message = message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish(
|
||||||
|
backendOnline: Boolean?,
|
||||||
|
message: String? = null,
|
||||||
|
error: String? = null
|
||||||
|
) {
|
||||||
|
_uiState.value = buildUiState().copy(
|
||||||
|
busy = _uiState.value.busy,
|
||||||
|
backendOnline = backendOnline,
|
||||||
|
message = message,
|
||||||
|
error = error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildUiState(): ProcurementUiState {
|
||||||
|
val session = persisted.session
|
||||||
|
val claim = persisted.claim
|
||||||
|
val execution = persisted.execution
|
||||||
|
val phase = when {
|
||||||
|
execution != null &&
|
||||||
|
(execution.safetyStopped || execution.isExpired()) ->
|
||||||
|
ProcurementPhase.AUTHORIZATION_EXPIRED
|
||||||
|
execution != null -> ProcurementPhase.RUNNING
|
||||||
|
claim?.task != null -> ProcurementPhase.CLAIMED
|
||||||
|
session?.isValid() == true -> ProcurementPhase.IDLE
|
||||||
|
else -> ProcurementPhase.SIGNED_OUT
|
||||||
|
}
|
||||||
|
val imagePath = claim?.referenceImage?.let {
|
||||||
|
File(appContext.filesDir, it.relativePath)
|
||||||
|
.takeIf(File::isFile)
|
||||||
|
?.absolutePath
|
||||||
|
}
|
||||||
|
return ProcurementUiState(
|
||||||
|
phase = phase,
|
||||||
|
profile = ConnectionProfile(
|
||||||
|
backendUrl = session?.backendUrl ?: "http://127.0.0.1:8080",
|
||||||
|
username = session?.username.orEmpty(),
|
||||||
|
deviceId = session?.deviceId.orEmpty(),
|
||||||
|
hasDeviceToken = !persisted.deviceToken.isNullOrBlank()
|
||||||
|
),
|
||||||
|
task = claim?.task,
|
||||||
|
referenceImagePath = imagePath,
|
||||||
|
execution = execution,
|
||||||
|
authenticationRequired = session?.isValid() != true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DeviceReadinessSnapshot.toHeartbeatInput(
|
||||||
|
activeTaskId: String?
|
||||||
|
): DeviceHeartbeatInput =
|
||||||
|
DeviceHeartbeatInput(
|
||||||
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
|
androidVersion = androidVersion,
|
||||||
|
pddVersion = pinduoduo.versionName
|
||||||
|
?: pinduoduo.versionCode?.toString()
|
||||||
|
?: "",
|
||||||
|
accessibilityEnabled = accessibilityEnabled,
|
||||||
|
pddInstalled = pinduoduo.installed,
|
||||||
|
activeTaskId = activeTaskId
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun userMessage(error: Exception): String =
|
||||||
|
when (error) {
|
||||||
|
is ProcurementApiException -> error.message ?: "后台请求失败"
|
||||||
|
is IOException -> "无法连接管理后台"
|
||||||
|
is IllegalArgumentException,
|
||||||
|
is IllegalStateException -> error.message ?: "本地任务状态无效"
|
||||||
|
else -> "任务操作失败"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newOpaqueSecret(): String {
|
||||||
|
val bytes = ByteArray(32)
|
||||||
|
SECURE_RANDOM.nextBytes(bytes)
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val REFERENCE_DIRECTORY = "procurement"
|
||||||
|
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
|
||||||
|
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
|
||||||
|
private const val MAX_REFERENCE_DIMENSION = 4_096
|
||||||
|
private const val MAX_REFERENCE_PIXELS = 20_000_000L
|
||||||
|
private val SECURE_RANDOM = SecureRandom()
|
||||||
|
|
||||||
|
fun create(context: Context): ProcurementRepository =
|
||||||
|
try {
|
||||||
|
ProcurementRepository(
|
||||||
|
context,
|
||||||
|
ProcurementSecureStore(context),
|
||||||
|
ProcurementApiClient()
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ProcurementRepository(
|
||||||
|
context,
|
||||||
|
UnavailableProcurementStateStore,
|
||||||
|
ProcurementApiClient(),
|
||||||
|
storageFailure = "设备安全存储不可用,后台采购功能已停用"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object UnavailableProcurementStateStore : ProcurementStateStore {
|
||||||
|
override fun load() = PersistedProcurementState()
|
||||||
|
|
||||||
|
override fun save(state: PersistedProcurementState) {
|
||||||
|
throw IllegalStateException("设备安全存储不可用")
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
interface ProcurementStateStore {
|
||||||
|
fun load(): PersistedProcurementState
|
||||||
|
fun save(state: PersistedProcurementState)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProcurementSecureStore(context: Context) : ProcurementStateStore {
|
||||||
|
private val preferences = EncryptedSharedPreferences.create(
|
||||||
|
context.applicationContext,
|
||||||
|
FILE_NAME,
|
||||||
|
MasterKey.Builder(context.applicationContext)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build(),
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun load(): PersistedProcurementState {
|
||||||
|
val raw = preferences.getString(STATE_KEY, null) ?: return PersistedProcurementState()
|
||||||
|
return runCatching { decodeState(JSONObject(raw)) }.getOrElse {
|
||||||
|
throw IllegalStateException("加密任务状态损坏,已停止恢复", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(state: PersistedProcurementState) {
|
||||||
|
check(
|
||||||
|
preferences.edit()
|
||||||
|
.putString(STATE_KEY, encodeState(state).toString())
|
||||||
|
.commit()
|
||||||
|
) { "无法持久化加密任务状态" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeState(state: PersistedProcurementState): JSONObject =
|
||||||
|
JSONObject().apply {
|
||||||
|
putNullable("device_token", state.deviceToken)
|
||||||
|
state.session?.let { session ->
|
||||||
|
put(
|
||||||
|
"session",
|
||||||
|
JSONObject().apply {
|
||||||
|
put("backend_url", session.backendUrl)
|
||||||
|
put("username", session.username)
|
||||||
|
put("device_id", session.deviceId)
|
||||||
|
put("access_token", session.accessToken)
|
||||||
|
put("expires_at_ms", session.expiresAtEpochMillis)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.claim?.let { claim ->
|
||||||
|
put(
|
||||||
|
"claim",
|
||||||
|
JSONObject().apply {
|
||||||
|
put("token", claim.token)
|
||||||
|
put("idempotency_key", claim.idempotencyKey)
|
||||||
|
putNullable("start_idempotency_key", claim.startIdempotencyKey)
|
||||||
|
putNullable("release_idempotency_key", claim.releaseIdempotencyKey)
|
||||||
|
claim.previewReadyAtEpochMillis?.let {
|
||||||
|
put("preview_ready_at_ms", it)
|
||||||
|
}
|
||||||
|
claim.task?.let { put("task", encodeTask(it)) }
|
||||||
|
claim.referenceImage?.let { image ->
|
||||||
|
put(
|
||||||
|
"reference_image",
|
||||||
|
JSONObject().apply {
|
||||||
|
put("relative_path", image.relativePath)
|
||||||
|
put("size_bytes", image.sizeBytes)
|
||||||
|
put("sha256", image.sha256)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.execution?.let { execution ->
|
||||||
|
put(
|
||||||
|
"execution",
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", execution.id)
|
||||||
|
put("current_step", execution.currentStep)
|
||||||
|
put("expires_at", execution.expiresAt)
|
||||||
|
put("server_clock_offset_ms", execution.serverClockOffsetMillis)
|
||||||
|
put("safety_stopped", execution.safetyStopped)
|
||||||
|
putNullable(
|
||||||
|
"cancel_acknowledgement_key",
|
||||||
|
execution.cancelAcknowledgementKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeState(json: JSONObject): PersistedProcurementState =
|
||||||
|
PersistedProcurementState(
|
||||||
|
session = json.optionalObject("session")?.let {
|
||||||
|
ProcurementSession(
|
||||||
|
backendUrl = it.getString("backend_url"),
|
||||||
|
username = it.getString("username"),
|
||||||
|
deviceId = it.getString("device_id"),
|
||||||
|
accessToken = it.getString("access_token"),
|
||||||
|
expiresAtEpochMillis = it.getLong("expires_at_ms")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deviceToken = json.optionalString("device_token"),
|
||||||
|
claim = json.optionalObject("claim")?.let { claim ->
|
||||||
|
ClaimContext(
|
||||||
|
token = claim.getString("token"),
|
||||||
|
idempotencyKey = claim.getString("idempotency_key"),
|
||||||
|
task = claim.optionalObject("task")?.let(::decodeTask),
|
||||||
|
referenceImage = claim.optionalObject("reference_image")?.let { image ->
|
||||||
|
ReferenceImageRecord(
|
||||||
|
relativePath = image.getString("relative_path"),
|
||||||
|
sizeBytes = image.getLong("size_bytes"),
|
||||||
|
sha256 = image.getString("sha256")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
previewReadyAtEpochMillis =
|
||||||
|
if (claim.has("preview_ready_at_ms")) {
|
||||||
|
claim.getLong("preview_ready_at_ms")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
startIdempotencyKey = claim.optionalString("start_idempotency_key"),
|
||||||
|
releaseIdempotencyKey =
|
||||||
|
claim.optionalString("release_idempotency_key")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
execution = json.optionalObject("execution")?.let {
|
||||||
|
RunningExecution(
|
||||||
|
id = it.getString("id"),
|
||||||
|
currentStep = it.getString("current_step"),
|
||||||
|
expiresAt = it.getString("expires_at"),
|
||||||
|
serverClockOffsetMillis = it.getLong("server_clock_offset_ms"),
|
||||||
|
safetyStopped = it.optBoolean("safety_stopped", false),
|
||||||
|
cancelAcknowledgementKey =
|
||||||
|
it.optionalString("cancel_acknowledgement_key")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun encodeTask(task: RemotePurchaseTask): JSONObject =
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", task.id)
|
||||||
|
put("status", task.status)
|
||||||
|
put("version", task.version)
|
||||||
|
put("claim_generation", task.claimGeneration)
|
||||||
|
putNullable("claim_expires_at", task.claimExpiresAt)
|
||||||
|
put("title", task.title)
|
||||||
|
put("description", task.description)
|
||||||
|
put("sku", task.sku)
|
||||||
|
put("reference_image_url", task.referenceImageUrl)
|
||||||
|
put("quantity", task.quantity)
|
||||||
|
putNullable("max_budget", task.maxBudget)
|
||||||
|
put("currency", task.currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeTask(json: JSONObject): RemotePurchaseTask =
|
||||||
|
RemotePurchaseTask(
|
||||||
|
id = json.getString("id"),
|
||||||
|
status = json.getString("status"),
|
||||||
|
version = json.getLong("version"),
|
||||||
|
claimGeneration = json.getLong("claim_generation"),
|
||||||
|
claimExpiresAt = json.optionalString("claim_expires_at"),
|
||||||
|
title = json.getString("title"),
|
||||||
|
description = json.optString("description"),
|
||||||
|
sku = json.getString("sku"),
|
||||||
|
referenceImageUrl = json.getString("reference_image_url"),
|
||||||
|
quantity = json.getInt("quantity"),
|
||||||
|
maxBudget = json.optionalString("max_budget"),
|
||||||
|
currency = json.optString("currency", "CNY")
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.putNullable(name: String, value: String?) {
|
||||||
|
put(name, value ?: JSONObject.NULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.optionalString(name: String): String? =
|
||||||
|
if (has(name) && !isNull(name)) getString(name) else null
|
||||||
|
|
||||||
|
private fun JSONObject.optionalObject(name: String): JSONObject? =
|
||||||
|
if (has(name) && !isNull(name)) getJSONObject(name) else null
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FILE_NAME = "procurement_secure_state"
|
||||||
|
const val STATE_KEY = "state_v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package com.roubao.autopilot.ui.screens
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.roubao.autopilot.procurement.LoginInput
|
||||||
|
import com.roubao.autopilot.procurement.ProcurementPhase
|
||||||
|
import com.roubao.autopilot.procurement.ProcurementUiState
|
||||||
|
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
|
||||||
|
import com.roubao.autopilot.ui.theme.BaoziTheme
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProcurementScreen(
|
||||||
|
state: ProcurementUiState,
|
||||||
|
readiness: DeviceReadinessSnapshot,
|
||||||
|
onLogin: (LoginInput) -> Unit,
|
||||||
|
onClaim: () -> Unit,
|
||||||
|
onStart: () -> Unit,
|
||||||
|
onRelease: () -> Unit,
|
||||||
|
onSync: () -> Unit
|
||||||
|
) {
|
||||||
|
val colors = BaoziTheme.colors
|
||||||
|
var confirmStart by remember(state.task?.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (confirmStart) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmStart = false },
|
||||||
|
title = { Text("确认开始采购任务?") },
|
||||||
|
text = { Text("开始后任务将由本设备持有,直到完成、取消或人工结束。") },
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmStart = false }) {
|
||||||
|
Text("返回核对")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
confirmStart = false
|
||||||
|
onStart()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("确认开始")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(colors.background),
|
||||||
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "采购任务",
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = colors.textPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = phaseLabel(state),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = phaseColor(state)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.busy) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { error ->
|
||||||
|
item {
|
||||||
|
Text(error, color = colors.error, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.message?.let { message ->
|
||||||
|
item {
|
||||||
|
Text(message, color = colors.textSecondary, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state.phase) {
|
||||||
|
ProcurementPhase.SIGNED_OUT -> item {
|
||||||
|
LoginSection(
|
||||||
|
state = state,
|
||||||
|
onLogin = onLogin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProcurementPhase.IDLE -> item {
|
||||||
|
IdleSection(
|
||||||
|
state = state,
|
||||||
|
readiness = readiness,
|
||||||
|
onClaim = onClaim
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProcurementPhase.CLAIMED -> {
|
||||||
|
if (state.authenticationRequired) {
|
||||||
|
item { LoginSection(state = state, onLogin = onLogin) }
|
||||||
|
}
|
||||||
|
item { TaskDetails(state) }
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onRelease,
|
||||||
|
enabled = !state.busy,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("退回")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { confirmStart = true },
|
||||||
|
enabled = !state.busy &&
|
||||||
|
state.referenceImagePath != null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("开始")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProcurementPhase.RUNNING,
|
||||||
|
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
|
||||||
|
if (state.authenticationRequired) {
|
||||||
|
item { LoginSection(state = state, onLogin = onLogin) }
|
||||||
|
}
|
||||||
|
item { TaskDetails(state) }
|
||||||
|
item {
|
||||||
|
ExecutionDetails(state)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = onSync,
|
||||||
|
enabled = !state.busy,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("立即同步")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoginSection(
|
||||||
|
state: ProcurementUiState,
|
||||||
|
onLogin: (LoginInput) -> Unit
|
||||||
|
) {
|
||||||
|
val colors = BaoziTheme.colors
|
||||||
|
var backendUrl by remember(state.profile.backendUrl) {
|
||||||
|
mutableStateOf(state.profile.backendUrl)
|
||||||
|
}
|
||||||
|
var username by remember(state.profile.username) {
|
||||||
|
mutableStateOf(state.profile.username)
|
||||||
|
}
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var deviceId by remember(state.profile.deviceId) {
|
||||||
|
mutableStateOf(state.profile.deviceId)
|
||||||
|
}
|
||||||
|
var deviceToken by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"采购员登录",
|
||||||
|
color = colors.textPrimary,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = backendUrl,
|
||||||
|
onValueChange = { backendUrl = it },
|
||||||
|
label = { Text("后台地址") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("采购员账号") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("密码") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = deviceId,
|
||||||
|
onValueChange = { deviceId = it },
|
||||||
|
label = { Text("设备 ID") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = deviceToken,
|
||||||
|
onValueChange = { deviceToken = it },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
if (state.profile.hasDeviceToken) {
|
||||||
|
"设备密钥(已安全保存)"
|
||||||
|
} else {
|
||||||
|
"设备密钥"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onLogin(
|
||||||
|
LoginInput(
|
||||||
|
backendUrl = backendUrl,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
deviceId = deviceId,
|
||||||
|
deviceToken = deviceToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
password = ""
|
||||||
|
deviceToken = ""
|
||||||
|
},
|
||||||
|
enabled = !state.busy,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Lock, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdleSection(
|
||||||
|
state: ProcurementUiState,
|
||||||
|
readiness: DeviceReadinessSnapshot,
|
||||||
|
onClaim: () -> Unit
|
||||||
|
) {
|
||||||
|
val colors = BaoziTheme.colors
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
DetailRow("采购员", state.profile.username)
|
||||||
|
DetailRow("设备", state.profile.deviceId)
|
||||||
|
DetailRow(
|
||||||
|
"设备状态",
|
||||||
|
if (readiness.canStartProbe) "已就绪" else "存在阻塞项"
|
||||||
|
)
|
||||||
|
Divider(color = colors.surfaceVariant)
|
||||||
|
Button(
|
||||||
|
onClick = onClaim,
|
||||||
|
enabled = !state.busy && readiness.canStartProbe,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.ShoppingCart, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("获取任务")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TaskDetails(state: ProcurementUiState) {
|
||||||
|
val colors = BaoziTheme.colors
|
||||||
|
val task = state.task ?: return
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
color = colors.textPrimary,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
state.referenceImagePath?.let { path ->
|
||||||
|
val bitmap = remember(path) {
|
||||||
|
BitmapFactory.decodeFile(path)?.asImageBitmap()
|
||||||
|
}
|
||||||
|
bitmap?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it,
|
||||||
|
contentDescription = "任务参考图",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(220.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DetailRow("SKU", task.sku)
|
||||||
|
DetailRow("数量", task.quantity.toString())
|
||||||
|
task.maxBudget?.let {
|
||||||
|
DetailRow("最高预算", "$it ${task.currency}")
|
||||||
|
}
|
||||||
|
if (task.description.isNotBlank()) {
|
||||||
|
DetailRow("描述", task.description)
|
||||||
|
}
|
||||||
|
DetailRow("任务 ID", task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExecutionDetails(state: ProcurementUiState) {
|
||||||
|
val colors = BaoziTheme.colors
|
||||||
|
val execution = state.execution ?: return
|
||||||
|
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
|
LaunchedEffect(execution.expiresAt) {
|
||||||
|
while (true) {
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
delay(1_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val remainingSeconds = runCatching {
|
||||||
|
val deadline = Instant.parse(execution.expiresAt).toEpochMilli() -
|
||||||
|
execution.serverClockOffsetMillis
|
||||||
|
((deadline - now).coerceAtLeast(0L) / 1_000L)
|
||||||
|
}.getOrDefault(0)
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Divider(color = colors.surfaceVariant)
|
||||||
|
DetailRow("当前步骤", execution.currentStep)
|
||||||
|
DetailRow(
|
||||||
|
"后台连接",
|
||||||
|
when (state.backendOnline) {
|
||||||
|
true -> "在线"
|
||||||
|
false -> "离线"
|
||||||
|
null -> "检查中"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DetailRow(
|
||||||
|
"剩余授权",
|
||||||
|
if (state.phase == ProcurementPhase.AUTHORIZATION_EXPIRED) {
|
||||||
|
"已到期,自动化已停止"
|
||||||
|
} else {
|
||||||
|
"%02d:%02d".format(
|
||||||
|
remainingSeconds / 60,
|
||||||
|
remainingSeconds % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"订单提交保持禁用",
|
||||||
|
color = colors.warning,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetailRow(label: String, value: String) {
|
||||||
|
val colors = BaoziTheme.colors
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = colors.textSecondary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.weight(0.28f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
color = colors.textPrimary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.weight(0.72f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun phaseColor(state: ProcurementUiState) =
|
||||||
|
when (state.phase) {
|
||||||
|
ProcurementPhase.AUTHORIZATION_EXPIRED -> BaoziTheme.colors.error
|
||||||
|
ProcurementPhase.RUNNING -> BaoziTheme.colors.success
|
||||||
|
else -> BaoziTheme.colors.textSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun phaseLabel(state: ProcurementUiState): String =
|
||||||
|
when (state.phase) {
|
||||||
|
ProcurementPhase.SIGNED_OUT -> "未登录管理后台"
|
||||||
|
ProcurementPhase.IDLE -> "等待领取"
|
||||||
|
ProcurementPhase.CLAIMED -> "已领取,等待开始"
|
||||||
|
ProcurementPhase.RUNNING -> "受控流程运行中"
|
||||||
|
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BackendEndpointPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun releaseAcceptsOnlyHttps() {
|
||||||
|
assertEquals(
|
||||||
|
"https://admin.example.com:8443",
|
||||||
|
BackendEndpointPolicy.normalize(
|
||||||
|
"https://admin.example.com:8443/",
|
||||||
|
debugBuild = false
|
||||||
|
).getOrThrow()
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
BackendEndpointPolicy.normalize(
|
||||||
|
"http://admin.example.com:8080",
|
||||||
|
debugBuild = false
|
||||||
|
).isSuccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun debugHttpIsLimitedToLoopback() {
|
||||||
|
assertTrue(
|
||||||
|
BackendEndpointPolicy.normalize(
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
debugBuild = true
|
||||||
|
).isSuccess
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
BackendEndpointPolicy.normalize(
|
||||||
|
"http://192.168.1.20:8080",
|
||||||
|
debugBuild = true
|
||||||
|
).isSuccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsEmbeddedCredentialsAndPaths() {
|
||||||
|
assertFalse(
|
||||||
|
BackendEndpointPolicy.normalize(
|
||||||
|
"https://user:pass@example.com/api",
|
||||||
|
debugBuild = false
|
||||||
|
).isSuccess
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ExecutionAuthorizationTest {
|
||||||
|
@Test
|
||||||
|
fun usesServerClockOffsetForOfflineDeadline() {
|
||||||
|
val expiry = "2026-07-27T01:30:00Z"
|
||||||
|
val localAtServerOneOClock = 1_000L
|
||||||
|
val offset = ExecutionAuthorization.serverClockOffset(
|
||||||
|
"2026-07-27T01:00:00Z",
|
||||||
|
localAtServerOneOClock
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
ExecutionAuthorization.isExpired(
|
||||||
|
expiry,
|
||||||
|
offset,
|
||||||
|
localAtServerOneOClock + 29 * 60_000L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
ExecutionAuthorization.isExpired(
|
||||||
|
expiry,
|
||||||
|
offset,
|
||||||
|
localAtServerOneOClock + 30 * 60_000L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun malformedExpiryFailsClosed() {
|
||||||
|
assertTrue(
|
||||||
|
ExecutionAuthorization.isExpired(
|
||||||
|
"not-a-time",
|
||||||
|
serverClockOffsetMillis = 0,
|
||||||
|
nowEpochMillis = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startRequiresAStablePreviewWindow() {
|
||||||
|
assertFalse(
|
||||||
|
StartAuthorization.isPreviewConfirmed(
|
||||||
|
previewReadyAtEpochMillis = 10_000L,
|
||||||
|
nowEpochMillis = 11_999L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
StartAuthorization.isPreviewConfirmed(
|
||||||
|
previewReadyAtEpochMillis = 10_000L,
|
||||||
|
nowEpochMillis = 12_000L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
package com.roubao.autopilot.procurement
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProcurementApiClientTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var api: ProcurementApiClient
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
api = ProcurementApiClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginClaimStartAndReferenceImageFollowDeviceContract() = runBlocking {
|
||||||
|
server.enqueue(
|
||||||
|
jsonResponse(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"access_token":"access-token-value",
|
||||||
|
"expires_in":3600,
|
||||||
|
"device":{"id":"device-id","enabled":true}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val baseUrl = server.url("/").toString().trimEnd('/')
|
||||||
|
val session = api.login(
|
||||||
|
baseUrl,
|
||||||
|
LoginInput(
|
||||||
|
backendUrl = baseUrl,
|
||||||
|
username = "buyer01",
|
||||||
|
password = "private-password",
|
||||||
|
deviceId = "device-id",
|
||||||
|
deviceToken = "private-device-token"
|
||||||
|
),
|
||||||
|
appVersion = "1.4.2",
|
||||||
|
androidVersion = "16"
|
||||||
|
)
|
||||||
|
val loginRequest = server.takeRequest()
|
||||||
|
assertEquals("/api/v1/auth/token", loginRequest.path)
|
||||||
|
assertTrue(loginRequest.body.readUtf8().contains("\"username\":\"buyer01\""))
|
||||||
|
|
||||||
|
server.enqueue(
|
||||||
|
jsonResponse(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"task":${taskJson("CLAIMED", 2)},
|
||||||
|
"replayed":false,
|
||||||
|
"server_time":"2026-07-27T01:00:00Z"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val claim = api.claimNext(
|
||||||
|
session,
|
||||||
|
"device-id",
|
||||||
|
"claim-token-value",
|
||||||
|
"claim-idempotency-key"
|
||||||
|
)
|
||||||
|
val claimRequest = server.takeRequest()
|
||||||
|
assertEquals("/api/v1/tasks/claim-next", claimRequest.path)
|
||||||
|
assertEquals("claim-token-value", claimRequest.getHeader("X-Claim-Token"))
|
||||||
|
assertEquals(
|
||||||
|
"claim-idempotency-key",
|
||||||
|
claimRequest.getHeader("Idempotency-Key")
|
||||||
|
)
|
||||||
|
val task = assertNotNull(claim.task).let { claim.task!! }
|
||||||
|
assertEquals("测试商品", task.title)
|
||||||
|
assertEquals(2, task.quantity)
|
||||||
|
|
||||||
|
val jpeg = byteArrayOf(
|
||||||
|
0xFF.toByte(),
|
||||||
|
0xD8.toByte(),
|
||||||
|
0xFF.toByte(),
|
||||||
|
0xD9.toByte()
|
||||||
|
)
|
||||||
|
val hash = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(jpeg)
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "image/jpeg")
|
||||||
|
.setHeader("ETag", "\"$hash\"")
|
||||||
|
.setBody(Buffer().write(jpeg))
|
||||||
|
)
|
||||||
|
val image = api.downloadReferenceImage(
|
||||||
|
session,
|
||||||
|
task,
|
||||||
|
"claim-token-value"
|
||||||
|
)
|
||||||
|
val imageRequest = server.takeRequest()
|
||||||
|
assertTrue(imageRequest.path!!.startsWith("/api/v1/tasks/task-id/reference-image"))
|
||||||
|
assertEquals(hash, image.sha256)
|
||||||
|
|
||||||
|
server.enqueue(
|
||||||
|
jsonResponse(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"task":${taskJson("RUNNING", 3)},
|
||||||
|
"execution":{
|
||||||
|
"id":"execution-id",
|
||||||
|
"current_step":"PREFLIGHT",
|
||||||
|
"order_submitted":false,
|
||||||
|
"execution_expires_at":"2026-07-27T01:30:00Z"
|
||||||
|
},
|
||||||
|
"replayed":false,
|
||||||
|
"server_time":"2026-07-27T01:00:00Z"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val started = api.start(
|
||||||
|
session,
|
||||||
|
task,
|
||||||
|
"claim-token-value",
|
||||||
|
"start-idempotency-key"
|
||||||
|
)
|
||||||
|
assertEquals("execution-id", started.executionId)
|
||||||
|
assertEquals("2026-07-27T01:30:00Z", started.executionExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noTaskIsAStableEmptyResult() = runBlocking {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(204))
|
||||||
|
val result = api.claimNext(
|
||||||
|
session(),
|
||||||
|
"device-id",
|
||||||
|
"claim-token-value",
|
||||||
|
"claim-idempotency-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(null, result.task)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun referenceImageMustStayOnTheBackendOrigin() = runBlocking {
|
||||||
|
val failure = runCatching {
|
||||||
|
api.downloadReferenceImage(
|
||||||
|
session(),
|
||||||
|
task(
|
||||||
|
referenceImageUrl =
|
||||||
|
"https://credentials.example.invalid/reference.jpg"
|
||||||
|
),
|
||||||
|
"claim-token-value"
|
||||||
|
)
|
||||||
|
}.exceptionOrNull()
|
||||||
|
|
||||||
|
assertTrue(failure is ProcurementApiException)
|
||||||
|
assertEquals(
|
||||||
|
"REFERENCE_IMAGE_ORIGIN_INVALID",
|
||||||
|
(failure as ProcurementApiException).code
|
||||||
|
)
|
||||||
|
assertEquals(0, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun session() = ProcurementSession(
|
||||||
|
backendUrl = server.url("/").toString().trimEnd('/'),
|
||||||
|
username = "buyer01",
|
||||||
|
deviceId = "device-id",
|
||||||
|
accessToken = "access-token-value",
|
||||||
|
expiresAtEpochMillis = Long.MAX_VALUE
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun taskJson(status: String, version: Int): String =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id":"task-id",
|
||||||
|
"status":"$status",
|
||||||
|
"version":$version,
|
||||||
|
"claim_generation":1,
|
||||||
|
"claim_expires_at":"2026-07-27T01:30:00Z",
|
||||||
|
"title":"测试商品",
|
||||||
|
"description":"测试描述",
|
||||||
|
"sku":"SKU-01",
|
||||||
|
"reference_image_url":"/api/v1/tasks/task-id/reference-image?claim_generation=1",
|
||||||
|
"quantity":2,
|
||||||
|
"max_budget":"20.00",
|
||||||
|
"currency":"CNY"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun task(referenceImageUrl: String) = RemotePurchaseTask(
|
||||||
|
id = "task-id",
|
||||||
|
status = "CLAIMED",
|
||||||
|
version = 2,
|
||||||
|
claimGeneration = 1,
|
||||||
|
claimExpiresAt = "2026-07-27T01:30:00Z",
|
||||||
|
title = "测试商品",
|
||||||
|
description = "测试描述",
|
||||||
|
sku = "SKU-01",
|
||||||
|
referenceImageUrl = referenceImageUrl,
|
||||||
|
quantity = 2,
|
||||||
|
maxBudget = "20.00",
|
||||||
|
currency = "CNY"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun jsonResponse(body: String) =
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setBody(body)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ start/运行续租/release 和取消安全确认。
|
|||||||
| `CMROUBAO_TLS_CERT_FILE` | 无 | TLS certificate;必须和 private key 同时设置 |
|
| `CMROUBAO_TLS_CERT_FILE` | 无 | TLS certificate;必须和 private key 同时设置 |
|
||||||
| `CMROUBAO_TLS_KEY_FILE` | 无 | TLS private key;非 loopback 监听必须设置 |
|
| `CMROUBAO_TLS_KEY_FILE` | 无 | TLS private key;非 loopback 监听必须设置 |
|
||||||
| `CMROUBAO_CLAIM_LEASE` | `10m` | CLAIMED 租约;允许 `1m` 至 `30m` |
|
| `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` |
|
| `CMROUBAO_READINESS_TTL` | `2m` | 设备就绪 heartbeat 新鲜度;允许 `30s` 至 `10m` |
|
||||||
|
|
||||||
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
|
不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。
|
||||||
@@ -43,6 +43,9 @@ $env:CMROUBAO_AUTH_PASSWORD = "至少 12 个 UTF-8 字节"
|
|||||||
go run ./cmd/authctl create-user ADMIN admin
|
go run ./cmd/authctl create-user ADMIN admin
|
||||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
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 create-device buyer-phone-01
|
||||||
# 发生人员离岗或设备风险时,现有凭证会随禁用立即失效
|
# 发生人员离岗或设备风险时,现有凭证会随禁用立即失效
|
||||||
go run ./cmd/authctl disable-user buyer01
|
go run ./cmd/authctl disable-user buyer01
|
||||||
@@ -78,6 +81,11 @@ Cookie 与 BUYER Bearer token 不能互换。两类登录在凭证校验前按
|
|||||||
和迁移都受当前用户、设备、claim generation、`X-Claim-Token` 与服务端租约约束;
|
和迁移都受当前用户、设备、claim generation、`X-Claim-Token` 与服务端租约约束;
|
||||||
原 claim token 只由 App 生成和保存,服务端数据库只存 SHA-256。
|
原 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,
|
默认 loopback 可使用 HTTP 开发。局域网监听必须同时设置 certificate/private key,
|
||||||
服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见
|
服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见
|
||||||
[`../docs/api.md`](../docs/api.md)。
|
[`../docs/api.md`](../docs/api.md)。
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
|
|||||||
router, err := buildRouter(ctx, config.Config{
|
router, err := buildRouter(ctx, config.Config{
|
||||||
AssetDirectory: filepath.Join(t.TempDir(), "assets"),
|
AssetDirectory: filepath.Join(t.TempDir(), "assets"),
|
||||||
ClaimLease: 10 * time.Minute,
|
ClaimLease: 10 * time.Minute,
|
||||||
RunningLease: 90 * time.Second,
|
RunningLease: 30 * time.Minute,
|
||||||
ReadinessTTL: 2 * time.Minute,
|
ReadinessTTL: 2 * time.Minute,
|
||||||
}, db)
|
}, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
defaultDatabasePath = "var/cmroubao.db"
|
defaultDatabasePath = "var/cmroubao.db"
|
||||||
defaultAssetDirectory = "var/assets"
|
defaultAssetDirectory = "var/assets"
|
||||||
defaultClaimLease = 10 * time.Minute
|
defaultClaimLease = 10 * time.Minute
|
||||||
defaultRunningLease = 90 * time.Second
|
defaultRunningLease = 30 * time.Minute
|
||||||
defaultReadinessTTL = 2 * time.Minute
|
defaultReadinessTTL = 2 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,8 +121,8 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
lookup,
|
lookup,
|
||||||
RunningLeaseEnvironment,
|
RunningLeaseEnvironment,
|
||||||
defaultRunningLease,
|
defaultRunningLease,
|
||||||
30*time.Second,
|
5*time.Minute,
|
||||||
10*time.Minute,
|
120*time.Minute,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
|
|||||||
t.Fatal("server safety limits must all be positive")
|
t.Fatal("server safety limits must all be positive")
|
||||||
}
|
}
|
||||||
if cfg.ClaimLease != 10*time.Minute ||
|
if cfg.ClaimLease != 10*time.Minute ||
|
||||||
cfg.RunningLease != 90*time.Second ||
|
cfg.RunningLease != 30*time.Minute ||
|
||||||
cfg.ReadinessTTL != 2*time.Minute {
|
cfg.ReadinessTTL != 2*time.Minute {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"lifecycle durations = %s / %s / %s",
|
"lifecycle durations = %s / %s / %s",
|
||||||
@@ -52,7 +52,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
TLSCertificateEnvironment: "tmp/server.crt",
|
TLSCertificateEnvironment: "tmp/server.crt",
|
||||||
TLSPrivateKeyEnvironment: "tmp/server.key",
|
TLSPrivateKeyEnvironment: "tmp/server.key",
|
||||||
ClaimLeaseEnvironment: "15m",
|
ClaimLeaseEnvironment: "15m",
|
||||||
RunningLeaseEnvironment: "2m",
|
RunningLeaseEnvironment: "45m",
|
||||||
ReadinessTTLEnvironment: "3m",
|
ReadinessTTLEnvironment: "3m",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if cfg.ClaimLease != 15*time.Minute ||
|
if cfg.ClaimLease != 15*time.Minute ||
|
||||||
cfg.RunningLease != 2*time.Minute ||
|
cfg.RunningLease != 45*time.Minute ||
|
||||||
cfg.ReadinessTTL != 3*time.Minute {
|
cfg.ReadinessTTL != 3*time.Minute {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"lifecycle durations = %s / %s / %s",
|
"lifecycle durations = %s / %s / %s",
|
||||||
@@ -174,10 +174,16 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
|||||||
ClaimLeaseEnvironment: "59s",
|
ClaimLeaseEnvironment: "59s",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "running lease below minimum",
|
||||||
|
values: map[string]string{
|
||||||
|
RunningLeaseEnvironment: "4m59s",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "running lease above maximum",
|
name: "running lease above maximum",
|
||||||
values: map[string]string{
|
values: map[string]string{
|
||||||
RunningLeaseEnvironment: "11m",
|
RunningLeaseEnvironment: "121m",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -498,13 +498,12 @@ func (s *Store) HeartbeatTask(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return usecase.TaskHeartbeatRepositoryResult{}, err
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
||||||
}
|
}
|
||||||
if err := validateClaim(
|
if err := validateClaimOwner(
|
||||||
task,
|
task,
|
||||||
request.UserID,
|
request.UserID,
|
||||||
request.DeviceID,
|
request.DeviceID,
|
||||||
request.ClaimGeneration,
|
request.ClaimGeneration,
|
||||||
request.ClaimTokenHash,
|
request.ClaimTokenHash,
|
||||||
request.Now,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return usecase.TaskHeartbeatRepositoryResult{}, err
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
||||||
}
|
}
|
||||||
@@ -512,6 +511,11 @@ func (s *Store) HeartbeatTask(
|
|||||||
return usecase.TaskHeartbeatRepositoryResult{},
|
return usecase.TaskHeartbeatRepositoryResult{},
|
||||||
usecase.ErrTaskStateConflict
|
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)
|
execution, err := getExecutionByID(ctx, tx, request.ExecutionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usecase.TaskHeartbeatRepositoryResult{}, err
|
return usecase.TaskHeartbeatRepositoryResult{}, err
|
||||||
@@ -525,7 +529,8 @@ func (s *Store) HeartbeatTask(
|
|||||||
usecase.ErrExecutionMismatch
|
usecase.ErrExecutionMismatch
|
||||||
}
|
}
|
||||||
expiresAt := *task.ClaimExpiresAt
|
expiresAt := *task.ClaimExpiresAt
|
||||||
if request.MinimumExpiry.After(expiresAt) {
|
if !expired &&
|
||||||
|
request.MinimumExpiry.After(expiresAt) {
|
||||||
expiresAt = request.MinimumExpiry
|
expiresAt = request.MinimumExpiry
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(
|
result, err := tx.ExecContext(
|
||||||
@@ -561,8 +566,7 @@ func (s *Store) HeartbeatTask(
|
|||||||
AND claimed_by_user_id = ?
|
AND claimed_by_user_id = ?
|
||||||
AND claimed_by_device_id = ?
|
AND claimed_by_device_id = ?
|
||||||
AND claim_generation = ?
|
AND claim_generation = ?
|
||||||
AND claim_token_hash = ?
|
AND claim_token_hash = ?`,
|
||||||
AND claim_expires_at > ?`,
|
|
||||||
formatTimestamp(expiresAt),
|
formatTimestamp(expiresAt),
|
||||||
formatTimestamp(request.Now),
|
formatTimestamp(request.Now),
|
||||||
request.TaskID,
|
request.TaskID,
|
||||||
@@ -570,7 +574,6 @@ func (s *Store) HeartbeatTask(
|
|||||||
request.DeviceID,
|
request.DeviceID,
|
||||||
request.ClaimGeneration,
|
request.ClaimGeneration,
|
||||||
request.ClaimTokenHash,
|
request.ClaimTokenHash,
|
||||||
formatTimestamp(request.Now),
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
return usecase.TaskHeartbeatRepositoryResult{}, repositoryFailure(err)
|
||||||
@@ -581,7 +584,7 @@ func (s *Store) HeartbeatTask(
|
|||||||
}
|
}
|
||||||
if affected != 1 {
|
if affected != 1 {
|
||||||
return usecase.TaskHeartbeatRepositoryResult{},
|
return usecase.TaskHeartbeatRepositoryResult{},
|
||||||
usecase.ErrClaimExpired
|
usecase.ErrTaskVersionConflict
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(
|
_, err = tx.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -797,13 +800,12 @@ func (s *Store) AcknowledgeTaskCancellation(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PurchaseTask{}, false, err
|
return domain.PurchaseTask{}, false, err
|
||||||
}
|
}
|
||||||
if err := validateClaim(
|
if err := validateClaimOwner(
|
||||||
task,
|
task,
|
||||||
request.UserID,
|
request.UserID,
|
||||||
request.DeviceID,
|
request.DeviceID,
|
||||||
request.ClaimGeneration,
|
request.ClaimGeneration,
|
||||||
request.ClaimTokenHash,
|
request.ClaimTokenHash,
|
||||||
request.Now,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return domain.PurchaseTask{}, false, err
|
return domain.PurchaseTask{}, false, err
|
||||||
}
|
}
|
||||||
@@ -846,8 +848,7 @@ func (s *Store) AcknowledgeTaskCancellation(
|
|||||||
AND claimed_by_user_id = ?
|
AND claimed_by_user_id = ?
|
||||||
AND claimed_by_device_id = ?
|
AND claimed_by_device_id = ?
|
||||||
AND claim_generation = ?
|
AND claim_generation = ?
|
||||||
AND claim_token_hash = ?
|
AND claim_token_hash = ?`,
|
||||||
AND claim_expires_at > ?`,
|
|
||||||
formatTimestamp(request.Now),
|
formatTimestamp(request.Now),
|
||||||
formatTimestamp(request.Now),
|
formatTimestamp(request.Now),
|
||||||
request.TaskID,
|
request.TaskID,
|
||||||
@@ -856,7 +857,6 @@ func (s *Store) AcknowledgeTaskCancellation(
|
|||||||
request.DeviceID,
|
request.DeviceID,
|
||||||
request.ClaimGeneration,
|
request.ClaimGeneration,
|
||||||
request.ClaimTokenHash,
|
request.ClaimTokenHash,
|
||||||
formatTimestamp(request.Now),
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
@@ -1041,6 +1041,28 @@ func validateClaim(
|
|||||||
generation int64,
|
generation int64,
|
||||||
tokenHash string,
|
tokenHash string,
|
||||||
now time.Time,
|
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 {
|
) error {
|
||||||
if task.ClaimedByUserID == nil ||
|
if task.ClaimedByUserID == nil ||
|
||||||
*task.ClaimedByUserID != userID ||
|
*task.ClaimedByUserID != userID ||
|
||||||
@@ -1051,7 +1073,7 @@ func validateClaim(
|
|||||||
*task.ClaimTokenHash != tokenHash {
|
*task.ClaimTokenHash != tokenHash {
|
||||||
return usecase.ErrClaimInvalid
|
return usecase.ErrClaimInvalid
|
||||||
}
|
}
|
||||||
if task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(now) {
|
if task.ClaimExpiresAt == nil {
|
||||||
return usecase.ErrClaimExpired
|
return usecase.ErrClaimExpired
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -748,6 +748,29 @@ func TestLifecycleRepositoryHeartbeatExtendsLeaseWithoutEvent(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assertLifecycleError(t, err, usecase.ErrClaimExpired)
|
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(
|
func TestLifecycleRepositoryReleaseIsIdempotentAndInvalidatesClaim(
|
||||||
@@ -870,6 +893,7 @@ func TestLifecycleRepositoryCancelAcknowledgementEndsExecution(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StartTask() error = %v", err)
|
t.Fatalf("StartTask() error = %v", err)
|
||||||
}
|
}
|
||||||
|
expiredAt := *started.Task.ClaimExpiresAt
|
||||||
cancelAt := startRequest.Now.Add(10 * time.Second)
|
cancelAt := startRequest.Now.Add(10 * time.Second)
|
||||||
adminID := fixture.adminUserID
|
adminID := fixture.adminUserID
|
||||||
cancelRequested, err := fixture.store.CancelTask(
|
cancelRequested, err := fixture.store.CancelTask(
|
||||||
@@ -940,16 +964,16 @@ func TestLifecycleRepositoryCancelAcknowledgementEndsExecution(
|
|||||||
ExecutionID: started.Execution.ID,
|
ExecutionID: started.Execution.ID,
|
||||||
ClaimGeneration: claimed.ClaimGeneration,
|
ClaimGeneration: claimed.ClaimGeneration,
|
||||||
ClaimTokenHash: tokenHash,
|
ClaimTokenHash: tokenHash,
|
||||||
Step: "STOPPING",
|
Step: "SAFE_STOPPED",
|
||||||
Now: cancelAt.Add(time.Second),
|
Now: expiredAt,
|
||||||
MinimumExpiry: cancelAt.Add(91 * time.Second),
|
MinimumExpiry: expiredAt.Add(90 * time.Second),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil || !heartbeat.CancelRequested {
|
if err != nil || !heartbeat.CancelRequested {
|
||||||
t.Fatalf("cancel heartbeat = %+v, error = %v", heartbeat, err)
|
t.Fatalf("cancel heartbeat = %+v, error = %v", heartbeat, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ackAt := cancelAt.Add(2 * time.Second)
|
ackAt := expiredAt.Add(time.Second)
|
||||||
request := usecase.CancelAcknowledgementRepositoryRequest{
|
request := usecase.CancelAcknowledgementRepositoryRequest{
|
||||||
UserID: fixture.buyerOneID,
|
UserID: fixture.buyerOneID,
|
||||||
DeviceID: fixture.deviceOneID,
|
DeviceID: fixture.deviceOneID,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cmroubao/backend-api/internal/domain"
|
"cmroubao/backend-api/internal/domain"
|
||||||
"cmroubao/backend-api/internal/transport/authcommon"
|
"cmroubao/backend-api/internal/transport/authcommon"
|
||||||
@@ -235,8 +236,11 @@ func (handler *deviceHandlers) startTask(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Header("Cache-Control", "no-store")
|
ctx.Header("Cache-Control", "no-store")
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
"task": deviceTaskResponse(result.Task),
|
"task": deviceTaskResponse(result.Task),
|
||||||
"execution": executionResponse(result.Execution),
|
"execution": deviceExecutionResponse(
|
||||||
|
result.Execution,
|
||||||
|
result.Task.ClaimExpiresAt,
|
||||||
|
),
|
||||||
"replayed": result.Replayed,
|
"replayed": result.Replayed,
|
||||||
"server_time": formatTime(result.ServerTime),
|
"server_time": formatTime(result.ServerTime),
|
||||||
})
|
})
|
||||||
@@ -275,8 +279,11 @@ func (handler *deviceHandlers) heartbeatTask(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Header("Cache-Control", "no-store")
|
ctx.Header("Cache-Control", "no-store")
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
"task": deviceTaskResponse(result.Task),
|
"task": deviceTaskResponse(result.Task),
|
||||||
"execution": executionResponse(result.Execution),
|
"execution": deviceExecutionResponse(
|
||||||
|
result.Execution,
|
||||||
|
result.Task.ClaimExpiresAt,
|
||||||
|
),
|
||||||
"cancel_requested": result.CancelRequested,
|
"cancel_requested": result.CancelRequested,
|
||||||
"server_time": formatTime(result.ServerTime),
|
"server_time": formatTime(result.ServerTime),
|
||||||
})
|
})
|
||||||
@@ -453,3 +460,12 @@ func deviceTaskResponse(task domain.PurchaseTask) gin.H {
|
|||||||
"currency": task.Currency,
|
"currency": task.Currency,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deviceExecutionResponse(
|
||||||
|
execution domain.TaskExecution,
|
||||||
|
expiresAt *time.Time,
|
||||||
|
) gin.H {
|
||||||
|
response := executionResponse(execution)
|
||||||
|
response["execution_expires_at"] = formatOptionalTime(expiresAt)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|||||||
@@ -432,9 +432,14 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
|
|||||||
if started.Task.Status != string(domain.TaskStatusRunning) ||
|
if started.Task.Status != string(domain.TaskStatusRunning) ||
|
||||||
started.Execution.ID == "" ||
|
started.Execution.ID == "" ||
|
||||||
started.Execution.CurrentStep != "PREFLIGHT" ||
|
started.Execution.CurrentStep != "PREFLIGHT" ||
|
||||||
|
started.Execution.ExpiresAt.IsZero() ||
|
||||||
started.Execution.OrderSubmitted {
|
started.Execution.OrderSubmitted {
|
||||||
t.Fatalf("start response = %+v", started)
|
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)
|
assertNoClaimSecret(t, startedResponse, testOpaqueToken)
|
||||||
|
|
||||||
heartbeatBody := fmt.Sprintf(
|
heartbeatBody := fmt.Sprintf(
|
||||||
@@ -464,6 +469,8 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
|
|||||||
if heartbeat.Task.Status != string(domain.TaskStatusRunning) ||
|
if heartbeat.Task.Status != string(domain.TaskStatusRunning) ||
|
||||||
heartbeat.Execution.ID != started.Execution.ID ||
|
heartbeat.Execution.ID != started.Execution.ID ||
|
||||||
heartbeat.Execution.CurrentStep != "SEARCH_RESULTS" ||
|
heartbeat.Execution.CurrentStep != "SEARCH_RESULTS" ||
|
||||||
|
heartbeat.Execution.ExpiresAt.IsZero() ||
|
||||||
|
heartbeat.Execution.ExpiresAt.Before(started.Execution.ExpiresAt) ||
|
||||||
heartbeat.Execution.OrderSubmitted ||
|
heartbeat.Execution.OrderSubmitted ||
|
||||||
heartbeat.CancelRequested {
|
heartbeat.CancelRequested {
|
||||||
t.Fatalf("task heartbeat response = %+v", heartbeat)
|
t.Fatalf("task heartbeat response = %+v", heartbeat)
|
||||||
@@ -918,9 +925,10 @@ type deviceLifecycleResponse struct {
|
|||||||
ReferenceImageURL string `json:"reference_image_url"`
|
ReferenceImageURL string `json:"reference_image_url"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
Execution struct {
|
Execution struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CurrentStep string `json:"current_step"`
|
CurrentStep string `json:"current_step"`
|
||||||
OrderSubmitted bool `json:"order_submitted"`
|
OrderSubmitted bool `json:"order_submitted"`
|
||||||
|
ExpiresAt time.Time `json:"execution_expires_at"`
|
||||||
} `json:"execution"`
|
} `json:"execution"`
|
||||||
Replayed bool `json:"replayed"`
|
Replayed bool `json:"replayed"`
|
||||||
CancelRequested bool `json:"cancel_requested"`
|
CancelRequested bool `json:"cancel_requested"`
|
||||||
|
|||||||
@@ -54,8 +54,9 @@
|
|||||||
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
|
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
|
||||||
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
|
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
|
||||||
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
|
验证。T-201 后端骨架、T-202 P0 原型、T-203 任务 API/管理 Web、T-204 最小鉴权
|
||||||
和 T-205 原子领取/租约状态机均已完成。下一步按编号开始 T-206,把 Android
|
T-205 原子领取/租约状态机和 T-206 Android 登录、手动领取、参考图预览、前台服务、
|
||||||
`HttpTaskSource`、前台服务和已确认的任务页面接到设备 API。
|
有限离线与恢复均已完成。下一步按编号开始 T-207,把现有端上 VLM/拼多多候选流程
|
||||||
|
接入已领取任务并实现事件、证据和终态结果回传。
|
||||||
候选优化数据集已经登记为 T-208;不得跳过 T-206/T-207 的第一版端到端闭环,提前
|
候选优化数据集已经登记为 T-208;不得跳过 T-206/T-207 的第一版端到端闭环,提前
|
||||||
建设报表、训练管线或外部商品抓取。
|
建设报表、训练管线或外部商品抓取。
|
||||||
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
|
|||||||
或结构化错误;无权限用户不可访问。
|
或结构化错误;无权限用户不可访问。
|
||||||
- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
|
- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
|
||||||
不得领取第二条或把同一任务分配两次。T-205 已完成后端 heartbeat、原子 claim、
|
不得领取第二条或把同一任务分配两次。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:解析结果包含搜索词、识别属性、预算、数量、置信度和警告;
|
- F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告;
|
||||||
原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、
|
原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、
|
||||||
冲突转人工以及 SKU/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。
|
冲突转人工以及 SKU/数量的本地确定性回填;当前样本未提供预算,因此预算保持空。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
| Android 自动化 | `AccessibilityService` 语义节点动作;Shizuku 保留为上游兼容路径 | 搜索与 5 个候选已真机验证 | T-101/T-102 已完成精确输入、结果页确认、候选卡识别、详情截图和验证返回;没有坐标或 shell 降级。上游 `main` 仍保留 Shizuku 13.1.5。 |
|
| 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 明确不支持该截图探针。 |
|
| 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,默认构建会清除私有资产。 |
|
| 第一层任务源 | 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 1.23.0 | MVP 已定 | 与现有本机工具链一致;构建测试必须设置 `GOTOOLCHAIN=local` 防止静默升级。 |
|
||||||
| 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | 已接入并收敛 | 只作为一次性脚手架输入;演示路由、默认 CORS、`.env` 自动加载、单例和 fatal 行为均已删除。 |
|
| 后端骨架 | Go Blueprint v0.10.11 生成的最小 Gin + SQLite 工程 | 已接入并收敛 | 只作为一次性脚手架输入;演示路由、默认 CORS、`.env` 自动加载、单例和 fatal 行为均已删除。 |
|
||||||
| 后端框架 | Gin v1.11.0 | MVP 已定 | 这是 `go.mod` 明确支持 Go 1.23.0 的最高已核实 Gin 版本。 |
|
| 后端框架 | 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。 |
|
| 管理鉴权 | 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。 |
|
| App 鉴权 | BUYER 密码 + 预授权设备 secret + 1 小时 opaque token | T-204 已验证 | 首次原子绑定空闲设备;数据库只存 token SHA-256,不提供自助登记/refresh。 |
|
||||||
| VLM 接入 | Android 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | 手机直连 provider;后端不代理模型。T-207 把 Key 迁移到 Keystore-backed 加密存储,并回传非秘密 provenance。 |
|
| 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。 |
|
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
|
||||||
| 后端测试 | 标准库 `testing` + `httptest` | T-205 已验证 | 当前 192 个测试覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、租约状态机、取消确认、跨连接与真实 TCP 并发。 |
|
| 后端测试 | 标准库 `testing` + `httptest` | T-206 已验证 | 当前含子测试 193 次覆盖配置、迁移、图片限制、任务事务、鉴权隔离、设备就绪、原子领取、幂等重放、有限离线租约、取消确认、跨连接与真实 TCP 并发。 |
|
||||||
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 |
|
| 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 或受控测试网络访问。 |
|
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
|
||||||
|
|
||||||
## Roubao 上游版本基线
|
## Roubao 上游版本基线
|
||||||
|
|||||||
+13
-5
@@ -314,20 +314,28 @@ CLAIMED/RUNNING/WAITING_CONFIRMATION
|
|||||||
规则:
|
规则:
|
||||||
|
|
||||||
- `SUCCEEDED` 是验证结果成功,不代表已下单;`order_submitted=false`。
|
- `SUCCEEDED` 是验证结果成功,不代表已下单;`order_submitted=false`。
|
||||||
- `CLAIMED` 默认租约为 10 分钟;T-206 将 `RUNNING/WAITING_CONFIRMATION` 默认
|
- `CLAIMED` 默认租约为 10 分钟;`RUNNING/WAITING_CONFIRMATION` 默认授权为
|
||||||
授权从当前 90 秒扩展为 30 分钟,允许范围 5 至 120 分钟。App 每 30 秒
|
30 分钟,允许范围 5 至 120 分钟。App 每 30 秒 best-effort heartbeat,成功后
|
||||||
best-effort heartbeat,成功后按服务端时间滑动续期。
|
按服务端时间滑动续期。
|
||||||
- 只有当前设备和有效 claim token 能启动、续租、上报或结束任务。
|
- 只有当前设备和有效 claim token 能启动、续租、上报或结束任务。
|
||||||
- 终态不可回退;重试创建新的 execution attempt,不篡改历史证据。
|
- 终态不可回退;重试创建新的 execution attempt,不篡改历史证据。
|
||||||
- App 在 claim 前生成并安全保存 256 bit Raw URL token;后端只存 SHA-256,
|
- App 在 claim 前生成并安全保存 256 bit Raw URL token;后端只存 SHA-256,
|
||||||
token 与 `Idempotency-Key` 相互独立。
|
token 与 `Idempotency-Key` 相互独立。
|
||||||
- 过期 `CLAIMED` 可以原子回收;过期 `RUNNING/WAITING_CONFIRMATION` 保持原状态,
|
- 过期 `CLAIMED` 可以原子回收;过期 `RUNNING/WAITING_CONFIRMATION` 保持原状态,
|
||||||
不自动回队列。App 到达服务端授权截止时间必须安全停止,但原设备可以在重连后
|
不自动回队列。App 到达服务端授权截止时间持久化 `SAFE_STOPPED` 并停止前台
|
||||||
补报已产生的终态结果并留下过期补报审计标志。
|
执行;原设备重连 heartbeat 只同步状态且不延长已过期授权。T-207 才允许原设备
|
||||||
|
补报授权内已产生的终态结果并留下过期补报审计标志。
|
||||||
- 管理取消 `PENDING/CLAIMED` 立即终态;执行中只设置停止请求,App 在下一个安全
|
- 管理取消 `PENDING/CLAIMED` 立即终态;执行中只设置停止请求,App 在下一个安全
|
||||||
检查点停止并调用 `cancel-ack` 后才进入 `CANCELED`。离线期间不能保证即时取消,
|
检查点停止并调用 `cancel-ack` 后才进入 `CANCELED`。离线期间不能保证即时取消,
|
||||||
App 恢复连接后必须先同步取消状态再继续页面动作。
|
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 工作流状态
|
### 4.2 Android 工作流状态
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -192,8 +192,10 @@
|
|||||||
|
|
||||||
**范围**
|
**范围**
|
||||||
|
|
||||||
- 包含:App 本地 provider 配置、Keystore 加密、手动/AI 模式、有限离线授权、加密
|
- T-206 已包含:后台任务安全登录/领取、有限离线授权、前台 heartbeat、状态恢复和
|
||||||
outbox、结果 provenance 和恢复同步。
|
安全停止。
|
||||||
|
- T-207 待包含:App 本地 provider 接入后台任务、Key 迁移、手动/AI 模式、加密
|
||||||
|
outbox、结果 provenance 和恢复补报。
|
||||||
- 不包含:无限离线执行、离线即时取消、后端模型代理、多设备统一模型或自动下单。
|
- 不包含:无限离线执行、离线即时取消、后端模型代理、多设备统一模型或自动下单。
|
||||||
|
|
||||||
**验收场景**
|
**验收场景**
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
| IX-001 | US-007 | 管理 Web 登录 | 提交账号密码 | 建立管理会话或显示通用错误 | P0 | 已定 |
|
| IX-001 | US-007 | 管理 Web 登录 | 提交账号密码 | 建立管理会话或显示通用错误 | P0 | 已定 |
|
||||||
| IX-002 | US-001 | 新建任务表单 | 填写并提交 | 创建 `PENDING` 任务 | P0 | 已定 |
|
| IX-002 | US-001 | 新建任务表单 | 填写并提交 | 创建 `PENDING` 任务 | P0 | 已定 |
|
||||||
| IX-003 | US-002 | 任务列表/详情 | 打开、刷新、取消 | 查看最新状态、证据和允许动作 | P0 | 已定 |
|
| IX-003 | US-002 | 任务列表/详情 | 打开、刷新、取消 | 查看最新状态、证据和允许动作 | P0 | 已定 |
|
||||||
| IX-004 | US-007 | App 登录/设备绑定 | 登录或会话恢复 | 建立人员+设备身份 | P0 | 已定 |
|
| IX-004 | US-007 | App 登录/设备绑定 | 登录或会话恢复 | 建立人员+设备身份 | P0 | T-206 已实现 |
|
||||||
| IX-005 | US-003 | App 任务页 | 点击“获取任务” | 就绪检查后领取一条任务或显示原因 | P0 | 已定 |
|
| IX-005 | US-003 | App 任务页 | 点击“获取任务” | 就绪检查后领取一条任务或显示原因 | P0 | T-206 已实现 |
|
||||||
| IX-006 | US-004 | App 执行页 | 确认开始/自动步骤 | 显示步骤并有界执行搜索与候选判断 | P0 | 已定 |
|
| IX-006 | US-004 | App 执行页 | 确认开始/自动步骤 | 显示步骤并有界执行搜索与候选判断 | P0 | 已定 |
|
||||||
| IX-007 | US-005 | App 候选确认 | 接受/拒绝/转人工 | 停止自动化并回传人员结论 | P0 | 已定 |
|
| IX-007 | US-005 | App 候选确认 | 接受/拒绝/转人工 | 停止自动化并回传人员结论 | P0 | 已定 |
|
||||||
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
|
| IX-008 | US-006 | App/管理端错误状态 | 自动失败、取消、重试上传 | 显示结构化原因和恢复动作 | P0 | 已定 |
|
||||||
| IX-009 | US-008 | App 候选理由/管理端决策详情 | 接受、拒绝、改选或修正 | 保存逐候选结构化人工标签 | P1 | 已定,T-208 后置 |
|
| 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 登录
|
## IX-001 管理 Web 登录
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -353,9 +353,9 @@ generation/token 且租约未过期的活跃任务可以读取。成功返回匿
|
|||||||
|
|
||||||
同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、
|
同 key、同请求重放返回同一 execution;错误用户/设备/token 返回 `403`,过期租约、
|
||||||
状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed`
|
状态或版本冲突返回 `409`。成功响应包含更新后的 `task`、`execution`、`replayed`
|
||||||
和 `server_time`。T-206 将默认 running lease 从当前 90 秒改为 30 分钟(允许配置
|
和 `server_time`。默认 running lease 为 30 分钟(允许配置 5 至 120 分钟),
|
||||||
5 至 120 分钟),并显式返回 `execution_expires_at`。该时间是 App 可以离线继续
|
`execution.execution_expires_at` 是 App 可以离线继续自动化的上限,不是后台自动
|
||||||
自动化的上限,不是后台自动重分配时间。
|
重分配时间。
|
||||||
|
|
||||||
### `POST /api/v1/tasks/{task_id}/heartbeat`
|
### `POST /api/v1/tasks/{task_id}/heartbeat`
|
||||||
|
|
||||||
@@ -392,7 +392,10 @@ generation/token 且租约未过期的活跃任务可以读取。成功返回匿
|
|||||||
只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、
|
只接受 1 至 64 字节的大写 ASCII step。heartbeat 使用服务端 UTC 更新 execution、
|
||||||
设备最近在线时间、任务 version 和 `execution_expires_at`,不写高频任务事件。
|
设备最近在线时间、任务 version 和 `execution_expires_at`,不写高频任务事件。
|
||||||
App 每 30 秒 best-effort 调用;网络失败时可执行到上一次服务端截止时间。截止时间
|
App 每 30 秒 best-effort 调用;网络失败时可执行到上一次服务端截止时间。截止时间
|
||||||
过期后拒绝续作,任务保持原非终态且绝不回到领取队列。
|
到达后 App 持久化 `SAFE_STOPPED` 并停止外部动作,任务保持原非终态且绝不回到
|
||||||
|
领取队列。原设备重连后可以用同一 execution/claim heartbeat 同步状态;后端不延长
|
||||||
|
已经过期的授权,且此时只接受 `step=SAFE_STOPPED`。若响应包含取消请求,App 可以
|
||||||
|
继续调用 `cancel-ack`;没有取消时仍保持安全停止,不自动恢复采购。
|
||||||
|
|
||||||
### `POST /api/v1/tasks/{task_id}/release`
|
### `POST /api/v1/tasks/{task_id}/release`
|
||||||
|
|
||||||
|
|||||||
+19
-13
@@ -5,9 +5,9 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-27
|
- 日期:2026-07-27
|
||||||
- 阶段:T-205 原子领取、租约和状态机完成,下一步 T-206
|
- 阶段:T-206 App 领取、有限离线和恢复完成,下一步 T-207
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-205
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-206
|
||||||
均已纳入 Git 历史;T-205 提交为 `ce875af`
|
均已纳入 Git 历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||||
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
|
- 后端: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、
|
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
|
- 测试:`test assembleDebug` 成功;App 两个变体、task contract 和导入器
|
||||||
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
|
共 32 份报告、184 次测试,0 failure、0 error、0 skipped
|
||||||
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 192 个测试通过;
|
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 含子测试共 193 次通过;
|
||||||
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
|
全包 race、`go vet ./...`、API/migration/authctl Windows 构建和根 `init.ps1`
|
||||||
均通过
|
均通过
|
||||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||||
@@ -32,9 +32,14 @@
|
|||||||
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
||||||
- 生命周期:设备 heartbeat 记录三个版本、两个就绪位和服务端活跃任务;claim 使用
|
- 生命周期:设备 heartbeat 记录三个版本、两个就绪位和服务端活跃任务;claim 使用
|
||||||
client-generated 256 bit token + 独立幂等 key、SQLite immediate transaction、
|
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 参考图均
|
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、安全阻塞、
|
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||||
@@ -46,8 +51,9 @@
|
|||||||
- VLM 部署决策:手机本地调用已配置的 OpenAI 兼容 provider;管理后端只负责身份、
|
- VLM 部署决策:手机本地调用已配置的 OpenAI 兼容 provider;管理后端只负责身份、
|
||||||
任务控制和结果审计,不保存/代理模型。T-207 保留 Roubao 独立模式并把端上 Key
|
任务控制和结果审计,不保存/代理模型。T-207 保留 Roubao 独立模式并把端上 Key
|
||||||
迁移到 Keystore-backed 加密存储
|
迁移到 Keystore-backed 加密存储
|
||||||
- 离线执行决策:T-206 将当前 90 秒运行租约扩展为默认 30 分钟有限授权;heartbeat
|
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
|
||||||
best-effort,授权内可离线执行,到期安全停止,RUNNING 不自动重新分配
|
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
|
||||||
|
RUNNING 不自动重新分配
|
||||||
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
|
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
|
||||||
`8.17.0 (81700)`
|
`8.17.0 (81700)`
|
||||||
- 设备就绪:肉包采购无障碍已启用并连接;拼多多首页、搜索输入、固定词结果页、
|
- 设备就绪:肉包采购无障碍已启用并连接;拼多多首页、搜索输入、固定词结果页、
|
||||||
@@ -79,7 +85,7 @@
|
|||||||
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
|
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
|
||||||
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
|
| `docs/tasks/T-204.md` | DONE | 用户、管理会话和预授权设备联合身份 |
|
||||||
| `docs/tasks/T-205.md` | DONE | 原子 claim、租约、execution 和取消安全确认 |
|
| `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/tasks/T-207.md` | TODO | App 本地 VLM、Key 加密、候选、事件、截图和结果回传 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `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 提前实现。
|
- 后置任务:T-208 候选决策数据与人工理由闭环;不得跳过 T-206/T-207 提前实现。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ Android 导航名称是逻辑目的地,具体 Compose/Fragment 形式待接入
|
|||||||
|
|
||||||
claim/start/release/cancel-ack 使用相应幂等规则;参考图、start、task heartbeat、
|
claim/start/release/cancel-ack 使用相应幂等规则;参考图、start、task heartbeat、
|
||||||
release 和 cancel-ack 都必须匹配当前 `X-Claim-Token` 与 `claim_generation`。
|
release 和 cancel-ack 都必须匹配当前 `X-Claim-Token` 与 `claim_generation`。
|
||||||
|
运行授权过期后只有 task heartbeat 状态同步和已请求取消的 cancel-ack 允许原设备
|
||||||
|
继续调用;它们不赋予 App 恢复外部动作的权限。
|
||||||
T-207 的 events/candidates/complete/fail 接受原设备对授权内已产生结果的离线补报,
|
T-207 的 events/candidates/complete/fail 接受原设备对授权内已产生结果的离线补报,
|
||||||
并由服务端记录是否在执行授权过期后收到;后端不提供 VLM 代理路由。
|
并由服务端记录是否在执行授权过期后收到;后端不提供 VLM 代理路由。
|
||||||
|
|
||||||
|
|||||||
+46
-11
@@ -5,10 +5,10 @@ phase: 2
|
|||||||
deps:
|
deps:
|
||||||
- T-202
|
- T-202
|
||||||
- T-205
|
- T-205
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-27
|
created: 2026-07-27
|
||||||
context_ref: 45d1436
|
context_ref: 45d1436
|
||||||
work_branch: null
|
work_branch: main
|
||||||
write_paths:
|
write_paths:
|
||||||
- README.md
|
- README.md
|
||||||
- android-buyer/**
|
- android-buyer/**
|
||||||
@@ -93,18 +93,18 @@ Base URL、model、prompt 或 API Key。
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] 预授权 BUYER 在真机登录成功;错误账号、错误设备、禁用和过期均被拒绝,秘密
|
- [x] 预授权 BUYER 在真机登录成功;错误账号、错误设备、禁用和过期均被拒绝,秘密
|
||||||
不出现在日志和普通存储。
|
不出现在日志和普通存储。
|
||||||
- [ ] 真机 heartbeat 后点击“获取任务”只领取管理 Web 创建的一条 `PENDING` 任务,
|
- [x] 真机 heartbeat 后点击“获取任务”只领取管理 Web 创建的一条 `PENDING` 任务,
|
||||||
显示标题、SKU、数量、描述和参考图;无任务显示空状态。
|
显示标题、SKU、数量、描述和参考图;无任务显示空状态。
|
||||||
- [ ] 重复点击和模拟响应丢失不会重复领取;App 重启能恢复同一 `CLAIMED`/RUNNING。
|
- [x] 重复点击和模拟响应丢失不会重复领取;App 重启能恢复同一 `CLAIMED`/RUNNING。
|
||||||
- [ ] start 返回默认约 30 分钟 `execution_expires_at`;断开管理后端超过 90 秒后,
|
- [x] start 返回默认约 30 分钟 `execution_expires_at`;断开管理后端超过 90 秒后,
|
||||||
受控 fake workflow 仍能在授权内继续,任务不会分配给第二台设备。
|
受控 fake workflow 仍能在授权内继续,任务不会分配给第二台设备。
|
||||||
- [ ] 离线授权到期后 App 安全停止;恢复网络先同步服务端状态,不擅自续作。
|
- [x] 离线授权到期后 App 安全停止;恢复网络先同步服务端状态,不擅自续作。
|
||||||
- [ ] 管理取消在在线时及时处理;离线时恢复连接后的首个同步点处理并确认。
|
- [x] 管理取消在在线时及时处理;离线时恢复连接后的首个同步点处理并确认。
|
||||||
- [ ] Roubao 原独立模式、端上 provider 选择和本地 fixture 探针回归通过;后台任务
|
- [x] Roubao 原独立模式、端上 provider 选择和本地 fixture 探针回归通过;后台任务
|
||||||
不下发、覆盖或读取来自任务内容的 provider 配置。
|
不下发、覆盖或读取来自任务内容的 provider 配置。
|
||||||
- [ ] Android/后端测试、根 `init.ps1` 和 OnePlus PKG110 Android 16 真机 smoke
|
- [x] Android/后端测试、根 `init.ps1` 和 OnePlus PKG110 Android 16 真机 smoke
|
||||||
通过,验证过程中不提交订单。
|
通过,验证过程中不提交订单。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
@@ -116,4 +116,39 @@ Base URL、model、prompt 或 API Key。
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 尚未开始。
|
### 2026-07-27:任务开始
|
||||||
|
|
||||||
|
- 基于提交 `9e698c1` 开始,工作区干净;T-202/T-205 依赖均已完成。
|
||||||
|
- codebase-memory MCP 本轮未暴露 graph 工具,按仓库规则回退到 `rg` 和定点读取。
|
||||||
|
- 开工基线 `.\init.ps1` 通过:Android Debug/Release 测试与 Debug APK 构建成功,
|
||||||
|
Go 全包测试、vet、gofmt 检查和 API/migration/authctl 构建成功。
|
||||||
|
- 先固定并实现后端默认 30 分钟运行授权和 `execution_expires_at`,再让 Android
|
||||||
|
登录、领取、恢复和前台服务依赖该契约。
|
||||||
|
|
||||||
|
### 2026-07-27:实现与验证完成
|
||||||
|
|
||||||
|
- 后端默认运行授权改为 30 分钟、允许 5-120 分钟;start/task heartbeat 的
|
||||||
|
`execution` 显式返回 `execution_expires_at`。授权过期后原设备 heartbeat 只同步
|
||||||
|
状态、不延长旧授权;匹配原 execution/claim 的取消确认仍可完成,RUNNING 不回队列。
|
||||||
|
- Android 新增独立“任务”入口、严格 backend 地址策略、无明文 fallback 的
|
||||||
|
`ProcurementSecureStore`、`ProcurementApiClient`、`HttpTaskSource`、参考图
|
||||||
|
MIME/JPEG/20 MiB/SHA-256 校验和加密 task/execution 恢复。密码不存储;设备
|
||||||
|
secret、access/claim token 和各操作幂等 key 只进加密存储。
|
||||||
|
- 前台服务每 30 秒 best-effort heartbeat;使用保守的服务端时钟偏移和持久
|
||||||
|
`SAFE_STOPPED` 判断离线截止。登录 token 过期时只允许同后台、同 BUYER、同设备
|
||||||
|
重新认证;过期恢复同步不会自动继续外部动作。
|
||||||
|
- 领取前先持久化 256 bit claim token/独立幂等 key,响应不确定时复用;参考图和
|
||||||
|
task 快照落盘后才可预览。start 增加二次确认和至少 2 秒稳定预览门禁,避免页面
|
||||||
|
重组或自动化注入导致误启动;T-206 受控步骤不打开拼多多、不调用 VLM、不提交订单。
|
||||||
|
- MockWebServer 覆盖 Android 登录、幂等领单、参考图 hash/同源限制和 start 到期契约;
|
||||||
|
端点策略与服务端时钟/预览门禁有纯 Kotlin 单测。最终根 `.\init.ps1` 通过:
|
||||||
|
Android 32 份报告、184 次测试零失败,Go 含子测试 193 次通过,vet、gofmt 和
|
||||||
|
API/migration/authctl Windows 构建成功。
|
||||||
|
- PKG110 Android 16 真机使用临时 18080 后端、一次性 DPAPI 保护凭证和脱敏任务
|
||||||
|
完成:BUYER 登录、readiness、领取、字段/参考图预览、start、前台通知、95 秒
|
||||||
|
后端断线、恢复滑动续期、管理取消/`cancel-ack`、release 和进程重启恢复。断线
|
||||||
|
后仍为同一 `CONTROLLED_WORKFLOW`,倒计时从约 29:38 到 27:54;重连恢复到约
|
||||||
|
29:41。验证未打开拼多多、未调用 VLM、未提交订单。
|
||||||
|
- 最终 Debug APK 已覆盖安装;一次性 API/数据库/DPAPI 凭证和 App 临时后台登录
|
||||||
|
状态已清理,Roubao 其他设置保留,采购无障碍恢复启用。T-207 接入真实端上
|
||||||
|
VLM/候选并实现事件、证据、结果和完整加密 outbox。
|
||||||
|
|||||||
+10
@@ -154,3 +154,13 @@
|
|||||||
模型预测、确定性推荐、受控截图和逐候选人工理由;四层数据不得混用。
|
模型预测、确定性推荐、受控截图和逐候选人工理由;四层数据不得混用。
|
||||||
- 影响:第一版领取与结果回传不被训练数据建设阻塞;20 条真实任务试验开始前完成
|
- 影响:第一版领取与结果回传不被训练数据建设阻塞;20 条真实任务试验开始前完成
|
||||||
T-208,人工接受、拒绝、改选和无匹配才形成可信优化标签。
|
T-208,人工接受、拒绝、改选和无匹配才形成可信优化标签。
|
||||||
|
|
||||||
|
## 2026-07-27 App 手动领取与有限离线
|
||||||
|
|
||||||
|
- 类型:阶段完成
|
||||||
|
- 内容:完成 T-206;Android 接入 BUYER/设备登录、手动 claim、参考图预览、
|
||||||
|
start/release、前台 heartbeat、默认 30 分钟有限离线、`SAFE_STOPPED`、取消确认
|
||||||
|
和进程恢复,秘密与任务状态使用 Keystore-backed 加密存储。
|
||||||
|
- 影响:管理员创建的任务已能在真机领取并受控运行,断开后台超过旧 90 秒窗口仍由
|
||||||
|
同一手机持有,重连优先同步;下一步 T-207 把现有端上 VLM/拼多多候选流程接入
|
||||||
|
`HttpTaskSource`,回传事件、证据和终态结果。
|
||||||
|
|||||||
Reference in New Issue
Block a user