Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2285c8394 | ||
|
|
496bf803f5 | ||
|
|
0d21ca9881 | ||
|
|
00a0c8386d | ||
|
|
5183df306f | ||
|
|
df6b4a570c | ||
|
|
fc5d9edfa6 | ||
|
|
59a483f53a | ||
|
|
328afde919 | ||
|
|
afa03ef3e4 | ||
|
|
71165b1fe7 | ||
|
|
7c0d37b512 |
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "cn.ilapage.goauto.agent"
|
applicationId = "cn.ilapage.goauto.agent"
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 83
|
versionCode = 85
|
||||||
versionName = "0.9.70"
|
versionName = "0.9.72"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
+37
-4
@@ -560,7 +560,11 @@ class PurchaseLiveAutomation(
|
|||||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||||
} else {
|
} else {
|
||||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
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)
|
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -612,9 +616,21 @@ class PurchaseLiveAutomation(
|
|||||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
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(
|
private fun restoreFinalEvidenceInCurrentPanel(
|
||||||
initial: UiSnapshot,
|
initial: UiSnapshot,
|
||||||
expected: String,
|
expected: String,
|
||||||
@@ -749,16 +765,33 @@ class PurchaseLiveAutomation(
|
|||||||
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
||||||
Regex("${Regex.escape(suffix)}(?![0-9])").containsMatchIn(normalizeAddressText(value))
|
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) {
|
repeat(50) {
|
||||||
val snapshot = driver.capture()
|
val snapshot = driver.capture()
|
||||||
pageProblem(snapshot)
|
pageProblem(snapshot)
|
||||||
if (predicate(snapshot)) return snapshot
|
if (predicate(snapshot)) return snapshot
|
||||||
|
last = snapshot
|
||||||
pause(200)
|
pause(200)
|
||||||
}
|
}
|
||||||
|
val sampled = last
|
||||||
|
if (diagnostic != null && sampled != null) fail(code, "$message [${diagnostic(sampled)}]")
|
||||||
fail(code, message)
|
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> {
|
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||||
@@ -888,7 +921,7 @@ class PurchaseLiveAutomation(
|
|||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
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}")
|
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun record(event: AgentDiagnosticEvent) {
|
fun record(event: AgentDiagnosticEvent) {
|
||||||
require(event.taskId > 0)
|
require(event.taskId > 0)
|
||||||
require(event.taskType in setOf("collection", "purchase"))
|
require(event.taskType in setOf("collection", "purchase"))
|
||||||
|
|||||||
+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,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Business-Rules-and-Glossary
|
wiki_page: Business-Rules-and-Glossary
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||||
wiki_revision: 808db5fb272804ca2320a629efb89cd8deb9cbb9
|
wiki_revision: ad21f0e3df9c5ec953d7dac437a06a0d082c1df3
|
||||||
synchronized_at: 2026-10-12T15:15:56Z
|
synchronized_at: 2026-10-13T08:23:11Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- gitea-wiki-mirror:start -->
|
||||||
@@ -721,6 +721,16 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
|||||||
### 采购手机环境前提
|
### 采购手机环境前提
|
||||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||||
|
|
||||||
|
## 采购接受证据齐全的快捷确认面板(#388)
|
||||||
|
|
||||||
|
实现 `0d21ca9`,合并 `496bf80`(2026-10-13,Android 0.9.72/85);尚未装机,真机采购效果未验证。
|
||||||
|
|
||||||
|
- 背景:地址保存后返回的规格面板可能带「已选」摘要和底部「现在买,仅 ¥…」按钮,被面板解析归为 `QUICK_CONFIRMATION`。采购原先只接受 `NORMAL_SCROLLABLE`、`NON_SCROLLABLE_CONFIRMATION`、`ORDER_CONFIRMATION` 和 `REQUIRED_EVIDENCE`,因此地址已保存成功也会以 `PURCHASE_ADDRESS_SAVE_TIMEOUT`「地址保存后无法返回订单页面」失败(14 份失败快照离线回放证实)。
|
||||||
|
- 规则:采购侧把 `QUICK_CONFIRMATION` 视为可下单面板,**仅当**同时满足 #331 必需证据:收货地址入口、支付方式入口和唯一可用的数量输入框都在同一规格面板容器内(与 `REQUIRED_EVIDENCE` 同一门槛)。缺任何一项仍不接受。
|
||||||
|
- 该判断用于地址保存后识别面板、最终确认和点击下单前的按钮定位;保存后直接停在证据完整的快捷确认面板时,会按最终证据直接通过,不再按返回键。
|
||||||
|
- 不变:采集流程中 `QUICK_CONFIRMATION` 的含义和处理;地址后缀须唯一;下单目标仍只能是面板容器内最底部的可点击整行,含支付方式别名或支付字样即拒绝;规格、单价、数量复核和只点击一次下单均不放宽。
|
||||||
|
- 诊断:按返回键后仍无法确认时,失败提示末尾附 `[stage=address_save_back;type=…;<必需证据摘要>;saved=0|1;submitTargets=N]`,只含类型、计数和 0/1,不含页面文字。
|
||||||
|
|
||||||
## 管理端售后角色(#341)
|
## 管理端售后角色(#341)
|
||||||
|
|
||||||
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
||||||
@@ -1016,6 +1026,16 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
|
|||||||
|
|
||||||
合并 main `15bab9c`,2026-10-12 随发布上线,待业务验收。采集任务页新增「设备」筛选(含已停用设备),使用服务端既有 `deviceId` 参数;选择设备时「取消未开始任务」按钮禁用,因为批量取消接口不支持设备维度,避免取消范围超出当前列表。
|
合并 main `15bab9c`,2026-10-12 随发布上线,待业务验收。采集任务页新增「设备」筛选(含已停用设备),使用服务端既有 `deviceId` 参数;选择设备时「取消未开始任务」按钮禁用,因为批量取消接口不支持设备维度,避免取消范围超出当前列表。
|
||||||
|
|
||||||
|
## 采集任务按设备批量取消与采购员取消范围(#387)
|
||||||
|
|
||||||
|
合并 main `df6b4a5`,待发布和业务验收。取代 #383 中「选择设备时按钮禁用」的规则。
|
||||||
|
|
||||||
|
- 「取消未开始的任务」按当前筛选(来源、状态、Goods ID、设备)批量取消待执行任务;选了设备时只取消该设备的任务。
|
||||||
|
- 采购员必须先在「设备」筛选中选择设备(下拉只含归属本人的设备),未选择时提示并停止,不发请求;服务端同样要求非管理员传 `deviceId`,且设备必须归属当前账号,否则返回 `FORBIDDEN`「只能取消归属自己设备的任务」,不取消任何任务。
|
||||||
|
- 采购员单条取消也只能取消归属本人设备的任务;未指定设备或他人设备的任务一律拒绝(不区分任务当前状态)。
|
||||||
|
- 管理员不受归属限制:不选设备时取消筛选范围内全部待执行任务(含未指定设备的任务),也可单条取消任意任务;暂不支持「只取消未指定设备的任务」。
|
||||||
|
- 「设备归属」指 `agent_device.owner_user_id`,以执行取消时的归属为准。
|
||||||
|
|
||||||
## 客户端密钥上传 Agent 版本(#384)
|
## 客户端密钥上传 Agent 版本(#384)
|
||||||
|
|
||||||
合并 main `07800cf`,2026-10-12 发布上线,待业务验收。
|
合并 main `07800cf`,2026-10-12 发布上线,待业务验收。
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Android-Agent-API-Contract
|
wiki_page: Android-Agent-API-Contract
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||||
wiki_revision: c05446aecd49fb7234f79f5d5e2df68f244166e9
|
wiki_revision: eb957f366d0561df9af8c89b63c6a2406b3acf6c
|
||||||
synchronized_at: 2026-10-12T08:31:57Z
|
synchronized_at: 2026-10-13T03:20:31Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- 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
|
||||||
GET /api/admin/v1/collection-tasks/{taskId}
|
GET /api/admin/v1/collection-tasks/{taskId}
|
||||||
POST /api/admin/v1/collection-tasks/{taskId}/reset
|
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}
|
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` 幂等。
|
`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:注册与心跳
|
## Android:注册与心跳
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Deployment-and-Operations
|
wiki_page: Deployment-and-Operations
|
||||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||||
wiki_revision: 7e91eea0ec077f5af813375bdd94d8878e7e16d7
|
wiki_revision: 19eaaa2853544e73f4abc5b788b571377e87296b
|
||||||
synchronized_at: 2026-10-12T15:16:03Z
|
synchronized_at: 2026-10-13T03:49:33Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
<!-- gitea-wiki-mirror:start -->
|
<!-- gitea-wiki-mirror:start -->
|
||||||
@@ -347,6 +347,25 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
|||||||
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
|
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
|
||||||
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
|
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
|
||||||
|
|
||||||
|
### Agent 装机签名对照(2026-10-13 核对)
|
||||||
|
|
||||||
|
采购机和测试机上已装的 Agent 签名不同,装包前必须按目标设备选对签名:
|
||||||
|
|
||||||
|
| 设备 | 序列号 | 已装证书 SHA-256 | 签名文件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 采购1(PKG110) | `3B65BD02H7F00000` | `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec` | `D:/OPC/goauto/debug.keystore`(私有,不提交) |
|
||||||
|
| 采购2(PLY110) | `3B663A00JW500000` | 同采购1 | 同采购1 |
|
||||||
|
| 测试机(PLY110) | `3B668T01GF400000` | `3eab2527dc3ce592fe22f2f8cc31e4d0fa07dd1fa54b6209948ea6ce9cd7c4f6` | `C:/Users/Qiu/.android/debug.keystore` |
|
||||||
|
|
||||||
|
- 本机 Gradle 调试构建产物已经是采购机签名(由上面的 init 脚本强制),可以直接装采购机;装测试机前需用 apksigner 以测试机签名改签。
|
||||||
|
- 装包前逐台核对:
|
||||||
|
- `ro.serialno` 确认目标设备(USB 连接的手机不一定是测试机);
|
||||||
|
- 用 `pm path` 拉取已装 `base.apk`,比较它与待装 APK 的 `apksigner verify --print-certs`;
|
||||||
|
- 设备空闲,没有待执行或执行中的任务。
|
||||||
|
- 签名不一致会报 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`,**不得卸载重装**:卸载会清掉设备凭据和本地采购数据。
|
||||||
|
- 装完后手动打开 Agent,等待心跳恢复;无障碍显示未连接时,在系统设置中把 GoAuto 关闭后重新打开(#385)。
|
||||||
|
- 2026-10-13 记录:0.9.71(84,main `328afde`)测试机包 SHA256 `10ad5a2b898230897b15a54f73212ae56a11fcf2f73beffc0392321699d7cf00`,采购1 包 SHA256 `3004cda3784182381e98ce9953cabcdba4017eb535c8578a2b317b3a7303e6ab`,均以 `install -r` 覆盖安装,未卸载。
|
||||||
|
|
||||||
|
|
||||||
## Android 商品导航诊断(#373,诊断阶段)
|
## Android 商品导航诊断(#373,诊断阶段)
|
||||||
|
|
||||||
@@ -441,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。
|
- 按内容验收:`/`、`/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 会在到达服务前被拒。
|
- Nginx 9527 vhost `client_max_body_size 100m`,超过 100MB 的 APK 会在到达服务前被拒。
|
||||||
- 回滚:current 切回 `/home/goauto/releases/20261012-15bab9c-381-382-383` 并重启。
|
- 回滚: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` 并重启(先确认无执行中任务);无数据库变化。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (handler Handler) AdminList(c *gin.Context) {
|
func (handler Handler) AdminList(c *gin.Context) {
|
||||||
@@ -176,7 +177,7 @@ func (handler Handler) AdminCancel(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response, err := service.Cancel(c.Request.Context(), id, request)
|
response, err := service.CancelOwnedBy(c.Request.Context(), id, request, ownerScope(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -194,7 +195,7 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response, err := service.BatchCancel(c.Request.Context(), request)
|
response, err := service.BatchCancelOwnedBy(c.Request.Context(), request, ownerScope(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(c, err)
|
writeError(c, err)
|
||||||
return
|
return
|
||||||
@@ -202,6 +203,37 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerScope 返回取消操作的归属限制:管理员返回 nil(不限制),其他角色返回
|
||||||
|
// 当前用户 id(取不到时为 0,此时任何设备都不算本人设备)。读取方式与
|
||||||
|
// device/handler.go 一致(JWT rolekey / identity,#333)。
|
||||||
|
func ownerScope(c *gin.Context) *uint64 {
|
||||||
|
if role, _ := jwt.ExtractClaims(c)["rolekey"].(string); role == "admin" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := currentUserID(c)
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUserID(c *gin.Context) uint64 {
|
||||||
|
switch id := jwt.ExtractClaims(c)["identity"].(type) {
|
||||||
|
case float64:
|
||||||
|
if id > 0 {
|
||||||
|
return uint64(id)
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
if id > 0 {
|
||||||
|
return uint64(id)
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if id > 0 {
|
||||||
|
return uint64(id)
|
||||||
|
}
|
||||||
|
case uint64:
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func decodeStrict(c *gin.Context, destination any) error {
|
func decodeStrict(c *gin.Context, destination any) error {
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||||
decoder := json.NewDecoder(c.Request.Body)
|
decoder := json.NewDecoder(c.Request.Body)
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ import (
|
|||||||
|
|
||||||
const maxBatchCancelItems = 500
|
const maxBatchCancelItems = 500
|
||||||
|
|
||||||
|
const messageNotOwnDevice = "只能取消归属自己设备的任务"
|
||||||
|
|
||||||
|
// deviceOwnedBy 判断设备是否归属指定用户;设备为空、不存在或未归属都视为否。
|
||||||
|
func deviceOwnedBy(db *gorm.DB, deviceID *uint64, ownerUserID uint64) (bool, error) {
|
||||||
|
if deviceID == nil || *deviceID == 0 || ownerUserID == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&models.AgentDevice{}).Where("id = ? AND owner_user_id = ?", *deviceID, ownerUserID).Count(&count).Error; err != nil {
|
||||||
|
return false, internalError(err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CancelResponse describes the outcome of cancelling one pending collection task.
|
// CancelResponse describes the outcome of cancelling one pending collection task.
|
||||||
type CancelResponse struct {
|
type CancelResponse struct {
|
||||||
TaskID uint64 `json:"taskId"`
|
TaskID uint64 `json:"taskId"`
|
||||||
@@ -31,6 +45,9 @@ type BatchCancelRequest struct {
|
|||||||
// 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用
|
// 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用
|
||||||
// 「筛选 + 一个按钮」想避免的事(#298)。
|
// 「筛选 + 一个按钮」想避免的事(#298)。
|
||||||
GoodsID string `json:"goodsId,omitempty"`
|
GoodsID string `json:"goodsId,omitempty"`
|
||||||
|
// DeviceID 按设备限定取消范围(#387)。管理员可不传(范围与之前一致,含
|
||||||
|
// 未指定设备的任务);非管理员必须传,且必须是归属自己的设备。
|
||||||
|
DeviceID uint64 `json:"deviceId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchCancelSkippedItem struct {
|
type BatchCancelSkippedItem struct {
|
||||||
@@ -57,6 +74,13 @@ type BatchCancelResponse struct {
|
|||||||
// failure #292 already paid for once. The semantics are "stop what has not
|
// failure #292 already paid for once. The semantics are "stop what has not
|
||||||
// started, let what is running finish".
|
// started, let what is running finish".
|
||||||
func (service *Service) Cancel(ctx context.Context, taskID uint64, request ActionRequest) (CancelResponse, error) {
|
func (service *Service) Cancel(ctx context.Context, taskID uint64, request ActionRequest) (CancelResponse, error) {
|
||||||
|
return service.CancelOwnedBy(ctx, taskID, request, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelOwnedBy 与 Cancel 相同,但 ownerUserID 非空时只允许取消归属该用户设备
|
||||||
|
// 的任务(#387)。未指定设备、设备不存在或归属他人的任务一律返回同一个拒绝,
|
||||||
|
// 不区分任务当前状态,避免向采购员泄露不属于他的任务信息。
|
||||||
|
func (service *Service) CancelOwnedBy(ctx context.Context, taskID uint64, request ActionRequest, ownerUserID *uint64) (CancelResponse, error) {
|
||||||
if err := validateAction(taskID, request); err != nil {
|
if err := validateAction(taskID, request); err != nil {
|
||||||
return CancelResponse{}, err
|
return CancelResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -69,6 +93,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
|||||||
}
|
}
|
||||||
return internalError(err)
|
return internalError(err)
|
||||||
}
|
}
|
||||||
|
if ownerUserID != nil {
|
||||||
|
owned, err := deviceOwnedBy(tx, record.DeviceID, *ownerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return serviceError(CodeForbidden, messageNotOwnDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
if record.Status == models.TaskStatusCancelled {
|
if record.Status == models.TaskStatusCancelled {
|
||||||
response = CancelResponse{TaskID: taskID, Cancelled: true}
|
response = CancelResponse{TaskID: taskID, Cancelled: true}
|
||||||
return nil
|
return nil
|
||||||
@@ -88,9 +121,13 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
|||||||
// 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。
|
// 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。
|
||||||
// RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。
|
// RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。
|
||||||
now := service.Now()
|
now := service.Now()
|
||||||
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
|
update := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
|
||||||
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now).
|
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now)
|
||||||
Updates(map[string]any{
|
if ownerUserID != nil {
|
||||||
|
// 上面已校验归属;这里在写入条件里再兜一层,防止校验与更新之间归属变化。
|
||||||
|
update = update.Where("device_id IN (SELECT id FROM agent_device WHERE owner_user_id = ?)", *ownerUserID)
|
||||||
|
}
|
||||||
|
result := update.Updates(map[string]any{
|
||||||
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
|
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
|
||||||
})
|
})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -115,6 +152,25 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
|||||||
// 回滚已经成功取消的其它任务。采购员要的是「能停多少停多少」,而不是全有或
|
// 回滚已经成功取消的其它任务。采购员要的是「能停多少停多少」,而不是全有或
|
||||||
// 全无——批量越大,中途有一条被领走的概率越高。
|
// 全无——批量越大,中途有一条被领走的概率越高。
|
||||||
func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequest) (BatchCancelResponse, error) {
|
func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequest) (BatchCancelResponse, error) {
|
||||||
|
return service.BatchCancelOwnedBy(ctx, request, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCancelOwnedBy 与 BatchCancel 相同,但 ownerUserID 非空(非管理员)时
|
||||||
|
// 必须指定 DeviceID,且该设备必须归属 ownerUserID,否则不取消任何任务(#387)。
|
||||||
|
func (service *Service) BatchCancelOwnedBy(ctx context.Context, request BatchCancelRequest, ownerUserID *uint64) (BatchCancelResponse, error) {
|
||||||
|
if ownerUserID != nil {
|
||||||
|
if request.DeviceID == 0 {
|
||||||
|
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "请先选择设备")
|
||||||
|
}
|
||||||
|
deviceID := request.DeviceID
|
||||||
|
owned, err := deviceOwnedBy(service.DB.WithContext(ctx), &deviceID, *ownerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return BatchCancelResponse{}, err
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return BatchCancelResponse{}, serviceError(CodeForbidden, messageNotOwnDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
source := strings.TrimSpace(request.Source)
|
source := strings.TrimSpace(request.Source)
|
||||||
if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch {
|
if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch {
|
||||||
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "source 无效")
|
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "source 无效")
|
||||||
@@ -135,6 +191,9 @@ func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequ
|
|||||||
if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" {
|
if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" {
|
||||||
query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%")
|
query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%")
|
||||||
}
|
}
|
||||||
|
if request.DeviceID > 0 {
|
||||||
|
query = query.Where("device_id = ?", request.DeviceID)
|
||||||
|
}
|
||||||
var candidates []models.CollectionTask
|
var candidates []models.CollectionTask
|
||||||
// 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。
|
// 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。
|
||||||
if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil {
|
if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go-admin/app/goauto/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #387:批量取消按设备限定,单条/批量取消对非管理员强制设备归属。
|
||||||
|
|
||||||
|
func ownDevice(t *testing.T, db *gorm.DB, name string, owner uint64) models.AgentDevice {
|
||||||
|
t.Helper()
|
||||||
|
record, _ := registerTaskDevice(t, db, name)
|
||||||
|
if owner > 0 {
|
||||||
|
if err := db.Model(&models.AgentDevice{}).Where("id = ?", record.ID).Update("owner_user_id", owner).Error; err != nil {
|
||||||
|
t.Fatalf("set owner: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskStatus(t *testing.T, db *gorm.DB, id uint64) string {
|
||||||
|
t.Helper()
|
||||||
|
var stored models.CollectionTask
|
||||||
|
if err := db.First(&stored, id).Error; err != nil {
|
||||||
|
t.Fatalf("load task: %v", err)
|
||||||
|
}
|
||||||
|
return stored.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func callCancel(t *testing.T, db *gorm.DB, claims jwt.MapClaims, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
engine := gin.New()
|
||||||
|
handler := Handler{DB: db}
|
||||||
|
setClaims := func(c *gin.Context) {
|
||||||
|
c.Set(jwt.JwtPayloadKey, claims)
|
||||||
|
c.Set("userId", 0)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
engine.POST("/batch-cancel", setClaims, handler.AdminBatchCancel)
|
||||||
|
engine.POST("/:taskId/cancel", setClaims, handler.AdminCancel)
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload)))
|
||||||
|
var decoded map[string]any
|
||||||
|
_ = json.Unmarshal(recorder.Body.Bytes(), &decoded)
|
||||||
|
return recorder, decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchaser(id float64) jwt.MapClaims {
|
||||||
|
return jwt.MapClaims{"rolekey": "purchaser", "identity": id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminClaims() jwt.MapClaims { return jwt.MapClaims{"rolekey": "admin", "identity": float64(1)} }
|
||||||
|
|
||||||
|
func TestBatchCancelWithDeviceIDOnlyCancelsThatDevice(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
service := newTaskService(db)
|
||||||
|
one := ownDevice(t, db, "one", 3)
|
||||||
|
two := ownDevice(t, db, "two", 3)
|
||||||
|
onOne := createTask(t, db, &one.ID)
|
||||||
|
onTwo := createTask(t, db, &two.ID)
|
||||||
|
unassigned := createTask(t, db, nil)
|
||||||
|
|
||||||
|
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{DeviceID: one.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch cancel: %v", err)
|
||||||
|
}
|
||||||
|
if response.CancelledCount != 1 || response.CancelledIDs[0] != onOne.ID {
|
||||||
|
t.Fatalf("expected only device one task cancelled, got %+v", response)
|
||||||
|
}
|
||||||
|
if taskStatus(t, db, onTwo.ID) != models.TaskStatusPending || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
|
||||||
|
t.Fatalf("other device and unassigned tasks must stay pending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCancelWithoutDeviceIDKeepsFullScope(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
service := newTaskService(db)
|
||||||
|
one := ownDevice(t, db, "one", 3)
|
||||||
|
createTask(t, db, &one.ID)
|
||||||
|
createTask(t, db, nil)
|
||||||
|
|
||||||
|
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch cancel: %v", err)
|
||||||
|
}
|
||||||
|
if response.CancelledCount != 2 {
|
||||||
|
t.Fatalf("expected both tasks cancelled including unassigned, got %+v", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCancelPurchaserRequiresDeviceID(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
one := ownDevice(t, db, "one", 3)
|
||||||
|
record := createTask(t, db, &one.ID)
|
||||||
|
|
||||||
|
for _, body := range []map[string]any{{}, {"deviceId": 0}} {
|
||||||
|
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", body)
|
||||||
|
if recorder.Code != http.StatusUnprocessableEntity || decoded["code"] != "INVALID_REQUEST" || decoded["message"] != "请先选择设备" {
|
||||||
|
t.Fatalf("expected INVALID_REQUEST, got %d %v", recorder.Code, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if taskStatus(t, db, record.ID) != models.TaskStatusPending {
|
||||||
|
t.Fatalf("no task may be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCancelPurchaserOwnDeviceSucceeds(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
own := ownDevice(t, db, "own", 3)
|
||||||
|
other := ownDevice(t, db, "other", 4)
|
||||||
|
mine := createTask(t, db, &own.ID)
|
||||||
|
theirs := createTask(t, db, &other.ID)
|
||||||
|
|
||||||
|
recorder, _ := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": own.ID})
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if taskStatus(t, db, mine.ID) != models.TaskStatusCancelled || taskStatus(t, db, theirs.ID) != models.TaskStatusPending {
|
||||||
|
t.Fatalf("expected only own device task cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCancelPurchaserRejectsForeignUnownedAndMissingDevices(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
foreign := ownDevice(t, db, "foreign", 4)
|
||||||
|
unowned := ownDevice(t, db, "unowned", 0)
|
||||||
|
foreignTask := createTask(t, db, &foreign.ID)
|
||||||
|
unownedTask := createTask(t, db, &unowned.ID)
|
||||||
|
|
||||||
|
for name, id := range map[string]uint64{"foreign": foreign.ID, "unowned": unowned.ID, "missing": 999999} {
|
||||||
|
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": id})
|
||||||
|
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
|
||||||
|
t.Fatalf("%s: expected 403 FORBIDDEN, got %d %v", name, recorder.Code, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if taskStatus(t, db, foreignTask.ID) != models.TaskStatusPending || taskStatus(t, db, unownedTask.ID) != models.TaskStatusPending {
|
||||||
|
t.Fatalf("rejected requests must not cancel anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCancelMissingIdentityIsRejected(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
unowned := ownDevice(t, db, "unowned", 0)
|
||||||
|
record := createTask(t, db, &unowned.ID)
|
||||||
|
recorder, _ := callCancel(t, db, jwt.MapClaims{"rolekey": "purchaser"}, "/batch-cancel", map[string]any{"deviceId": unowned.ID})
|
||||||
|
if recorder.Code != http.StatusForbidden || taskStatus(t, db, record.ID) != models.TaskStatusPending {
|
||||||
|
t.Fatalf("expected rejection, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCancelAdminAnyDeviceAndUnassigned(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
foreign := ownDevice(t, db, "foreign", 4)
|
||||||
|
onForeign := createTask(t, db, &foreign.ID)
|
||||||
|
unassigned := createTask(t, db, nil)
|
||||||
|
|
||||||
|
recorder, _ := callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{"deviceId": foreign.ID})
|
||||||
|
if recorder.Code != http.StatusOK || taskStatus(t, db, onForeign.ID) != models.TaskStatusCancelled || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
|
||||||
|
t.Fatalf("admin with deviceId should cancel only that device, code=%d", recorder.Code)
|
||||||
|
}
|
||||||
|
recorder, _ = callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{})
|
||||||
|
if recorder.Code != http.StatusOK || taskStatus(t, db, unassigned.ID) != models.TaskStatusCancelled {
|
||||||
|
t.Fatalf("admin without deviceId should cancel unassigned tasks, code=%d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleCancelPurchaserOwnership(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
own := ownDevice(t, db, "own", 3)
|
||||||
|
foreign := ownDevice(t, db, "foreign", 4)
|
||||||
|
mine := createTask(t, db, &own.ID)
|
||||||
|
theirs := createTask(t, db, &foreign.ID)
|
||||||
|
unassigned := createTask(t, db, nil)
|
||||||
|
body := map[string]any{"requestId": uuid.NewString()}
|
||||||
|
|
||||||
|
for name, id := range map[string]uint64{"foreign": theirs.ID, "unassigned": unassigned.ID} {
|
||||||
|
recorder, decoded := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", id), body)
|
||||||
|
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
|
||||||
|
t.Fatalf("%s: expected 403, got %d %v", name, recorder.Code, decoded)
|
||||||
|
}
|
||||||
|
if taskStatus(t, db, id) != models.TaskStatusPending {
|
||||||
|
t.Fatalf("%s: status must be unchanged", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recorder, _ := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", mine.ID), body)
|
||||||
|
if recorder.Code != http.StatusOK || taskStatus(t, db, mine.ID) != models.TaskStatusCancelled {
|
||||||
|
t.Fatalf("own device task should cancel, code=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
// 他人设备上已取消的任务也不能通过幂等分支探测。
|
||||||
|
if _, err := newTaskService(db).Cancel(context.Background(), theirs.ID, ActionRequest{RequestID: uuid.NewString()}); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
recorder, _ = callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", theirs.ID), body)
|
||||||
|
if recorder.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("foreign cancelled task must still be forbidden, got %d", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleCancelAdminUnaffected(t *testing.T) {
|
||||||
|
db := openTaskDatabase(t)
|
||||||
|
foreign := ownDevice(t, db, "foreign", 4)
|
||||||
|
theirs := createTask(t, db, &foreign.ID)
|
||||||
|
unassigned := createTask(t, db, nil)
|
||||||
|
for _, id := range []uint64{theirs.ID, unassigned.ID} {
|
||||||
|
recorder, _ := callCancel(t, db, adminClaims(), fmt.Sprintf("/%d/cancel", id), map[string]any{"requestId": uuid.NewString()})
|
||||||
|
if recorder.Code != http.StatusOK || taskStatus(t, db, id) != models.TaskStatusCancelled {
|
||||||
|
t.Fatalf("admin cancel of %d failed: %d", id, recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,7 +317,7 @@ func writeError(context *gin.Context, err error) {
|
|||||||
status = http.StatusUnprocessableEntity
|
status = http.StatusUnprocessableEntity
|
||||||
case device.CodeTokenInvalid:
|
case device.CodeTokenInvalid:
|
||||||
status = http.StatusUnauthorized
|
status = http.StatusUnauthorized
|
||||||
case device.CodeDeviceDisabled:
|
case device.CodeDeviceDisabled, CodeForbidden:
|
||||||
status = http.StatusForbidden
|
status = http.StatusForbidden
|
||||||
case CodeTaskNotFound:
|
case CodeTaskNotFound:
|
||||||
status = http.StatusNotFound
|
status = http.StatusNotFound
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
DefaultLeaseDuration = 2 * time.Minute
|
DefaultLeaseDuration = 2 * time.Minute
|
||||||
DefaultTaskTimeout = 120
|
DefaultTaskTimeout = 120
|
||||||
|
|
||||||
|
CodeForbidden = "FORBIDDEN"
|
||||||
CodeTaskNotFound = "TASK_NOT_FOUND"
|
CodeTaskNotFound = "TASK_NOT_FOUND"
|
||||||
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
|
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
|
||||||
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
|
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||||
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
|
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
|
||||||
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span class="action-count">{{ pendingCancelCount }}</span></el-button>
|
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="!cancelNeedsDevice && pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span v-if="!cancelNeedsDevice" class="action-count">{{ pendingCancelCount }}</span></el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-table v-loading="loading" :data="tasks" border stripe empty-text="暂无采集任务">
|
<el-table v-loading="loading" :data="tasks" border stripe empty-text="暂无采集任务">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<el-dialog v-model="cancelBatch.open" title="取消未开始的任务" width="680px" :close-on-click-modal="false">
|
<el-dialog v-model="cancelBatch.open" title="取消未开始的任务" width="680px" :close-on-click-modal="false">
|
||||||
<el-alert
|
<el-alert
|
||||||
:title="`将取消整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下所有待执行任务,不只是当前页。`"
|
:title="`将取消${cancelDeviceLabel}上、整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下的所有待执行任务,不只是当前页。`"
|
||||||
type="warning" :closable="false" show-icon class="dialog-alert" />
|
type="warning" :closable="false" show-icon class="dialog-alert" />
|
||||||
<div v-loading="cancelBatch.previewLoading">
|
<div v-loading="cancelBatch.previewLoading">
|
||||||
<p>共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:</p>
|
<p>共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:</p>
|
||||||
@@ -114,6 +114,16 @@ export default {
|
|||||||
cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 }
|
cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||||
|
// #387:采购员未选设备时不能批量取消(服务端同样拒绝);管理员不受限。
|
||||||
|
cancelNeedsDevice() { return !this.isAdmin && !this.query.deviceId },
|
||||||
|
cancelDeviceLabel() {
|
||||||
|
if (!this.query.deviceId) return '全部设备(含未指定设备)'
|
||||||
|
const found = this.deviceFilterOptions.find(item => item.id === this.query.deviceId)
|
||||||
|
return `设备「${found ? found.name : this.query.deviceId}」`
|
||||||
|
}
|
||||||
|
},
|
||||||
created() { this.getList(); this.refreshPendingCancelCount(); this.loadDeviceFilterOptions() },
|
created() { this.getList(); this.refreshPendingCancelCount(); this.loadDeviceFilterOptions() },
|
||||||
watch: {
|
watch: {
|
||||||
// `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID
|
// `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID
|
||||||
@@ -135,20 +145,21 @@ export default {
|
|||||||
// 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选
|
// 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选
|
||||||
// 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个
|
// 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个
|
||||||
// 与实际可取消数量无关的数字误导采购员。
|
// 与实际可取消数量无关的数字误导采购员。
|
||||||
// 批量取消接口不支持设备维度;选了设备筛选时禁用,避免取消范围超出眼前列表。
|
// #387:设备维度也参与取消范围;采购员必须先选设备,且服务端只允许取消本人设备的任务。
|
||||||
cancelScopeStatus() { if (this.query.deviceId) return null; return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
|
cancelScopeStatus() { return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
|
||||||
async refreshPendingCancelCount() {
|
async refreshPendingCancelCount() {
|
||||||
const status = this.cancelScopeStatus()
|
const status = this.cancelScopeStatus()
|
||||||
if (!status) { this.pendingCancelCount = 0; return }
|
if (!status || this.cancelNeedsDevice) { this.pendingCancelCount = 0; return }
|
||||||
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
||||||
this.pendingCancelCount = r.data.total
|
this.pendingCancelCount = r.data.total
|
||||||
},
|
},
|
||||||
async openCancelBatch() {
|
async openCancelBatch() {
|
||||||
|
if (this.cancelNeedsDevice) { ElMessage.warning('请先在「设备」筛选中选择要取消任务的设备'); return }
|
||||||
const status = this.cancelScopeStatus()
|
const status = this.cancelScopeStatus()
|
||||||
if (!status) return
|
if (!status) return
|
||||||
this.cancelBatch = { open: true, previewLoading: true, submitting: false, preview: [], previewTotal: 0 }
|
this.cancelBatch = { open: true, previewLoading: true, submitting: false, preview: [], previewTotal: 0 }
|
||||||
try {
|
try {
|
||||||
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
||||||
this.cancelBatch.preview = r.data.items
|
this.cancelBatch.preview = r.data.items
|
||||||
this.cancelBatch.previewTotal = r.data.total
|
this.cancelBatch.previewTotal = r.data.total
|
||||||
} finally { this.cancelBatch.previewLoading = false }
|
} finally { this.cancelBatch.previewLoading = false }
|
||||||
@@ -158,7 +169,7 @@ export default {
|
|||||||
if (!status) return
|
if (!status) return
|
||||||
this.cancelBatch.submitting = true
|
this.cancelBatch.submitting = true
|
||||||
try {
|
try {
|
||||||
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, goodsId: this.query.goodsId || undefined })
|
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, deviceId: this.query.deviceId || undefined, goodsId: this.query.goodsId || undefined })
|
||||||
const skippedText = response.data.skipped?.length ? `,${response.data.skipped.length} 个因执行中或已被设备领取而跳过(任务号:${response.data.skipped.map(item => item.taskId).join('、')})` : ''
|
const skippedText = response.data.skipped?.length ? `,${response.data.skipped.length} 个因执行中或已被设备领取而跳过(任务号:${response.data.skipped.map(item => item.taskId).join('、')})` : ''
|
||||||
ElMessage.success(`已取消 ${response.data.cancelledCount} 个任务${skippedText}`)
|
ElMessage.success(`已取消 ${response.data.cancelledCount} 个任务${skippedText}`)
|
||||||
if (response.data.hasMore) {
|
if (response.data.hasMore) {
|
||||||
|
|||||||
Reference in New Issue
Block a user