Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d21ca9881 | ||
|
|
00a0c8386d | ||
|
|
5183df306f | ||
|
|
df6b4a570c |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 84
|
||||
versionName = "0.9.71"
|
||||
versionCode = 85
|
||||
versionName = "0.9.72"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+37
-4
@@ -560,7 +560,11 @@ class PurchaseLiveAutomation(
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
waitFor(
|
||||
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
|
||||
"地址保存后无法返回订单页面,未创建订单",
|
||||
diagnostic = { addressSaveBackDiagnostic(it, expected, suffix) },
|
||||
) {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||
}
|
||||
}
|
||||
@@ -612,9 +616,21 @@ class PurchaseLiveAutomation(
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
return screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES
|
||||
return acceptsPurchasePanel(screen)
|
||||
}
|
||||
|
||||
/**
|
||||
* #388: after an address save PDD may re-render the sheet with the "已选" summary, which the parser
|
||||
* classifies as QUICK_CONFIRMATION. Accept that type only with the same evidence bar as
|
||||
* REQUIRED_EVIDENCE (address, payment entry and the single quantity input in one container).
|
||||
* This only affects panel recognition; address suffix, bottom-row submit target, blocked words
|
||||
* and spec/price/quantity checks are unchanged.
|
||||
*/
|
||||
private fun acceptsPurchasePanel(screen: ParsedPddScreen): Boolean =
|
||||
screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES ||
|
||||
(screen.specPanelType == SpecPanelType.QUICK_CONFIRMATION &&
|
||||
screen.hasRequiredPanelEvidence && screen.requiredEvidenceSameContainer)
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
initial: UiSnapshot,
|
||||
expected: String,
|
||||
@@ -749,16 +765,33 @@ class PurchaseLiveAutomation(
|
||||
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
||||
Regex("${Regex.escape(suffix)}(?![0-9])").containsMatchIn(normalizeAddressText(value))
|
||||
|
||||
private fun waitFor(code: String, message: String, predicate: (UiSnapshot) -> Boolean): UiSnapshot {
|
||||
private fun waitFor(
|
||||
code: String,
|
||||
message: String,
|
||||
diagnostic: ((UiSnapshot) -> String)? = null,
|
||||
predicate: (UiSnapshot) -> Boolean,
|
||||
): UiSnapshot {
|
||||
var last: UiSnapshot? = null
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (predicate(snapshot)) return snapshot
|
||||
last = snapshot
|
||||
pause(200)
|
||||
}
|
||||
val sampled = last
|
||||
if (diagnostic != null && sampled != null) fail(code, "$message [${diagnostic(sampled)}]")
|
||||
fail(code, message)
|
||||
}
|
||||
|
||||
/** Types, counts and 0/1 flags only; never page text. */
|
||||
private fun addressSaveBackDiagnostic(snapshot: UiSnapshot, expected: String, suffix: String): String {
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
val saved = if (hasSavedAddressEvidence(snapshot, expected, suffix)) 1 else 0
|
||||
return "stage=address_save_back;type=${screen.specPanelType};${screen.requiredEvidenceSummary()};" +
|
||||
"saved=$saved;submitTargets=${finalSubmitTargets(snapshot).size}"
|
||||
}
|
||||
|
||||
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
@@ -888,7 +921,7 @@ class PurchaseLiveAutomation(
|
||||
return current
|
||||
}
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
if (screen.specPanelType !in PURCHASE_CONFIRMATION_PANEL_TYPES) {
|
||||
if (!acceptsPurchasePanel(screen)) {
|
||||
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* #388: after an address save PDD can show the order sheet with the "已选" summary, which the parser classifies as
|
||||
* QUICK_CONFIRMATION. Purchase accepts it only with the #331 required evidence in one container. All nodes are
|
||||
* synthetic (derived from [SpecPanelFixtures.sheet]); addresses and phones are fake.
|
||||
*/
|
||||
class PurchaseQuickConfirmationAddressTest {
|
||||
// 1. Full call chain: finalConfirmation passes and submitOrderOnce clicks once on the bottom row leaf.
|
||||
@Test fun `evidence complete quick confirmation passes final confirmation and submits once on the bottom row`() {
|
||||
val panel = quick()
|
||||
assertQuick(panel)
|
||||
val driver = StaticDriver(panel)
|
||||
val automation = automation(driver)
|
||||
|
||||
val evidence = automation.finalConfirmation(input(), ShippingAddressProof(ADDRESS, SUFFIX))
|
||||
assertEquals("黑色", evidence.mappedColor)
|
||||
assertEquals(1960L, evidence.unitPriceCent)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(listOf("r/sheet/submit/l/t"), driver.clicked)
|
||||
val error = runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", error.code)
|
||||
assertEquals(1, driver.clicked.size)
|
||||
}
|
||||
|
||||
// 2. Chain path 1: save -> address list (non panel) -> one Back -> evidence complete quick panel.
|
||||
@Test fun `save then address list then one back accepts evidence complete quick confirmation`() {
|
||||
val driver = FlowDriver(afterSave = "list", afterBack = "quickNew")
|
||||
assertQuick(driver.quickNew)
|
||||
val proof = automation(driver).updateShippingAddress(SUFFIX)
|
||||
|
||||
assertEquals(ADDRESS, proof.expectedAddress)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.saveClicks)
|
||||
}
|
||||
|
||||
// 3. Chain path 2: save lands directly on the evidence complete quick panel; no Back, final_evidence.
|
||||
@Test fun `save landing directly on evidence complete quick confirmation passes without back`() {
|
||||
val driver = FlowDriver(afterSave = "quickNew", afterBack = "quickNew")
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val proof = automation(driver, diagnostics).updateShippingAddress(SUFFIX)
|
||||
|
||||
assertEquals(ADDRESS, proof.expectedAddress)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("addressSaveWait;outcome=final_evidence") && it.contains("type=QUICK_CONFIRMATION") })
|
||||
}
|
||||
|
||||
// 4a. Rejected: no address entry (no masked phone) -> panel type not accepted, no target, no click.
|
||||
@Test fun `quick confirmation without address entry has no submit target`() {
|
||||
val panel = quick(phone = "测试,示例省示例市")
|
||||
assertQuick(panel)
|
||||
assertNoSubmitTarget(panel)
|
||||
}
|
||||
|
||||
// 4b. Rejected: required evidence is not in one panel container.
|
||||
@Test fun `quick confirmation with evidence in different containers has no submit target`() {
|
||||
val panel = quick(detachAddress = true)
|
||||
assertQuick(panel)
|
||||
assertNoSubmitTarget(panel)
|
||||
}
|
||||
|
||||
// 5. Rejected: bottom row mentions payment method or payment wording.
|
||||
@Test fun `quick confirmation whose bottom row has payment wording has no submit target`() {
|
||||
listOf("微信支付 现在买,仅 ¥19.6", "现在买,仅 ¥19.6 立即支付", "先用后付 现在买,仅 ¥19.6").forEach { bottom ->
|
||||
val panel = quick(bottom = bottom)
|
||||
assertQuick(panel)
|
||||
assertNoSubmitTarget(panel)
|
||||
}
|
||||
// The alias-only variant must be rejected by the bottom row rule itself.
|
||||
val aliasError = runCatching { automation(StaticDriver(quick(bottom = "微信支付 现在买,仅 ¥19.6"))).submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", aliasError.code)
|
||||
}
|
||||
|
||||
// 6. Rejected: spec on the panel differs from the task.
|
||||
@Test fun `quick confirmation with another spec fails final confirmation without clicking`() {
|
||||
val panel = quick()
|
||||
assertQuick(panel)
|
||||
val driver = StaticDriver(panel)
|
||||
val error = runCatching {
|
||||
automation(driver).finalConfirmation(input(color = "红色"), ShippingAddressProof(ADDRESS, SUFFIX))
|
||||
}.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
// 7. Rejected: unit price outside the allowed range.
|
||||
@Test fun `quick confirmation with price out of range fails final confirmation without clicking`() {
|
||||
val panel = quick()
|
||||
assertQuick(panel)
|
||||
val driver = StaticDriver(panel)
|
||||
val error = runCatching {
|
||||
automation(driver).finalConfirmation(input().copy(maxUnitPriceCent = 1000), ShippingAddressProof(ADDRESS, SUFFIX))
|
||||
}.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
// 8. Rejected: the task address suffix is not unique on the panel.
|
||||
@Test fun `quick confirmation with duplicated address suffix still fails`() {
|
||||
val panel = quick(duplicateAddress = true)
|
||||
assertQuick(panel)
|
||||
val driver = StaticDriver(panel)
|
||||
val error = runCatching {
|
||||
automation(driver).finalConfirmation(input(), ShippingAddressProof(ADDRESS, SUFFIX))
|
||||
}.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
// 9. Diagnostic: still failing after Back carries only structural markers, no page text.
|
||||
@Test fun `address save back timeout carries structured diagnostic without page text`() {
|
||||
val driver = FlowDriver(afterSave = "list", afterBack = "quickBlocked")
|
||||
assertQuick(driver.quickBlocked)
|
||||
val error = runCatching { automation(driver).updateShippingAddress(SUFFIX) }.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
val message = error.message.orEmpty()
|
||||
assertTrue(message, message.startsWith("地址保存后无法返回订单页面,未创建订单 [stage=address_save_back;type=QUICK_CONFIRMATION;"))
|
||||
assertTrue(message, message.contains("saved=1;submitTargets=0]"))
|
||||
assertEquals(1, driver.backCount)
|
||||
listOf("测试路", "139", "示例", ADDRESS, "微信").forEach { assertFalse("$it in $message", message.contains(it)) }
|
||||
}
|
||||
|
||||
private fun automation(driver: PurchaseUiDriver, diagnostics: MutableList<String> = mutableListOf()) =
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
|
||||
private fun assertQuick(snapshot: UiSnapshot) {
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
assertEquals(SpecPanelType.QUICK_CONFIRMATION, screen.specPanelType)
|
||||
}
|
||||
|
||||
private fun assertNoSubmitTarget(panel: UiSnapshot) {
|
||||
val driver = StaticDriver(panel)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val error = runCatching { automation(driver, diagnostics).submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
|
||||
// Payment wording may already stop the page earlier (PAYMENT_FORBIDDEN); either way nothing is clicked.
|
||||
assertTrue(error.code, error.code in setOf("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "PURCHASE_PAYMENT_FORBIDDEN"))
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
private fun input(color: String = "黑色") = PurchaseExecutionInput(
|
||||
taskId = 101, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=101",
|
||||
goodsId = "101", mappedColor = color, mappedSize = "XL", quantity = 1,
|
||||
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = SUFFIX,
|
||||
)
|
||||
|
||||
/** Quick confirmation panel: summary, close, +/- and input, payment row, address row and a full-width bottom buy row. */
|
||||
private fun quick(
|
||||
address: String = ADDRESS,
|
||||
bottom: String = "现在买,仅 ¥19.6",
|
||||
phone: String = "测试,${SpecPanelFixtures.FAKE_PHONE},示例省示例市",
|
||||
detachAddress: Boolean = false,
|
||||
duplicateAddress: Boolean = false,
|
||||
): UiSnapshot {
|
||||
val base = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色"), sizeLabels = listOf("XL")))
|
||||
val addrPrefix = "r/sheet/body/addr"
|
||||
val nodes = base.nodes.map { node ->
|
||||
var n = when (node.path) {
|
||||
"r/sheet/body/summary" -> node.copy(text = "已选 黑色 XL")
|
||||
"r/sheet/body/addr/a/detail" -> node.copy(text = address)
|
||||
"r/sheet/body/addr/a/b/c/phone" -> node.copy(text = phone)
|
||||
"r/sheet/submit/l/t" -> node.copy(text = bottom)
|
||||
else -> node
|
||||
}
|
||||
if (detachAddress && (n.path == addrPrefix || n.path.startsWith("$addrPrefix/"))) {
|
||||
n = n.copy(
|
||||
path = n.path.replaceFirst(addrPrefix, "r/addrwrap/addr"),
|
||||
parentPath = n.parentPath?.let { if (it == "r/sheet/body") "r/addrwrap" else it.replaceFirst(addrPrefix, "r/addrwrap/addr") },
|
||||
)
|
||||
}
|
||||
n
|
||||
}.toMutableList()
|
||||
if (detachAddress) {
|
||||
nodes += SnapshotNode("r/addrwrap", "r", "", null, null, "android.widget.FrameLayout", NodeBounds(0, 330, 1080, 484), false, false, false, false, true, true)
|
||||
}
|
||||
if (duplicateAddress) {
|
||||
nodes += SnapshotNode("r/sheet/body/dup", "r/sheet/body", address, null, null, "android.widget.TextView", NodeBounds(36, 1000, 1000, 1060), false, false, false, false, true, true)
|
||||
}
|
||||
return base.copy(nodes = nodes)
|
||||
}
|
||||
|
||||
private class StaticDriver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
|
||||
val clicked = mutableListOf<String>()
|
||||
override fun capture() = snapshot
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult { clicked += target.path; return FreshActionResult.SUCCESS }
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
}
|
||||
|
||||
/** confirmation (old address) -> list -> edit -> save -> [afterSave] -> Back -> [afterBack]. */
|
||||
private class FlowDriver(private val afterSave: String, private val afterBack: String) : PurchaseUiDriver {
|
||||
private var page = "quickOld"
|
||||
private var address = "测试路1号-old"
|
||||
var backCount = 0
|
||||
var saveClicks = 0
|
||||
val quickNew: UiSnapshot get() = quickFor(ADDRESS)
|
||||
val quickBlocked: UiSnapshot get() = quickFor(ADDRESS, bottom = "微信支付 现在买,仅 ¥19.6")
|
||||
|
||||
private fun quickFor(addr: String, bottom: String = "现在买,仅 ¥19.6") =
|
||||
PurchaseQuickConfirmationAddressTest().quick(address = addr, bottom = bottom)
|
||||
|
||||
override fun capture(): UiSnapshot = when (page) {
|
||||
"quickOld" -> quickFor(address)
|
||||
"quickNew" -> quickFor(address)
|
||||
"quickBlocked" -> quickFor(address, bottom = "微信支付 现在买,仅 ¥19.6")
|
||||
"list" -> snapshot(listOf(
|
||||
node("title", "收货地址"),
|
||||
node("modify-parent", "", clickable = true),
|
||||
node("modify", "修改", parentPath = "modify-parent"),
|
||||
node("address", address),
|
||||
))
|
||||
else -> snapshot(listOf(
|
||||
node("title", "修改收货地址"),
|
||||
node("recipient-label", "收货人", bounds = NodeBounds(20, 200, 160, 260)),
|
||||
node("editor-recipient", "测试收货人", className = "android.widget.EditText", bounds = NodeBounds(180, 190, 900, 270)),
|
||||
node("phone-label", "手机号", bounds = NodeBounds(20, 300, 160, 360)),
|
||||
node("editor-phone", "13800000000", className = "android.widget.EditText", bounds = NodeBounds(180, 290, 900, 370)),
|
||||
node("detail", "详细地址", bounds = NodeBounds(20, 400, 160, 460)),
|
||||
node("editor-address", address, className = "android.widget.EditText", bounds = NodeBounds(180, 390, 900, 480)),
|
||||
node("save-parent", "", clickable = true), node("save", "保存", parentPath = "save-parent"),
|
||||
))
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
when {
|
||||
target.label.contains(SpecPanelFixtures.FAKE_PHONE) -> page = "list"
|
||||
target.label == "修改" -> page = "edit"
|
||||
target.label == "保存" -> { saveClicks++; page = afterSave }
|
||||
else -> error("unexpected click ${target.path}")
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS }
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
override fun backPurchase(): Boolean { backCount++; page = afterBack; return true }
|
||||
|
||||
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(SpecPanelFixtures.PDD, SpecPanelFixtures.ACTIVITY, nodes)
|
||||
private fun node(
|
||||
path: String, text: String, clickable: Boolean = false, className: String = "android.widget.TextView",
|
||||
bounds: NodeBounds = NodeBounds(0, 0, 500, 100), parentPath: String? = null,
|
||||
) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, false, false, false, true, true)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SUFFIX = "_cg101"
|
||||
const val ADDRESS = "测试路1号_cg101"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 808db5fb272804ca2320a629efb89cd8deb9cbb9
|
||||
synchronized_at: 2026-10-12T15:15:56Z
|
||||
wiki_revision: 5299f7358c54dfcd20ba2a1389946c11e0d8eedb
|
||||
synchronized_at: 2026-10-13T03:19:48Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -1016,6 +1016,16 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
|
||||
|
||||
合并 main `15bab9c`,2026-10-12 随发布上线,待业务验收。采集任务页新增「设备」筛选(含已停用设备),使用服务端既有 `deviceId` 参数;选择设备时「取消未开始任务」按钮禁用,因为批量取消接口不支持设备维度,避免取消范围超出当前列表。
|
||||
|
||||
## 采集任务按设备批量取消与采购员取消范围(#387)
|
||||
|
||||
合并 main `df6b4a5`,待发布和业务验收。取代 #383 中「选择设备时按钮禁用」的规则。
|
||||
|
||||
- 「取消未开始的任务」按当前筛选(来源、状态、Goods ID、设备)批量取消待执行任务;选了设备时只取消该设备的任务。
|
||||
- 采购员必须先在「设备」筛选中选择设备(下拉只含归属本人的设备),未选择时提示并停止,不发请求;服务端同样要求非管理员传 `deviceId`,且设备必须归属当前账号,否则返回 `FORBIDDEN`「只能取消归属自己设备的任务」,不取消任何任务。
|
||||
- 采购员单条取消也只能取消归属本人设备的任务;未指定设备或他人设备的任务一律拒绝(不区分任务当前状态)。
|
||||
- 管理员不受归属限制:不选设备时取消筛选范围内全部待执行任务(含未指定设备的任务),也可单条取消任意任务;暂不支持「只取消未指定设备的任务」。
|
||||
- 「设备归属」指 `agent_device.owner_user_id`,以执行取消时的归属为准。
|
||||
|
||||
## 客户端密钥上传 Agent 版本(#384)
|
||||
|
||||
合并 main `07800cf`,2026-10-12 发布上线,待业务验收。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: c05446aecd49fb7234f79f5d5e2df68f244166e9
|
||||
synchronized_at: 2026-10-12T08:31:57Z
|
||||
wiki_revision: eb957f366d0561df9af8c89b63c6a2406b3acf6c
|
||||
synchronized_at: 2026-10-13T03:20:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -250,6 +250,8 @@ POST /api/admin/v1/collection-tasks/batch
|
||||
GET /api/admin/v1/collection-tasks
|
||||
GET /api/admin/v1/collection-tasks/{taskId}
|
||||
POST /api/admin/v1/collection-tasks/{taskId}/reset
|
||||
POST /api/admin/v1/collection-tasks/{taskId}/cancel
|
||||
POST /api/admin/v1/collection-tasks/batch-cancel
|
||||
DELETE /api/admin/v1/collection-tasks/{taskId}
|
||||
```
|
||||
|
||||
@@ -292,6 +294,12 @@ HTTP 200 响应返回 `successCount`、`failureCount` 和按请求顺序排列
|
||||
|
||||
`reset` 请求体和失败任务 `DELETE` 请求体均为 `{"requestId":"uuid"}`。只允许删除 `failed`;重置允许 `completed`、`completed_partial`、`failed`。二者均按 `requestId` 幂等。
|
||||
|
||||
取消(#297、#298、#387):
|
||||
|
||||
- 单条取消 `POST /collection-tasks/{taskId}/cancel`,请求体 `{"requestId":"uuid"}`。只允许取消 `pending` 且未被设备领取(租约未生效)的任务;已取消返回幂等成功;`running` 或其他状态返回 `TASK_STATE_CONFLICT`。
|
||||
- 批量取消 `POST /collection-tasks/batch-cancel`,请求体字段均可选:`source`、`status`、`goodsId`(与列表筛选同一匹配方式)、`deviceId`。只取消范围内 `pending` 且未被领取的任务,单次最多 500 条;响应 `cancelledCount`、`cancelledIds`、`skipped[{taskId,status}]`、`hasMore`。
|
||||
- 权限:管理员不受设备归属限制,`deviceId` 可省略(范围含未指定设备的任务)。非管理员(采购员)批量取消必须传 `deviceId`,缺少时 HTTP 422 `INVALID_REQUEST`「请先选择设备」;设备不存在、未归属或归属他人时 HTTP 403 `FORBIDDEN`「只能取消归属自己设备的任务」,不取消任何任务。非管理员单条取消未指定设备或他人设备的任务同样返回 403 `FORBIDDEN`,不区分任务状态。
|
||||
|
||||
## Android:注册与心跳
|
||||
|
||||
```http
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 37630e95b7e03248546467826525f45921033f74
|
||||
synchronized_at: 2026-10-13T01:37:19Z
|
||||
wiki_revision: 19eaaa2853544e73f4abc5b788b571377e87296b
|
||||
synchronized_at: 2026-10-13T03:49:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -460,3 +460,12 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
|
||||
- 按内容验收:`/`、`/login` 新 index,10 项 JS/CSS 200,验证码 200,未登录 Admin 接口 401;`/api/client/v1/agent-app-releases` 的 GET/PUT 无密钥 401;下载路径 404(未开放);日志无 panic/fatal/1146/1054。
|
||||
- Nginx 9527 vhost `client_max_body_size 100m`,超过 100MB 的 APK 会在到达服务前被拒。
|
||||
- 回滚:current 切回 `/home/goauto/releases/20261012-15bab9c-381-382-383` 并重启。
|
||||
|
||||
## #387 采集任务按设备批量取消发布(2026-10-13)
|
||||
|
||||
- 用户授权「先把#387发布到线上」。main `5183df3`(实现 `fc5d9ed`,合并 `df6b4a5`);相对上一线上源码 `07800cf`,Server/Web 只增加 #387 的 6 个文件,无迁移、无配置变化,未做数据库备份。`go test ./app/goauto/task/` 通过;Linux amd64 `CGO_ENABLED=0` 构建、Web `build:prod` 通过。
|
||||
- 发布目录 `/home/goauto/releases/20261013-5183df3-387`:goauto-server SHA256 `85c307cf2393eaf0d6188217ddf0560d3fead3313b72ce98c1e6bf2e6ea44b23`(运行进程一致),index.html SHA256 `dfd9dadfe28fbdb500aa91ce98611850215ad8b53b908b79200f23b205960ba9`;config 与上一发布 diff 一致,static/temp/var 链接沿用,旧 js/css 哈希资源保留(`cp -rn`);目录 0755,已用 Nginx 运行账号 `www` 验证可读。
|
||||
- 切换沿用等待脚本(`/root/goauto-switch-387.sh`,复制自 #384 脚本):采集与采购执行中数量连续为 0 后原子切换并重启。11:47:24 开始等待,11:47:41 切换,`goauto.service` active;日志 `/home/goauto/backups/20261013-387-switch.log`。当时有 1 个待执行采购任务,保持原状。
|
||||
- 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 与新采集任务页资源均 200,验证码 code=200,未登录访问 `collection-tasks/batch-cancel`、`yeeke-returns` 为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线(Agent 0.9.71)。
|
||||
- 未做:线上管理员与采购员账号的功能检查(待用户验收)。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261012-07800cf-384` 并重启(先确认无执行中任务);无数据库变化。
|
||||
|
||||
Reference in New Issue
Block a user