Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9076d6f6 | ||
|
|
5f329d2311 | ||
|
|
b2285c8394 | ||
|
|
496bf803f5 | ||
|
|
0d21ca9881 | ||
|
|
00a0c8386d | ||
|
|
5183df306f | ||
|
|
df6b4a570c | ||
|
|
fc5d9edfa6 | ||
|
|
59a483f53a | ||
|
|
328afde919 | ||
|
|
afa03ef3e4 | ||
|
|
71165b1fe7 | ||
|
|
7c0d37b512 | ||
|
|
68eccade03 | ||
|
|
d4cf2bbb3e | ||
|
|
07800cf41e | ||
|
|
6107c235cf | ||
|
|
19484f0a68 | ||
|
|
3495de96a2 | ||
|
|
ac112b59b5 | ||
|
|
15bab9c989 | ||
|
|
0893b55efc | ||
|
|
c5709a0f3a | ||
|
|
0745b2d894 | ||
|
|
9e54263ee6 | ||
|
|
ac7f81141c | ||
|
|
6cc3bb98d0 | ||
|
|
cc4a55cf60 | ||
|
|
7897d9925b | ||
|
|
16e3d35b8f | ||
|
|
82ed6664b7 | ||
|
|
f334c1dcb0 | ||
|
|
4039fdae2b | ||
|
|
26562fc763 | ||
|
|
d3d3837014 | ||
|
|
a0d2416c49 | ||
|
|
ff8c5361cb | ||
|
|
d45a9b9a24 | ||
|
|
b86f858cb7 | ||
|
|
e966d0f702 | ||
|
|
2ae3e8115f | ||
|
|
9619c38ae3 | ||
|
|
037459391a | ||
|
|
74b71e2b5a | ||
|
|
2f24b4b5b4 | ||
|
|
d8b4f3a9d5 | ||
|
|
23dafcf73f | ||
|
|
3d5e9646f6 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 82
|
||||
versionName = "0.9.69"
|
||||
versionCode = 85
|
||||
versionName = "0.9.72"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -30,9 +30,6 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = "-373-fix1"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
@@ -12,11 +12,15 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityPauseText
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import cn.ilapage.goauto.agent.ui.AgentSettingsFragment
|
||||
import cn.ilapage.goauto.agent.ui.AgentUiVisibility
|
||||
import cn.ilapage.goauto.agent.ui.AgentStatusFragment
|
||||
import cn.ilapage.goauto.agent.ui.TaskHistoryFragment
|
||||
import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private var selectedTab = TAB_STATUS
|
||||
@@ -30,6 +34,18 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private var accessibilityDialog: androidx.appcompat.app.AlertDialog? = null
|
||||
private val accessibilityReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
|
||||
if (intent?.action != AgentForegroundService.ACTION_ACCESSIBILITY_ALERT) return
|
||||
val readiness = runCatching {
|
||||
AccessibilityReadiness.valueOf(intent.getStringExtra(AgentForegroundService.EXTRA_ACCESSIBILITY_READINESS).orEmpty())
|
||||
}.getOrNull() ?: return
|
||||
showAccessibilityDialog(readiness)
|
||||
}
|
||||
}
|
||||
private var accessibilityReceiverRegistered = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
selectedTab = AgentTabSelectionPolicy.initialTab(
|
||||
@@ -53,9 +69,15 @@ class MainActivity : AppCompatActivity() {
|
||||
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
|
||||
screenPolicyHandler.post(screenPolicyRefresh)
|
||||
AgentUiVisibility.resumed = true
|
||||
registerAccessibilityReceiver()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
AgentUiVisibility.resumed = false
|
||||
unregisterAccessibilityReceiver()
|
||||
accessibilityDialog?.dismiss()
|
||||
accessibilityDialog = null
|
||||
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
|
||||
super.onPause()
|
||||
}
|
||||
@@ -65,6 +87,38 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun showAccessibilityDialog(readiness: AccessibilityReadiness) {
|
||||
accessibilityDialog?.dismiss()
|
||||
accessibilityDialog = null
|
||||
val detail = AccessibilityPauseText.recovery(readiness) ?: return // READY:恢复,仅关闭弹窗
|
||||
accessibilityDialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(AccessibilityPauseText.TITLE)
|
||||
.setMessage(detail)
|
||||
.setNegativeButton("稍后", null)
|
||||
.setPositiveButton("去设置") { _, _ ->
|
||||
startActivity(android.content.Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun registerAccessibilityReceiver() {
|
||||
if (accessibilityReceiverRegistered) return
|
||||
val filter = android.content.IntentFilter(AgentForegroundService.ACTION_ACCESSIBILITY_ALERT)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(accessibilityReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
registerReceiver(accessibilityReceiver, filter)
|
||||
}
|
||||
accessibilityReceiverRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterAccessibilityReceiver() {
|
||||
if (!accessibilityReceiverRegistered) return
|
||||
runCatching { unregisterReceiver(accessibilityReceiver) }
|
||||
accessibilityReceiverRegistered = false
|
||||
}
|
||||
|
||||
private fun buildContent(): LinearLayout {
|
||||
val content = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
|
||||
+355
-59
@@ -69,6 +69,9 @@ interface PddCollectorDriver {
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
|
||||
// Why the latest swipeSpec returned false (anchorMissing / anchorAmbiguous
|
||||
// mean the gesture anchor no longer resolves to exactly one node).
|
||||
fun specRowSwipeFailureReason(): String = "unknown"
|
||||
fun pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor)
|
||||
fun pullDownGoodsPage(): Boolean
|
||||
fun back(): Boolean
|
||||
@@ -169,6 +172,8 @@ data class ParsedPddScreen(
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
|
||||
val finalSelectionSummaries: List<String> = emptyList(),
|
||||
/** #379: option cards whose truncated caption (price + badge) was merged into the outer card; count only. */
|
||||
val badgeCaptionMerged: Int = 0,
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
@@ -245,6 +250,8 @@ object PddScreenParser {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
|
||||
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
|
||||
private val badgeCaptionSuffix =
|
||||
Regex("[\\s\\u00a0\\u3000]*[¥¥][\\s\\u00a0\\u3000]*(\\d+(?:\\.\\d{1,2})?)[\\s\\u00a0\\u3000]+[^¥¥\\d\\s\\u00a0\\u3000]{1,8}$")
|
||||
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
|
||||
private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)")
|
||||
private val excludedOptionWords = listOf(
|
||||
@@ -371,6 +378,7 @@ object PddScreenParser {
|
||||
visible.filter { it.path == region.path || it.path.startsWith("${region.path}/") }
|
||||
} ?: visible
|
||||
val headings = panelVisible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top }
|
||||
val badgeMergedBlocks = mutableSetOf<String>()
|
||||
val dimensions = buildList<VisibleDimension> {
|
||||
headings.forEachIndexed { index, heading ->
|
||||
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
||||
@@ -394,7 +402,7 @@ object PddScreenParser {
|
||||
// node is still chosen by safeOptionRank among the members.
|
||||
val values = candidates
|
||||
.groupBy { candidate ->
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion) { badgeMergedBlocks += it }?.path ?: candidate.path
|
||||
}
|
||||
.map { (blockPath, members) ->
|
||||
val block = visibleByPath[blockPath]
|
||||
@@ -593,6 +601,7 @@ object PddScreenParser {
|
||||
problem = problem,
|
||||
sourceNodes = visibleNodes,
|
||||
isPddPackage = snapshot.packageName == PDD_PACKAGE,
|
||||
badgeCaptionMerged = badgeMergedBlocks.size,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -612,6 +621,7 @@ object PddScreenParser {
|
||||
visibleByPath: Map<String, SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
inValueRegion: (SnapshotNode) -> Boolean,
|
||||
onBadgeMerge: (String) -> Unit,
|
||||
): SnapshotNode? {
|
||||
fun compact(value: String) = value.replace(Regex("\\s+"), "")
|
||||
var block: SnapshotNode? = null
|
||||
@@ -626,7 +636,7 @@ object PddScreenParser {
|
||||
val outer = compact(resolved.label)
|
||||
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||
val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) }
|
||||
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break
|
||||
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath) { onBadgeMerge(resolved.path) }) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
@@ -656,6 +666,7 @@ object PddScreenParser {
|
||||
block: SnapshotNode,
|
||||
members: List<SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
onBadgeMerge: () -> Unit,
|
||||
): Boolean {
|
||||
fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() }
|
||||
val full = name(block.label)
|
||||
@@ -664,7 +675,11 @@ object PddScreenParser {
|
||||
val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false
|
||||
val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false
|
||||
if (children.size != 2 || name(image.label) != full) return false
|
||||
val short = name(caption.label)
|
||||
// #379: a caption such as "名称...\n ¥11.8 零差评" keeps a price plus a badge
|
||||
// after its truncated name, which the terminal-price grammar cannot strip.
|
||||
val plainCaption = SpecValueNormalizer.normalizeColor(caption.label)
|
||||
val badgeName = if (plainCaption.any { it == '¥' || it == '¥' }) badgeCaptionName(caption.label, block.label) else null
|
||||
val short = (badgeName ?: plainCaption).filterNot { it.isWhitespace() }
|
||||
if (short.isEmpty() || short == full || !full.startsWith(short)) return false
|
||||
|
||||
val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") }
|
||||
@@ -682,7 +697,24 @@ object PddScreenParser {
|
||||
minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false
|
||||
|
||||
val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) }
|
||||
return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
|
||||
val merged = prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
|
||||
if (merged && badgeName != null) onBadgeMerge()
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* #379: truncated name of a caption shaped `名称... ¥金额 徽标`, or null. The
|
||||
* badge is 1-8 characters without currency symbols, digits or whitespace and
|
||||
* the amount must equal the outer card's terminal price.
|
||||
*/
|
||||
private fun badgeCaptionName(captionLabel: String, fullLabel: String): String? {
|
||||
val match = badgeCaptionSuffix.find(captionLabel) ?: return null
|
||||
val amount = match.groupValues[1].toBigDecimalOrNull() ?: return null
|
||||
val fullAmount = SpecValueNormalizer.trailingPriceAmount(fullLabel) ?: return null
|
||||
if (amount.compareTo(fullAmount) != 0) return null
|
||||
val head = captionLabel.substring(0, match.range.first).filterNot { it.isWhitespace() }
|
||||
.replace(Regex("(?:\\.{3}|…)+$"), "")
|
||||
return head.takeIf { it.isNotEmpty() && head.none { ch -> ch == '¥' || ch == '¥' } }
|
||||
}
|
||||
|
||||
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||
@@ -1319,7 +1351,7 @@ class PddProductDetailCollector(
|
||||
val missing = linkedSetOf<String>()
|
||||
val unsupported = linkedSetOf<String>()
|
||||
val colorImages = linkedMapOf<String, ByteArray>()
|
||||
val colorNormalization = ColorNormalizationTracker()
|
||||
val colorNormalization = ColorNormalizationTracker(trace)
|
||||
val specPanelContainer = openedScreen.specPanelContainer
|
||||
collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported, colorImages, colorNormalization)?.let { return it }
|
||||
|
||||
@@ -1328,15 +1360,20 @@ class PddProductDetailCollector(
|
||||
prices.clear()
|
||||
colorImages.clear()
|
||||
missing += "spec_dimension_invalid:color"
|
||||
} else if (colorNormalization.conflict) {
|
||||
// #379: same normalized name from different raw labels keeps one value.
|
||||
missing += "spec_dimension_conflict:color"
|
||||
}
|
||||
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
||||
val sizes = linkedMapOf<String, Boolean>()
|
||||
val sizeNormalization = SizeNormalizationTracker()
|
||||
val sizeNormalization = SizeNormalizationTracker(trace)
|
||||
collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported, sizeNormalization)?.let { return it }
|
||||
|
||||
if (sizeNormalization.invalid) {
|
||||
sizes.clear()
|
||||
missing += "spec_dimension_invalid:size"
|
||||
} else if (sizeNormalization.conflict) {
|
||||
missing += "spec_dimension_conflict:size"
|
||||
}
|
||||
|
||||
if (colors.isEmpty()) missing += "color"
|
||||
@@ -1412,6 +1449,14 @@ class PddProductDetailCollector(
|
||||
// These signatures stay in memory; only the aggregate tri-state is saved.
|
||||
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
|
||||
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
||||
// Keep the earliest abnormal reason; later normal ends must not hide it.
|
||||
fun terminate(reason: AgentDiagnosticReason) {
|
||||
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND ||
|
||||
diagnosticTermination == AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
) {
|
||||
diagnosticTermination = reason
|
||||
}
|
||||
}
|
||||
val imageAttempts = mutableSetOf<String>()
|
||||
|
||||
fun collectVisibleImages(values: List<VisibleSpecValue>) {
|
||||
@@ -1448,7 +1493,7 @@ class PddProductDetailCollector(
|
||||
diagnosticRowValues.getOrPut(index) { linkedSetOf() }.addAll(row.map(VisibleSpecValue::text))
|
||||
}
|
||||
val values = rows.flatten()
|
||||
values.forEach(normalization::observe)
|
||||
normalization.observeFrame(values, "parsed", null, screen.badgeCaptionMerged)
|
||||
diagnosticParsedValues += values.map(VisibleSpecValue::text)
|
||||
diagnosticNonClickableCandidates = maxOf(
|
||||
diagnosticNonClickableCandidates,
|
||||
@@ -1479,7 +1524,9 @@ class PddProductDetailCollector(
|
||||
}
|
||||
|
||||
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
||||
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
|
||||
val colorStart = moveColorsToStart(goodsId, config, evidence, deadline)
|
||||
colorStart.failure?.let { return it }
|
||||
val colorStartConfirmed = colorStart.confirmed
|
||||
val initial = parse(goodsId, config, evidence)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
diagnosticInitialSelectedColors = initial.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||
@@ -1530,6 +1577,7 @@ class PddProductDetailCollector(
|
||||
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
||||
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
|
||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
|
||||
traceColorClickMiss(screen, value, rows, clickResult)
|
||||
missing += "selection:${value.text}"
|
||||
continue
|
||||
}
|
||||
@@ -1561,7 +1609,18 @@ class PddProductDetailCollector(
|
||||
// established left/right paging behavior.
|
||||
if (rows.any { it.size > 1 }) {
|
||||
val rowSeeds = rows.map { row -> row.map(VisibleSpecValue::text).toSet() }
|
||||
// Frame-to-frame row tracking: rows are followed by overlap with
|
||||
// their previous frame, not by the pass's starting frame.
|
||||
val tracks = rows.map(::colorRowTrack).toMutableList()
|
||||
val coveredRows = mutableSetOf<Int>()
|
||||
var coveredIncomplete: Boolean? = null
|
||||
rowSeeds.forEachIndexed { rowIndex, seed ->
|
||||
if (rowIndex in coveredRows) {
|
||||
// A shared container already swept this row's values.
|
||||
diagnosticHorizontalRows[seed] = coveredIncomplete
|
||||
?: diagnosticHorizontalRows[seed]?.takeIf { it }
|
||||
return@forEachIndexed
|
||||
}
|
||||
val moveRight = rowIndex % 2 == 0
|
||||
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
|
||||
var diagnosticPreviousSignature: List<String>? = null
|
||||
@@ -1569,11 +1628,15 @@ class PddProductDetailCollector(
|
||||
var diagnosticMoved = false
|
||||
var diagnosticRowUncertain = false
|
||||
var diagnosticRowIncomplete: Boolean? = null
|
||||
var sharedMoves = 0
|
||||
var sharedBroken = tracks.size < 2
|
||||
var rowEnd = "limit"
|
||||
var rowSkipNote = "none"
|
||||
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
|
||||
var diagnosticBeforeClicks: List<String>? = null
|
||||
collectVisibleColors { observedRows ->
|
||||
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
|
||||
.singleOrNull()?.sortedBy { it.node.bounds.left }
|
||||
diagnosticBeforeClicks = matchColorRow(tracks[rowIndex], observedRows, tracks.size, requireUniqueOverlap = true)
|
||||
?.sortedBy { it.node.bounds.left }
|
||||
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
|
||||
}?.let { return it }
|
||||
screen = parse(goodsId, config, evidence)
|
||||
@@ -1581,11 +1644,37 @@ class PddProductDetailCollector(
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
rows = colorRows(screen)
|
||||
observeColorDiscovery(screen, rows)
|
||||
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
|
||||
?.takeIf { row -> row.any { it.text in seed } }
|
||||
val matches = tracks.map { matchColorRow(it, rows, tracks.size) }
|
||||
val matchedRow = matches[rowIndex]
|
||||
val matchKind = colorRowMatchKind(tracks[rowIndex], rows, tracks.size)
|
||||
val rowMoved = matchedRow != null && positionSignature(matchedRow) != tracks[rowIndex].signature
|
||||
val othersDesc = tracks.indices.filter { it != rowIndex }.joinToString(",") { other ->
|
||||
val match = matches[other]
|
||||
when {
|
||||
match == null -> "none"
|
||||
positionSignature(match) != tracks[other].signature -> "moved"
|
||||
else -> "unchanged"
|
||||
}
|
||||
}
|
||||
if (rowMoved && matchedRow != null) {
|
||||
sharedMoves++
|
||||
// Position (text + bounds), not the text set: a partial
|
||||
// last swipe moves options without changing which are visible.
|
||||
val everyOtherRowMoved = tracks.indices.all { other ->
|
||||
other == rowIndex || matches[other]?.let { match ->
|
||||
positionSignature(match) != tracks[other].signature
|
||||
} == true
|
||||
}
|
||||
if (!everyOtherRowMoved) sharedBroken = true
|
||||
}
|
||||
matches.forEachIndexed { index, match ->
|
||||
match?.let { tracks[index] = colorRowTrack(it) }
|
||||
}
|
||||
if (matchedRow == null) {
|
||||
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken")
|
||||
rowEnd = "reflow"
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
|
||||
terminate(AgentDiagnosticReason.COLOR_ROW_REFLOWED)
|
||||
break
|
||||
}
|
||||
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
|
||||
@@ -1605,24 +1694,57 @@ class PddProductDetailCollector(
|
||||
diagnosticPreviousSignature = signature
|
||||
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
|
||||
horizontalSignatureReads[signature] = signatureReads
|
||||
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken uncertain=$diagnosticRowUncertain signatureReads=$signatureReads")
|
||||
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
|
||||
rowEnd = "edge"
|
||||
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticRowIncomplete = false
|
||||
}
|
||||
// The skip depends only on reaching this row's edge
|
||||
// with every other row having followed every move,
|
||||
// not on the diagnostic certainty: a click-induced
|
||||
// layout change must not force a redundant sweep.
|
||||
val rest = (rowIndex + 1 until tracks.size).toList()
|
||||
if (rest.isNotEmpty()) {
|
||||
// The reverse sweep is the only thing that recovers
|
||||
// values left of an unconfirmed start, so skipping it
|
||||
// needs a sweep that began at a confirmed left edge.
|
||||
val skipOrigin = when {
|
||||
!colorStartConfirmed -> "start_unconfirmed"
|
||||
verticalPass != 0 -> "later_vertical_pass"
|
||||
rowIndex != 0 -> "not_first_row"
|
||||
else -> null
|
||||
}
|
||||
if (skipOrigin != null) {
|
||||
rowSkipNote = "not_skipped reason=$skipOrigin"
|
||||
} else if (!sharedBroken && sharedMoves > 0) {
|
||||
coveredRows += rest
|
||||
coveredIncomplete = diagnosticRowIncomplete
|
||||
rowSkipNote = "skipped=${rest.joinToString(",")}"
|
||||
} else {
|
||||
rowSkipNote = "not_skipped reason=${if (sharedMoves == 0) "no_shared_moves" else "shared_broken"}"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
|
||||
rowEnd = "limit"
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
||||
if (!driver.swipeSpec(direction, currentRow.first().node)) {
|
||||
rowEnd = "swipe_failed"
|
||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||
break
|
||||
}
|
||||
diagnosticHorizontalSwipes++
|
||||
pause(350)
|
||||
val settleStartedAt = now()
|
||||
pause(HORIZONTAL_SETTLE_MILLIS)
|
||||
lastHorizontalSwipeDoneAt = now()
|
||||
trace("color-settle waitedMs=${now() - settleStartedAt}")
|
||||
}
|
||||
trace("color-row-end row=$rowIndex end=$rowEnd skip=$rowSkipNote")
|
||||
// An uncertain revisit cannot erase previously observed
|
||||
// unresolved movement; only reliable stability clears it.
|
||||
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
|
||||
@@ -1640,7 +1762,7 @@ class PddProductDetailCollector(
|
||||
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
|
||||
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
|
||||
if (!discoverVertically) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1652,21 +1774,21 @@ class PddProductDetailCollector(
|
||||
}
|
||||
previousVerticalSignature = verticalSignature
|
||||
if (verticalStable >= config.limits.getValue("stableEdgeReads")) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
||||
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||
break
|
||||
}
|
||||
if (verticalPass == config.limits.getValue("specVerticalSwipes")) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
|
||||
terminate(AgentDiagnosticReason.COLOR_SCAN_LIMIT)
|
||||
break
|
||||
}
|
||||
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
|
||||
if (anchor == null) {
|
||||
diagnosticHorizontalUnknown = true
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
|
||||
terminate(AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE)
|
||||
break
|
||||
}
|
||||
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) {
|
||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
|
||||
terminate(AgentDiagnosticReason.COLOR_SWIPE_FAILED)
|
||||
break
|
||||
}
|
||||
diagnosticVerticalSwipes++
|
||||
@@ -1678,6 +1800,31 @@ class PddProductDetailCollector(
|
||||
|
||||
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
|
||||
|
||||
private var lastHorizontalSwipeDoneAt = -1L
|
||||
|
||||
private fun traceColorClickMiss(screen: ParsedPddScreen, value: VisibleSpecValue, rows: List<List<VisibleSpecValue>>, result: FreshActionResult) {
|
||||
val rowIndex = rows.indexOfFirst { row -> row.any { it.text == value.text } }
|
||||
val column = rows.getOrNull(rowIndex).orEmpty().sortedBy { it.node.bounds.left }.indexOfFirst { it.text == value.text }
|
||||
val bounds = value.node.bounds
|
||||
val width = bounds.right - bounds.left
|
||||
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
|
||||
var container: SnapshotNode? = byPath[value.node.parentPath]
|
||||
var hops = 0
|
||||
while (container != null && !container.scrollable && hops < 6) {
|
||||
container = container.parentPath?.let { byPath[it] }
|
||||
hops++
|
||||
}
|
||||
val exposed = if (width > 0 && container != null) {
|
||||
val shown = (minOf(bounds.right, container.bounds.right) - maxOf(bounds.left, container.bounds.left)).coerceAtLeast(0)
|
||||
"${shown * 100 / width}%"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
val containerText = container?.let { "${it.bounds}" } ?: "unknown"
|
||||
val sinceSwipe = if (lastHorizontalSwipeDoneAt < 0) "none" else "${now() - lastHorizontalSwipeDoneAt}ms"
|
||||
trace("color-click-miss color=${traceLabel(value.text)} result=$result row=$rowIndex column=$column bounds=$bounds container=$containerText exposed=$exposed sinceSwipe=$sinceSwipe")
|
||||
}
|
||||
|
||||
private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult {
|
||||
val outcome = driver.clickFreshDetailed(target)
|
||||
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
|
||||
@@ -1837,31 +1984,72 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
|
||||
private class ColorStart(val failure: PddCollectorResult?, val confirmed: Boolean)
|
||||
|
||||
private fun moveColorsToStart(
|
||||
goodsId: String,
|
||||
config: PddCollectorConfig,
|
||||
evidence: PageEvidence,
|
||||
deadline: Long,
|
||||
): PddCollectorResult? {
|
||||
): ColorStart {
|
||||
var previous = emptyList<String>()
|
||||
var stable = 0
|
||||
repeat(config.limits.getValue("specHorizontalSwipes")) {
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
var swipes = 0
|
||||
var anchorRetries = 0
|
||||
var anchorFailures = 0
|
||||
var confirmed = false
|
||||
var end = "limit"
|
||||
// Reset has its own swipe budget; anchor retries do not consume it.
|
||||
val maxSwipes = config.limits.getValue("specHorizontalSwipes") * 2
|
||||
while (swipes < maxSwipes) {
|
||||
if (now() > deadline) return ColorStart(failure("RULE_NOT_MATCHED", "采集超过规则总超时"), false)
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
screen.problem?.let { return ColorStart(failure(it.code, it.message), false) }
|
||||
if (!screen.pageEvidenceMatched) return ColorStart(failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页"), false)
|
||||
val rows = colorRows(screen)
|
||||
if (rows.size > 1 && rows.all { it.size == 1 }) return null
|
||||
if (rows.size > 1 && rows.all { it.size == 1 }) {
|
||||
end = "single_column"
|
||||
break
|
||||
}
|
||||
val values = rows.flatten()
|
||||
val signature = optionSignature(values)
|
||||
stable = if (signature == previous) stable + 1 else 0
|
||||
previous = signature
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
||||
val anchor = values.firstOrNull()?.node ?: return null
|
||||
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) return null
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) {
|
||||
confirmed = true
|
||||
end = "stable"
|
||||
break
|
||||
}
|
||||
// After an anchor failure, try a different color node of this frame.
|
||||
val anchor = values.getOrNull(anchorFailures)?.node ?: values.firstOrNull()?.node
|
||||
if (anchor == null) {
|
||||
end = "no_anchor"
|
||||
break
|
||||
}
|
||||
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) {
|
||||
val reason = driver.specRowSwipeFailureReason()
|
||||
anchorFailures++
|
||||
// A failed gesture says nothing about the position, so the next
|
||||
// equal read must not count as stability.
|
||||
previous = emptyList()
|
||||
stable = 0
|
||||
if (
|
||||
(reason == "anchorMissing" || reason == "anchorAmbiguous") &&
|
||||
anchorFailures <= MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES &&
|
||||
anchorRetries < MAX_RESET_ANCHOR_RETRIES
|
||||
) {
|
||||
anchorRetries++
|
||||
continue
|
||||
}
|
||||
end = "swipe_failed"
|
||||
break
|
||||
}
|
||||
anchorFailures = 0
|
||||
swipes++
|
||||
pause(350)
|
||||
}
|
||||
return null
|
||||
trace("color-start confirmed=$confirmed swipes=$swipes anchorRetries=$anchorRetries end=$end")
|
||||
return ColorStart(null, confirmed)
|
||||
}
|
||||
|
||||
private fun collectSizes(
|
||||
@@ -1887,9 +2075,17 @@ class PddProductDetailCollector(
|
||||
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
|
||||
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
|
||||
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
|
||||
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation, config)
|
||||
// #379: observe every candidate before continuation de-duplication,
|
||||
// otherwise a same-name conflict is swallowed by groupBy.
|
||||
val continuationCandidates = if (parsedSizes.isNotEmpty()) emptyList() else continuationCandidates(screen, continuation, config)
|
||||
normalization.observeFrame(
|
||||
if (parsedSizes.isNotEmpty()) parsedSizes else continuationCandidates,
|
||||
if (parsedSizes.isNotEmpty()) "parsed" else "continuation",
|
||||
pass,
|
||||
screen.badgeCaptionMerged,
|
||||
)
|
||||
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else dedupeContinuation(continuationCandidates)
|
||||
visible.forEach { value ->
|
||||
normalization.observe(value)
|
||||
if (SpecValueNormalizer.isSafeSize(value.text)) {
|
||||
sizes[value.text] = sizes[value.text] == true || value.available
|
||||
}
|
||||
@@ -1934,37 +2130,75 @@ class PddProductDetailCollector(
|
||||
return null
|
||||
}
|
||||
|
||||
private class SizeNormalizationTracker {
|
||||
private val sources = linkedMapOf<String, MutableSet<String>>()
|
||||
/**
|
||||
* #160/#379: an empty or currency-bearing normalized value rejects the whole
|
||||
* dimension ([invalid]); a normalized name backed by several raw labels only
|
||||
* marks a [conflict] and the dimension keeps a single value for that name.
|
||||
*/
|
||||
private open class SpecNormalizationTracker(
|
||||
private val dim: String,
|
||||
private val isSafe: (String) -> Boolean,
|
||||
private val trace: (String) -> Unit,
|
||||
) {
|
||||
// normalized text -> raw label -> frames in which that raw label was seen
|
||||
private val sources = linkedMapOf<String, LinkedHashMap<String, MutableSet<Int>>>()
|
||||
private var frameCounter = 0
|
||||
private var invalidTraced = false
|
||||
private var conflictTraced = false
|
||||
var invalid: Boolean = false
|
||||
private set
|
||||
var conflict: Boolean = false
|
||||
private set
|
||||
|
||||
fun observe(value: VisibleSpecValue) {
|
||||
if (!SpecValueNormalizer.isSafeSize(value.text)) {
|
||||
invalid = true
|
||||
return
|
||||
/** [pass] identifies the frame; without it the tracker numbers its own observations. */
|
||||
fun observeFrame(values: List<VisibleSpecValue>, path: String, pass: Int?, badgeMerged: Int) {
|
||||
val frame = pass ?: ++frameCounter
|
||||
values.forEach { value ->
|
||||
if (!isSafe(value.text)) {
|
||||
invalid = true
|
||||
if (!invalidTraced) {
|
||||
invalidTraced = true
|
||||
val reason = if (value.text.isBlank()) "empty" else "unsafe_currency"
|
||||
traceOutcome("invalid_rejected", reason, path, frame, false, false, 1, values.size, badgeMerged)
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
val raws = sources.getOrPut(value.text) { linkedMapOf() }
|
||||
val isNewRaw = value.rawText !in raws
|
||||
raws.getOrPut(value.rawText) { linkedSetOf() } += frame
|
||||
if (raws.size > 1) {
|
||||
conflict = true
|
||||
if (isNewRaw && !conflictTraced) {
|
||||
conflictTraced = true
|
||||
traceOutcome(
|
||||
"conflict_kept", "normalized_conflict", path, frame,
|
||||
raws.values.count { frame in it } > 1,
|
||||
raws.keys.map { it.replace(TRAILING_PRICE, "") }.toSet().size == 1,
|
||||
raws.size, values.size, badgeMerged,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
|
||||
rawValues += value.rawText
|
||||
if (rawValues.size > 1) invalid = true
|
||||
}
|
||||
|
||||
// Counts and enum values only: never the raw node text.
|
||||
private fun traceOutcome(
|
||||
outcome: String, reason: String, path: String, pass: Int, sameFrame: Boolean,
|
||||
onlyTrailingPriceDiff: Boolean, rawVariants: Int, candidates: Int, badgeMerged: Int,
|
||||
) {
|
||||
trace(
|
||||
"spec-normalize dim=$dim outcome=$outcome reason=$reason path=$path pass=$pass sameFrame=$sameFrame " +
|
||||
"onlyTrailingPriceDiff=$onlyTrailingPriceDiff rawVariants=$rawVariants candidates=$candidates " +
|
||||
"badgeCaptionMerged=$badgeMerged",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ColorNormalizationTracker {
|
||||
private val sources = linkedMapOf<String, MutableSet<String>>()
|
||||
var invalid: Boolean = false
|
||||
private set
|
||||
private class SizeNormalizationTracker(trace: (String) -> Unit) :
|
||||
SpecNormalizationTracker("size", SpecValueNormalizer::isSafeSize, trace)
|
||||
|
||||
fun observe(value: VisibleSpecValue) {
|
||||
if (!SpecValueNormalizer.isSafeColor(value.text)) {
|
||||
invalid = true
|
||||
return
|
||||
}
|
||||
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
|
||||
rawValues += value.rawText
|
||||
if (rawValues.size > 1) invalid = true
|
||||
}
|
||||
}
|
||||
private class ColorNormalizationTracker(trace: (String) -> Unit) :
|
||||
SpecNormalizationTracker("color", SpecValueNormalizer::isSafeColor, trace)
|
||||
|
||||
private fun recordSizeDiscovery(
|
||||
reason: AgentDiagnosticReason,
|
||||
@@ -2044,7 +2278,8 @@ class PddProductDetailCollector(
|
||||
)
|
||||
}
|
||||
|
||||
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
|
||||
/** Normalized continuation candidates before any de-duplication; rawText keeps the original label. */
|
||||
private fun continuationCandidates(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
|
||||
if (context == null) return emptyList()
|
||||
val headingLabels = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
|
||||
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }.toSet()
|
||||
@@ -2059,13 +2294,21 @@ class PddProductDetailCollector(
|
||||
.filterNot { it.label in context.colorLabels || it.label.replace(" ", "") in headingLabels }
|
||||
.filterNot { PddScreenParser.isExcludedOptionLabel(it.label) || priceOnly.matches(it.label.replace(" ", "")) }
|
||||
.map { node ->
|
||||
VisibleSpecValue(node.label, node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains), node)
|
||||
VisibleSpecValue(
|
||||
SpecValueNormalizer.normalizeSize(node.label),
|
||||
node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains),
|
||||
node,
|
||||
rawText = node.label,
|
||||
)
|
||||
}
|
||||
.groupBy(VisibleSpecValue::text)
|
||||
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
|
||||
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun dedupeContinuation(candidates: List<VisibleSpecValue>): List<VisibleSpecValue> = candidates
|
||||
.groupBy(VisibleSpecValue::text)
|
||||
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
|
||||
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
|
||||
|
||||
private fun stablePrice(
|
||||
goodsId: String,
|
||||
color: String,
|
||||
@@ -2213,12 +2456,65 @@ class PddProductDetailCollector(
|
||||
private fun traceLabel(value: String): String = value.replace(Regex("[\\r\\n\\t]+"), " ").take(40)
|
||||
|
||||
private companion object {
|
||||
/** Same terminal-price grammar as [SpecValueNormalizer]; used only to classify a trace, never to normalize. */
|
||||
val TRAILING_PRICE = Regex("""[\s\u00a0\u3000]*(?:¥|¥)[\s\u00a0\u3000]*\d+(?:\.\d{1,2})?[\s\u00a0\u3000]*$""")
|
||||
const val PRICE_SETTLE_MILLIS = 300L
|
||||
/** Fixed wait for the color grid to stop coasting after a horizontal swipe (no extra parse). */
|
||||
const val HORIZONTAL_SETTLE_MILLIS = 1200L
|
||||
const val MAX_RESET_ANCHOR_RETRIES = 6
|
||||
const val MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES = 2
|
||||
const val SPEC_PANEL_LOST_STABLE_READS = 2
|
||||
const val COLOR_IMAGE_MAX_COUNT = 64
|
||||
const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
||||
}
|
||||
|
||||
/** Last observed frame of one color row, used to follow it across horizontal swipes. */
|
||||
private class ColorRowTrack(val texts: Set<String>, val centerY: Int, val tolerance: Int, val signature: List<String>)
|
||||
|
||||
private fun colorRowTrack(row: List<VisibleSpecValue>) = ColorRowTrack(
|
||||
row.map(VisibleSpecValue::text).toSet(),
|
||||
row.map { it.node.bounds.centerY }.average().toInt(),
|
||||
(row.first().node.bounds.height / 2).coerceIn(24, 80),
|
||||
positionSignature(row),
|
||||
)
|
||||
|
||||
private fun colorRowMatchKind(
|
||||
track: ColorRowTrack,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
trackedRowCount: Int,
|
||||
): String = when {
|
||||
rows.any { row -> row.any { it.text in track.texts } } -> "overlap"
|
||||
matchColorRow(track, rows, trackedRowCount) != null -> "position"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
private fun positionSignature(row: List<VisibleSpecValue>): List<String> =
|
||||
optionSignature(row.sortedBy { it.node.bounds.left })
|
||||
|
||||
/**
|
||||
* Finds the current-frame row for a tracked row. Overlap with the previous
|
||||
* frame wins. With no overlap (a whole-page move) a multi-row grid whose
|
||||
* row count is unchanged may match by vertical position, but only when
|
||||
* exactly one current row sits within the colorRows tolerance. Anything
|
||||
* else is not the same row and stays a reflow.
|
||||
*/
|
||||
private fun matchColorRow(
|
||||
track: ColorRowTrack,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
trackedRowCount: Int,
|
||||
requireUniqueOverlap: Boolean = false,
|
||||
): List<VisibleSpecValue>? {
|
||||
val overlapping = rows.filter { row -> row.any { it.text in track.texts } }
|
||||
if (overlapping.isNotEmpty()) {
|
||||
if (requireUniqueOverlap && overlapping.size > 1) return null
|
||||
return overlapping.maxByOrNull { row -> row.count { it.text in track.texts } }
|
||||
}
|
||||
if (trackedRowCount < 2 || rows.size != trackedRowCount) return null
|
||||
return rows.filter { row ->
|
||||
kotlin.math.abs(row.map { it.node.bounds.centerY }.average().toInt() - track.centerY) <= track.tolerance
|
||||
}.singleOrNull()
|
||||
}
|
||||
|
||||
private fun colorRows(screen: ParsedPddScreen): List<List<VisibleSpecValue>> {
|
||||
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
||||
|
||||
+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()
|
||||
}
|
||||
|
||||
@@ -166,7 +166,6 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun record(event: AgentDiagnosticEvent) {
|
||||
require(event.taskId > 0)
|
||||
require(event.taskType in setOf("collection", "purchase"))
|
||||
|
||||
+136
-3
@@ -79,6 +79,13 @@ import cn.ilapage.goauto.agent.diagnostics.AccessibilitySnapshotSource
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotCapture
|
||||
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy
|
||||
import cn.ilapage.goauto.agent.diagnostics.SnapshotContext
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertAction
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertTracker
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityDispatchGate
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityPauseText
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadinessDetector
|
||||
import cn.ilapage.goauto.agent.ui.AgentUiVisibility
|
||||
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -103,6 +110,7 @@ class AgentForegroundService : Service() {
|
||||
private var repurchaseSession: RepurchaseSession? = null
|
||||
private val repurchaseEvidence = java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.ConcurrentHashMap<Long, RepurchaseExecutionEvidence>>()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val accessibilityAlerts = AccessibilityAlertTracker()
|
||||
private val probeHandoff = PurchaseProbeHandoff()
|
||||
private val backfillGuard = OrderBackfillGuard(taskMutex)
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
@@ -176,6 +184,7 @@ class AgentForegroundService : Service() {
|
||||
// Separate, serialized I/O ticks keep history reads and result observation off the
|
||||
// heartbeat scheduler and, especially, off the single purchase execution thread.
|
||||
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
|
||||
repurchaseExecutor.scheduleWithFixedDelay(::refreshDeviceBusy, 1, 2, TimeUnit.SECONDS)
|
||||
diagnosticInstance = this
|
||||
}
|
||||
|
||||
@@ -197,6 +206,12 @@ class AgentForegroundService : Service() {
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
if (intent?.action == ACTION_OPEN_PDD_PRODUCT) {
|
||||
val goodsId = intent.getStringExtra(EXTRA_OPEN_PDD_GOODS_ID)
|
||||
val purchase = intent.getBooleanExtra(EXTRA_OPEN_PDD_PURCHASE, false)
|
||||
runCatching { repurchaseExecutor.execute { openPddProduct(goodsId, purchase) } }
|
||||
return START_STICKY
|
||||
}
|
||||
if (intent?.action == ACTION_BACKFILL_STOP) {
|
||||
backfillGuard.cancelled.set(true)
|
||||
return START_STICKY
|
||||
@@ -228,6 +243,7 @@ class AgentForegroundService : Service() {
|
||||
repurchase.requestStop(repurchase.state.roundId)
|
||||
repurchaseExecutor.shutdownNow()
|
||||
repurchaseState = RepurchaseState(message = "服务已停止;不会恢复整轮重购")
|
||||
deviceBusyReason = null
|
||||
backfillGuard.cancelled.set(true)
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
cancelIdleReturn("服务已停止")
|
||||
@@ -435,6 +451,15 @@ class AgentForegroundService : Service() {
|
||||
|
||||
private fun scheduleTask(api: AgentApiClient, token: String): String {
|
||||
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
|
||||
val readiness = AccessibilityReadinessDetector.current(this)
|
||||
handleAccessibilityAlert(readiness)
|
||||
if (!AccessibilityDispatchGate.allowsClaiming(readiness)) {
|
||||
// 不调用任何领取/查询下一任务接口,任务留在服务端 pending;恢复后下一轮同步自动继续。
|
||||
cancelIdleReturn("无障碍未就绪")
|
||||
stateStore.update("ONLINE", AccessibilityPauseText.TITLE, tokenStored = true)
|
||||
updateNotification(AccessibilityPauseText.TITLE)
|
||||
return MANUAL_ACCESSIBILITY_NOT_READY
|
||||
}
|
||||
val collectionCooldown = activeCollectionCooldown()
|
||||
val purchaseTask = api.nextPurchaseTask(token)
|
||||
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
|
||||
@@ -481,6 +506,50 @@ class AgentForegroundService : Service() {
|
||||
return MANUAL_COLLECTION_TASK
|
||||
}
|
||||
|
||||
private fun handleAccessibilityAlert(readiness: AccessibilityReadiness) {
|
||||
val action = accessibilityAlerts.onReadiness(readiness)
|
||||
if (action == AccessibilityAlertAction.NONE) return
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (action == AccessibilityAlertAction.CANCEL) {
|
||||
manager.cancel(ACCESSIBILITY_ALERT_NOTIFICATION_ID)
|
||||
} else if (!AgentUiVisibility.resumed) {
|
||||
postAccessibilityAlertNotification(manager, readiness)
|
||||
}
|
||||
// 前台时由 MainActivity 收到广播后弹窗;恢复时同一广播让它关闭弹窗。
|
||||
sendBroadcast(Intent(ACTION_ACCESSIBILITY_ALERT).apply {
|
||||
setPackage(packageName)
|
||||
putExtra(EXTRA_ACCESSIBILITY_READINESS, readiness.name)
|
||||
})
|
||||
}
|
||||
|
||||
private fun postAccessibilityAlertNotification(manager: NotificationManager, readiness: AccessibilityReadiness) {
|
||||
// 通知权限被拒时静默跳过,不新增请求流程。
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
runCatching {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 1, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, ACCESSIBILITY_ALERT_CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this).setPriority(Notification.PRIORITY_HIGH)
|
||||
}
|
||||
val detail = AccessibilityPauseText.recovery(readiness)
|
||||
manager.notify(
|
||||
ACCESSIBILITY_ALERT_NOTIFICATION_ID,
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setContentTitle(AccessibilityPauseText.TITLE)
|
||||
.setContentText(detail)
|
||||
.setStyle(Notification.BigTextStyle().bigText(detail))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishManualCheckResult(result: String) {
|
||||
sendBroadcast(Intent(ACTION_CHECK_RESULT).apply {
|
||||
setPackage(packageName)
|
||||
@@ -497,9 +566,47 @@ class AgentForegroundService : Service() {
|
||||
updateNotification(state.message, force = true)
|
||||
}
|
||||
|
||||
private fun repurchaseLocalIdle(): Boolean = !working.get() && taskMutex.currentTaskId() == null &&
|
||||
purchaseStore.activeTaskId() == null && purchaseStore.pendingOutbox().isEmpty() &&
|
||||
stateStore.read().currentTaskId == null && !backfillState.running
|
||||
private fun deviceBusyInputs(includeRepurchase: Boolean): DeviceBusyPolicy.Inputs {
|
||||
val mutexTaskId = taskMutex.currentTaskId()
|
||||
return DeviceBusyPolicy.Inputs(
|
||||
repurchaseBusy = includeRepurchase && repurchaseState.busy,
|
||||
backfillRunning = backfillState.running,
|
||||
currentPageCollecting = mutexTaskId == CURRENT_PAGE_RESERVATION_ID,
|
||||
taskActive = working.get() || mutexTaskId != null || purchaseStore.activeTaskId() != null ||
|
||||
stateStore.read().currentTaskId != null,
|
||||
unuploadedPurchase = purchaseStore.pendingOutbox().isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
// The repurchase round itself asks this question between its tasks, so it must not count its own busy flag.
|
||||
private fun repurchaseLocalIdle(): Boolean = DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = false)) == null
|
||||
|
||||
/** Publishes the read-only busy reason for the history detail buttons; click time re-checks. */
|
||||
private fun refreshDeviceBusy() {
|
||||
runCatching {
|
||||
val reason = if (repurchaseClosed.get()) null else DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true))
|
||||
if (reason != deviceBusyReason) {
|
||||
deviceBusyReason = reason
|
||||
sendBroadcast(Intent(ACTION_DEVICE_BUSY_STATE).setPackage(packageName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPddProduct(goodsId: String?, purchase: Boolean) {
|
||||
val result = runCatching {
|
||||
PddManualOpenCoordinator(
|
||||
busyReason = { synchronized(taskMutex) { DeviceBusyPolicy.reason(deviceBusyInputs(includeRepurchase = true)) } },
|
||||
invalidateHandoff = probeHandoff::invalidate,
|
||||
launch = { url, preferDirect, diagnostic ->
|
||||
cn.ilapage.goauto.agent.automation.PddLinkLauncher(this).open(url, preferDirect, diagnostic)
|
||||
},
|
||||
).run(goodsId, purchase)
|
||||
}.getOrDefault(PddManualOpenResult.Failed)
|
||||
sendBroadcast(Intent(ACTION_OPEN_PDD_RESULT).apply {
|
||||
setPackage(packageName)
|
||||
putExtra(EXTRA_OPEN_PDD_MESSAGE, PddManualOpenCoordinator.message(result))
|
||||
})
|
||||
}
|
||||
|
||||
private fun repurchaseEnvironment(session: RepurchaseSession): String? {
|
||||
if (repurchaseClosed.get()) return "服务已经停止"
|
||||
@@ -1714,6 +1821,9 @@ class AgentForegroundService : Service() {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Agent 连接与心跳", NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(ACCESSIBILITY_ALERT_CHANNEL_ID, "无障碍未就绪提醒", NotificationManager.IMPORTANCE_HIGH),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1811,6 +1921,15 @@ class AgentForegroundService : Service() {
|
||||
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
|
||||
@Volatile var backfillState = OrderBackfillState()
|
||||
private set
|
||||
const val ACTION_OPEN_PDD_PRODUCT = "cn.ilapage.goauto.agent.OPEN_PDD_PRODUCT"
|
||||
const val ACTION_OPEN_PDD_RESULT = "cn.ilapage.goauto.agent.OPEN_PDD_RESULT"
|
||||
const val ACTION_DEVICE_BUSY_STATE = "cn.ilapage.goauto.agent.DEVICE_BUSY_STATE"
|
||||
const val EXTRA_OPEN_PDD_GOODS_ID = "open_pdd_goods_id"
|
||||
const val EXTRA_OPEN_PDD_PURCHASE = "open_pdd_purchase"
|
||||
const val EXTRA_OPEN_PDD_MESSAGE = "open_pdd_message"
|
||||
/** Null when idle; otherwise the user-facing reason. Updated about every two seconds. */
|
||||
@Volatile var deviceBusyReason: String? = null
|
||||
private set
|
||||
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
|
||||
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
|
||||
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
|
||||
@@ -1822,6 +1941,9 @@ class AgentForegroundService : Service() {
|
||||
const val EXTRA_CURRENT_PAGE_MESSAGE = "current_page_message"
|
||||
const val EXTRA_REPLACEMENT_ORIGIN_TYPE = "replacement_origin_type"
|
||||
const val EXTRA_REPLACEMENT_ORIGIN_TASK_ID = "replacement_origin_task_id"
|
||||
const val ACTION_ACCESSIBILITY_ALERT = "cn.ilapage.goauto.agent.ACCESSIBILITY_ALERT"
|
||||
const val EXTRA_ACCESSIBILITY_READINESS = "accessibility_readiness"
|
||||
const val MANUAL_ACCESSIBILITY_NOT_READY = "accessibility_not_ready"
|
||||
const val MANUAL_EMPTY = "empty"
|
||||
const val MANUAL_COLLECTION_TASK = "collection_task"
|
||||
const val MANUAL_PURCHASE_TASK = "purchase_task"
|
||||
@@ -1834,6 +1956,8 @@ class AgentForegroundService : Service() {
|
||||
const val MANUAL_COLLECTION_COOLDOWN_PREFIX = "collection_cooldown:"
|
||||
private const val CHANNEL_ID = "agent_connection"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val ACCESSIBILITY_ALERT_CHANNEL_ID = "agent_accessibility_alert"
|
||||
private const val ACCESSIBILITY_ALERT_NOTIFICATION_ID = 1002
|
||||
private const val HEARTBEAT_SECONDS = 15L
|
||||
private const val TASK_WAKE_LOCK_TIMEOUT_MILLIS = 5 * 60 * 1000L
|
||||
private const val IDLE_RETURN_WAKE_LOCK_TIMEOUT_MILLIS = 30_000L
|
||||
@@ -1867,6 +1991,15 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun openPddProduct(context: Context, goodsId: String, purchase: Boolean) {
|
||||
val intent = Intent(context, AgentForegroundService::class.java).apply {
|
||||
action = ACTION_OPEN_PDD_PRODUCT
|
||||
putExtra(EXTRA_OPEN_PDD_GOODS_ID, goodsId)
|
||||
putExtra(EXTRA_OPEN_PDD_PURCHASE, purchase)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
fun collectCurrentPage(
|
||||
context: Context,
|
||||
requestId: String,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
|
||||
/** Single source of truth for "the device is busy"; the service and the UI both consume its result. */
|
||||
internal object DeviceBusyPolicy {
|
||||
data class Inputs(
|
||||
val repurchaseBusy: Boolean = false,
|
||||
val backfillRunning: Boolean = false,
|
||||
/** The manual current-page collection holds the task mutex with its own reservation. */
|
||||
val currentPageCollecting: Boolean = false,
|
||||
/** A collection/purchase/image-search task is claimed, running or restored. */
|
||||
val taskActive: Boolean = false,
|
||||
/** Unuploaded purchase results or a purchase waiting for recovery. */
|
||||
val unuploadedPurchase: Boolean = false,
|
||||
)
|
||||
|
||||
const val REPURCHASE = "重购进行中"
|
||||
const val BACKFILL = "订单回填中"
|
||||
const val CURRENT_PAGE = "当前页采集进行中"
|
||||
const val TASK = "设备正在执行任务"
|
||||
const val UNUPLOADED = "有未上传的采购结果"
|
||||
|
||||
/** Null means idle. A collection cooldown is intentionally not an input. */
|
||||
fun reason(inputs: Inputs): String? = when {
|
||||
inputs.repurchaseBusy -> REPURCHASE
|
||||
inputs.backfillRunning -> BACKFILL
|
||||
inputs.currentPageCollecting -> CURRENT_PAGE
|
||||
inputs.taskActive -> TASK
|
||||
inputs.unuploadedPurchase -> UNUPLOADED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PddManualOpenResult {
|
||||
object InvalidGoodsId : PddManualOpenResult()
|
||||
data class Busy(val reason: String) : PddManualOpenResult()
|
||||
data class Opened(val fallbackNotice: String?) : PddManualOpenResult()
|
||||
object Failed : PddManualOpenResult()
|
||||
}
|
||||
|
||||
/** Manual "open in PDD" entry. Only opens the product link; it never clicks anything inside PDD. */
|
||||
internal class PddManualOpenCoordinator(
|
||||
private val busyReason: () -> String?,
|
||||
private val invalidateHandoff: () -> Unit,
|
||||
private val launch: (url: String, preferDirect: Boolean, diagnostic: (PddLaunchEvent) -> Unit) -> Boolean,
|
||||
) {
|
||||
fun run(goodsId: String?, purchase: Boolean): PddManualOpenResult {
|
||||
val url = link(goodsId) ?: return PddManualOpenResult.InvalidGoodsId
|
||||
busyReason()?.let { return PddManualOpenResult.Busy(it) }
|
||||
// The opened page is no longer the product a previous probe prepared.
|
||||
invalidateHandoff()
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
val opened = launch(url, purchase) { events += it }
|
||||
return if (opened) PddManualOpenResult.Opened(fallbackNotice(events)) else PddManualOpenResult.Failed
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val OPENED_HINT = "打开期间如有新任务,Agent 会接管屏幕"
|
||||
const val FAILED = "无法打开拼多多链接"
|
||||
const val INVALID = "商品编号无效,无法打开"
|
||||
|
||||
fun link(goodsId: String?): String? =
|
||||
goodsId?.takeIf { it.isNotEmpty() && it.all { c -> c in '0'..'9' } }
|
||||
?.let { "https://mobile.yangkeduo.com/goods.html?goods_id=$it" }
|
||||
|
||||
/** Never contains the URL or an exception message; only the simple exception class name. */
|
||||
fun fallbackNotice(events: List<PddLaunchEvent>): String? {
|
||||
val direct = events.firstOrNull { it.path == PddLaunchPath.DIRECT && it.status != PddLaunchStatus.ACCEPTED }
|
||||
?: return null
|
||||
if (events.none { it.path == PddLaunchPath.BROWSER && it.status == PddLaunchStatus.ACCEPTED }) return null
|
||||
val name = direct.exceptionClass?.substringAfterLast('.')?.takeIf(String::isNotBlank)
|
||||
return "拼多多直接打开失败,已改用浏览器" + (name?.let { "($it)" } ?: "")
|
||||
}
|
||||
|
||||
fun message(result: PddManualOpenResult): String = when (result) {
|
||||
PddManualOpenResult.InvalidGoodsId -> INVALID
|
||||
is PddManualOpenResult.Busy -> "${result.reason},暂不能打开拼多多"
|
||||
is PddManualOpenResult.Opened -> listOfNotNull(result.fallbackNotice, OPENED_HINT).joinToString("\n")
|
||||
PddManualOpenResult.Failed -> FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
/** 无障碍未就绪时暂停领取任务(#385)。纯策略,便于单元测试。 */
|
||||
object AccessibilityDispatchGate {
|
||||
fun allowsClaiming(readiness: AccessibilityReadiness): Boolean = readiness == AccessibilityReadiness.READY
|
||||
}
|
||||
|
||||
/** 暂停提示的文案,状态 Tab、手动检查、弹窗和通知共用。 */
|
||||
object AccessibilityPauseText {
|
||||
const val TITLE = "无障碍未就绪,已暂停领取任务"
|
||||
|
||||
fun recovery(readiness: AccessibilityReadiness): String? = when (readiness) {
|
||||
AccessibilityReadiness.READY -> null
|
||||
AccessibilityReadiness.ENABLED_WAITING -> "请到 设置 → 无障碍,把 GoAuto 关闭后重新打开"
|
||||
AccessibilityReadiness.DISABLED -> "请到 设置 → 无障碍 开启 GoAuto"
|
||||
}
|
||||
}
|
||||
|
||||
enum class AccessibilityAlertAction { NONE, PROMPT, CANCEL }
|
||||
|
||||
/**
|
||||
* 只在「就绪 → 未就绪」时提示一次;持续未就绪(含 DISABLED 与 ENABLED_WAITING 互转)不重复;
|
||||
* 恢复就绪时撤销提示,之后再次失效重新提示。进程内状态,不持久化。
|
||||
* 进程启动后的第一次观察视为转变:首次未就绪会提示,首次就绪会撤销可能残留的旧通知。
|
||||
*/
|
||||
class AccessibilityAlertTracker {
|
||||
private var previous: AccessibilityReadiness? = null
|
||||
|
||||
@Synchronized
|
||||
fun onReadiness(current: AccessibilityReadiness): AccessibilityAlertAction {
|
||||
val last = previous
|
||||
previous = current
|
||||
val ready = current == AccessibilityReadiness.READY
|
||||
return when {
|
||||
ready && last != AccessibilityReadiness.READY -> AccessibilityAlertAction.CANCEL
|
||||
!ready && (last == null || last == AccessibilityReadiness.READY) -> AccessibilityAlertAction.PROMPT
|
||||
else -> AccessibilityAlertAction.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Agent 界面是否在前台(MainActivity onResume/onPause 维护),服务据此选择弹窗或通知。 */
|
||||
object AgentUiVisibility {
|
||||
@Volatile var resumed: Boolean = false
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import java.util.Date
|
||||
internal object ManualTaskCheckPolicy {
|
||||
fun blockedMessage(stateCode: String, currentTaskId: Long?, readiness: AccessibilityReadiness): String? = when {
|
||||
currentTaskId != null || stateCode == "BUSY" -> "当前任务执行中,无需重复检查"
|
||||
readiness != AccessibilityReadiness.READY -> "请先到“设置”开启采集采购助手"
|
||||
readiness != AccessibilityReadiness.READY -> AccessibilityPauseText.recovery(readiness)
|
||||
stateCode == "CONFIG_REQUIRED" -> "请先到“设置”配置服务地址"
|
||||
stateCode == "AUTH_ERROR" -> "设备身份校验失败,请先检查设置"
|
||||
else -> null
|
||||
@@ -38,6 +38,7 @@ internal object ManualTaskCheckPolicy {
|
||||
result == AgentForegroundService.MANUAL_EMPTY -> "暂无新任务,Agent 会继续自动检查"
|
||||
result == AgentForegroundService.MANUAL_COLLECTION_TASK -> "已领取采集任务"
|
||||
result == AgentForegroundService.MANUAL_PURCHASE_TASK -> "已领取采购任务"
|
||||
result == AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY -> AccessibilityPauseText.TITLE
|
||||
result == AgentForegroundService.MANUAL_BUSY -> "当前任务执行中,无需重复检查"
|
||||
result == AgentForegroundService.MANUAL_CONFIG_REQUIRED -> "请先到“设置”配置服务地址"
|
||||
result == AgentForegroundService.MANUAL_AUTH_ERROR -> "设备身份校验失败,请先检查设置"
|
||||
@@ -52,10 +53,17 @@ internal object AgentStatusTextPolicy {
|
||||
"最近心跳:$heartbeat",
|
||||
).joinToString("\n")
|
||||
|
||||
fun accessibilityDetail(readiness: AccessibilityReadiness): String? = when (readiness) {
|
||||
AccessibilityReadiness.READY -> null
|
||||
AccessibilityReadiness.ENABLED_WAITING -> "请到“设置”检查无障碍服务"
|
||||
AccessibilityReadiness.DISABLED -> "请到“设置”开启"
|
||||
fun accessibilityDetail(readiness: AccessibilityReadiness): String? = AccessibilityPauseText.recovery(readiness)
|
||||
|
||||
fun accessibilityTitle(readiness: AccessibilityReadiness): String = when (readiness) {
|
||||
AccessibilityReadiness.READY -> "已开启"
|
||||
AccessibilityReadiness.ENABLED_WAITING -> "未连接,任务已暂停"
|
||||
AccessibilityReadiness.DISABLED -> "未开启,任务已暂停"
|
||||
}
|
||||
|
||||
fun accessibilityColor(readiness: AccessibilityReadiness): Int = when (readiness) {
|
||||
AccessibilityReadiness.READY -> R.color.agent_primary_light
|
||||
else -> R.color.agent_error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +82,8 @@ class AgentStatusFragment : Fragment() {
|
||||
private lateinit var mediaPermissionDetail: TextView
|
||||
private lateinit var mediaPermissionAction: android.widget.Button
|
||||
private lateinit var deviceText: TextView
|
||||
private lateinit var deviceWifiText: TextView
|
||||
private lateinit var deviceDebugText: TextView
|
||||
private val mediaPermissionRequestLauncher by lazy {
|
||||
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
|
||||
refreshStatus()
|
||||
@@ -107,6 +117,7 @@ class AgentStatusFragment : Fragment() {
|
||||
AgentForegroundService.MANUAL_CONFIG_REQUIRED,
|
||||
AgentForegroundService.MANUAL_NETWORK_ERROR,
|
||||
AgentForegroundService.MANUAL_ERROR,
|
||||
AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -168,6 +179,20 @@ class AgentStatusFragment : Fragment() {
|
||||
}
|
||||
addView(mediaPermissionAction)
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("设备连接", 14f, context.getColor(R.color.agent_text_muted)))
|
||||
deviceWifiText = context.label("Wi-Fi IP:—", 18f, context.getColor(R.color.agent_text), true).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
deviceDebugText = context.label("无线调试:—", 18f, context.getColor(R.color.agent_text_muted), true).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}
|
||||
addView(deviceWifiText)
|
||||
addView(deviceDebugText)
|
||||
addView(context.label(DeviceConnectionPolicy.PORT_HINT, 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
})
|
||||
}))
|
||||
}
|
||||
refreshLayout = SwipeRefreshLayout(context).apply {
|
||||
setColorSchemeResources(R.color.agent_primary_light)
|
||||
@@ -245,6 +270,10 @@ class AgentStatusFragment : Fragment() {
|
||||
private fun refreshStatus() {
|
||||
if (!isAdded || view == null) return
|
||||
val context = requireContext()
|
||||
val deviceConnection = DeviceConnectionPolicy.display(DeviceConnectionReader.read(context))
|
||||
deviceWifiText.text = deviceConnection.wifiText
|
||||
deviceDebugText.text = deviceConnection.debugText
|
||||
deviceDebugText.setTextColor(context.getColor(deviceConnection.debugColor))
|
||||
val state = stateStore.read()
|
||||
val connected = state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")
|
||||
connectionTitle.text = when (state.code) {
|
||||
@@ -266,20 +295,8 @@ class AgentStatusFragment : Fragment() {
|
||||
"${if (purchase) "采购" else "采集"}任务 ${if (purchase) "CG-" else "#"}$id"
|
||||
} ?: "等待任务"
|
||||
val readiness = AccessibilityReadinessDetector.current(context)
|
||||
when (readiness) {
|
||||
AccessibilityReadiness.READY -> {
|
||||
accessibilityTitle.text = "已开启"
|
||||
accessibilityTitle.setTextColor(context.getColor(R.color.agent_primary_light))
|
||||
}
|
||||
AccessibilityReadiness.ENABLED_WAITING -> {
|
||||
accessibilityTitle.text = "等待连接"
|
||||
accessibilityTitle.setTextColor(context.getColor(R.color.agent_warning))
|
||||
}
|
||||
AccessibilityReadiness.DISABLED -> {
|
||||
accessibilityTitle.text = "未开启"
|
||||
accessibilityTitle.setTextColor(context.getColor(R.color.agent_error))
|
||||
}
|
||||
}
|
||||
accessibilityTitle.text = AgentStatusTextPolicy.accessibilityTitle(readiness)
|
||||
accessibilityTitle.setTextColor(context.getColor(AgentStatusTextPolicy.accessibilityColor(readiness)))
|
||||
accessibilityDetail.text = AgentStatusTextPolicy.accessibilityDetail(readiness).orEmpty()
|
||||
accessibilityDetail.visibility = if (accessibilityDetail.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
val mediaReadiness = MediaPermissionPolicy.current(context)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.ColorRes
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
|
||||
/** 无线调试开关的读取结果;读取失败为 [UNKNOWN]。 */
|
||||
internal enum class WirelessDebugState { ENABLED, DISABLED, UNKNOWN }
|
||||
|
||||
internal data class DeviceConnectionReading(
|
||||
val wifiIpv4: String?,
|
||||
val wirelessDebug: WirelessDebugState,
|
||||
)
|
||||
|
||||
internal data class DeviceConnectionDisplay(
|
||||
val wifiText: String,
|
||||
val debugText: String,
|
||||
@ColorRes val debugColor: Int,
|
||||
)
|
||||
|
||||
/** 原始读数到显示文案/颜色的纯函数策略,不依赖 Android 系统服务。 */
|
||||
internal object DeviceConnectionPolicy {
|
||||
const val PORT_HINT = "端口请在 开发者选项 → 无线调试 中查看"
|
||||
const val NO_WIFI_TEXT = "未连接 Wi-Fi"
|
||||
|
||||
/** 仅当前网络为 Wi-Fi 时,从地址列表中取第一个非回环 IPv4;否则返回 null。 */
|
||||
fun selectWifiIpv4(isWifi: Boolean, addresses: List<InetAddress>): String? {
|
||||
if (!isWifi) return null
|
||||
return addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress
|
||||
}
|
||||
|
||||
/** adb_wifi_enabled 原始值:1 开启,0 未开启,其他值或读取失败(null)为未知。 */
|
||||
fun debugState(raw: Int?): WirelessDebugState = when (raw) {
|
||||
1 -> WirelessDebugState.ENABLED
|
||||
0 -> WirelessDebugState.DISABLED
|
||||
else -> WirelessDebugState.UNKNOWN
|
||||
}
|
||||
|
||||
fun display(reading: DeviceConnectionReading): DeviceConnectionDisplay {
|
||||
val (text, color) = when (reading.wirelessDebug) {
|
||||
WirelessDebugState.ENABLED -> "已开启" to R.color.agent_primary_light
|
||||
WirelessDebugState.DISABLED -> "未开启" to R.color.agent_warning
|
||||
WirelessDebugState.UNKNOWN -> "无法读取" to R.color.agent_text_muted
|
||||
}
|
||||
return DeviceConnectionDisplay(
|
||||
wifiText = "Wi-Fi IP:${reading.wifiIpv4 ?: NO_WIFI_TEXT}",
|
||||
debugText = "无线调试:$text",
|
||||
debugColor = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统读取的薄封装;任何异常都降级为「未知」,不记录日志、不持久化。 */
|
||||
internal object DeviceConnectionReader {
|
||||
fun read(context: Context): DeviceConnectionReading = DeviceConnectionReading(
|
||||
wifiIpv4 = readWifiIpv4(context),
|
||||
wirelessDebug = DeviceConnectionPolicy.debugState(readDebugRaw(context)),
|
||||
)
|
||||
|
||||
private fun readWifiIpv4(context: Context): String? = try {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
val network = manager?.activeNetwork
|
||||
val isWifi = network?.let { manager.getNetworkCapabilities(it) }
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
val addresses = if (isWifi && manager != null && network != null) {
|
||||
manager.getLinkProperties(network)?.linkAddresses?.map { it.address }.orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
DeviceConnectionPolicy.selectWifiIpv4(isWifi, addresses)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readDebugRaw(context: Context): Int? = try {
|
||||
Settings.Global.getInt(context.contentResolver, "adb_wifi_enabled", 0)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
@@ -43,6 +44,7 @@ import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.service.AgentStateStore
|
||||
import cn.ilapage.goauto.agent.service.CollectionCooldownPolicy
|
||||
import cn.ilapage.goauto.agent.service.PddManualOpenCoordinator
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
@@ -143,6 +145,9 @@ class TaskHistoryFragment : Fragment() {
|
||||
private val imageLoader = HistoryImageLoader()
|
||||
private val imageRequests = mutableListOf<HistoryImageRequest>()
|
||||
private var currentPageReceiverRegistered = false
|
||||
private var openPddButton: MaterialButton? = null
|
||||
private var openPddReason: TextView? = null
|
||||
private var openPddAwaitingResult = false
|
||||
private var backfillPanel: LinearLayout? = null
|
||||
private var repurchasePanel: LinearLayout? = null
|
||||
private var repurchaseButton: MaterialButton? = null
|
||||
@@ -156,6 +161,17 @@ class TaskHistoryFragment : Fragment() {
|
||||
renderRejections()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_DEVICE_BUSY_STATE) {
|
||||
applyOpenPddBusy()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_OPEN_PDD_RESULT) {
|
||||
if (!openPddAwaitingResult) return
|
||||
openPddAwaitingResult = false
|
||||
val message = intent.getStringExtra(AgentForegroundService.EXTRA_OPEN_PDD_MESSAGE).orEmpty()
|
||||
if (message.isNotBlank() && isAdded) Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
|
||||
renderRepurchase()
|
||||
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
|
||||
@@ -743,6 +759,53 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual "open in PDD" entry. Execution goes through the service so it can re-check busy state and
|
||||
* invalidate the purchase probe handoff; the button never taps anything inside PDD.
|
||||
*/
|
||||
private fun openInPddSection(goodsId: String?, purchase: Boolean): View? {
|
||||
openPddButton = null
|
||||
openPddReason = null
|
||||
if (PddManualOpenCoordinator.link(goodsId) == null) return null
|
||||
val context = requireContext()
|
||||
val id = goodsId.orEmpty()
|
||||
val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = "在拼多多中打开"
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "在拼多多中打开商品 $id"
|
||||
setOnClickListener {
|
||||
val busy = AgentForegroundService.deviceBusyReason
|
||||
if (busy != null) {
|
||||
applyOpenPddBusy()
|
||||
toast("$busy,暂不能打开拼多多")
|
||||
} else {
|
||||
openPddAwaitingResult = true
|
||||
runCatching { AgentForegroundService.openPddProduct(requireContext(), id, purchase) }
|
||||
.onFailure { openPddAwaitingResult = false; toast(PddManualOpenCoordinator.FAILED) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val reason = context.label("", 12f, context.getColor(R.color.agent_text_muted)).apply { visibility = View.GONE }
|
||||
openPddButton = button
|
||||
openPddReason = reason
|
||||
applyOpenPddBusy()
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, context.dp(4), 0, context.dp(8))
|
||||
addView(button)
|
||||
addView(reason)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyOpenPddBusy() {
|
||||
val button = openPddButton ?: return
|
||||
val reasonView = openPddReason ?: return
|
||||
val busy = AgentForegroundService.deviceBusyReason
|
||||
button.isEnabled = busy == null
|
||||
reasonView.text = busy.orEmpty()
|
||||
reasonView.visibility = if (busy == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
private fun renderCollectionDetail(detail: CollectionHistoryDetail) {
|
||||
val context = requireContext()
|
||||
val task = detail.task
|
||||
@@ -757,6 +820,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
val detailCard = context.cardColumn().apply {
|
||||
addView(context.label("#${task.taskId} · 第 ${task.attemptNumber} 次 · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true))
|
||||
openInPddSection(task.goodsId, purchase = false)?.let(::addView)
|
||||
if (task.source == "agent_current_page") addView(context.label("来源:Agent 当前页面", 13f, context.getColor(R.color.agent_text_muted)))
|
||||
addView(context.label(collectionStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(info, 14f))
|
||||
@@ -850,6 +914,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED)
|
||||
filter.addAction(AgentForegroundService.ACTION_DEVICE_BUSY_STATE)
|
||||
filter.addAction(AgentForegroundService.ACTION_OPEN_PDD_RESULT)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@@ -937,9 +1003,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
addView(context.label(PurchaseRejectionPresentation.notice(record) + "\n${record.errorCode}", 14f, context.getColor(R.color.agent_warning)))
|
||||
}))
|
||||
}
|
||||
val infoHead = "蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\nPDD 商品:${task.pddGoodsId}"
|
||||
val info = buildString {
|
||||
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
|
||||
append("PDD 商品:${task.pddGoodsId}\n")
|
||||
append("商品标题:${task.pddTitle.ifBlank { "—" }}\n")
|
||||
append("目标规格:${listOf(task.targetColor, task.targetSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
|
||||
append("执行规格:${listOf(task.mappedColor, task.mappedSize).filter(String::isNotBlank).joinToString(" / ").ifBlank { "—" }}\n")
|
||||
@@ -957,6 +1022,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
|
||||
addView(context.label(purchaseStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(infoHead, 14f))
|
||||
openInPddSection(task.pddGoodsId, purchase = true)?.let(::addView)
|
||||
addView(context.label(info, 14f))
|
||||
}), collectionCardParams())
|
||||
val lastFailure = detail.lastFailureMessage?.takeIf(String::isNotBlank)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertAction
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityAlertTracker
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityDispatchGate
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AccessibilityDispatchGateTest {
|
||||
@Test fun pausesClaimingWhenDisabledOrWaiting() {
|
||||
assertFalse(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.DISABLED))
|
||||
assertFalse(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
|
||||
@Test fun allowsClaimingWhenReady() {
|
||||
assertTrue(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.READY))
|
||||
}
|
||||
|
||||
@Test fun promptsOnceWhenReadyBecomesNotReady() {
|
||||
val tracker = AccessibilityAlertTracker()
|
||||
assertEquals(AccessibilityAlertAction.CANCEL, tracker.onReadiness(AccessibilityReadiness.READY))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.READY))
|
||||
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
|
||||
@Test fun switchingBetweenNotReadyStatesDoesNotPromptAgain() {
|
||||
val tracker = AccessibilityAlertTracker()
|
||||
tracker.onReadiness(AccessibilityReadiness.READY)
|
||||
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.DISABLED))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.DISABLED))
|
||||
}
|
||||
|
||||
@Test fun recoveryCancelsAndNextFailurePromptsAgain() {
|
||||
val tracker = AccessibilityAlertTracker()
|
||||
tracker.onReadiness(AccessibilityReadiness.READY)
|
||||
tracker.onReadiness(AccessibilityReadiness.DISABLED)
|
||||
assertEquals(AccessibilityAlertAction.CANCEL, tracker.onReadiness(AccessibilityReadiness.READY))
|
||||
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
|
||||
@Test fun firstObservationAfterProcessStartPromptsWhenNotReady() {
|
||||
assertEquals(
|
||||
AccessibilityAlertAction.PROMPT,
|
||||
AccessibilityAlertTracker().onReadiness(AccessibilityReadiness.ENABLED_WAITING),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
|
||||
import cn.ilapage.goauto.agent.ui.AccessibilityReadinessResolver
|
||||
import cn.ilapage.goauto.agent.ui.ManualTaskCheckPolicy
|
||||
@@ -46,9 +47,13 @@ class AccessibilityReadinessTest {
|
||||
ManualTaskCheckPolicy.blockedMessage("BUSY", 35L, AccessibilityReadiness.READY),
|
||||
)
|
||||
assertEquals(
|
||||
"请先到“设置”开启采集采购助手",
|
||||
"请到 设置 → 无障碍 开启 GoAuto",
|
||||
ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.DISABLED),
|
||||
)
|
||||
assertEquals(
|
||||
"请到 设置 → 无障碍,把 GoAuto 关闭后重新打开",
|
||||
ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.ENABLED_WAITING),
|
||||
)
|
||||
assertEquals(null, ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.READY))
|
||||
}
|
||||
|
||||
@@ -59,5 +64,9 @@ class AccessibilityReadinessTest {
|
||||
assertEquals("已领取采购任务", ManualTaskCheckPolicy.resultMessage("purchase_task"))
|
||||
assertEquals("采集间隔中,还需 12 秒", ManualTaskCheckPolicy.resultMessage("collection_cooldown:12"))
|
||||
assertEquals("检查失败,Agent 将继续自动重试", ManualTaskCheckPolicy.resultMessage("network_error"))
|
||||
assertEquals(
|
||||
"无障碍未就绪,已暂停领取任务",
|
||||
ManualTaskCheckPolicy.resultMessage(AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,21 @@ class AgentStatusTextPolicyTest {
|
||||
@Test fun hidesReadyDetailButKeepsRecoveryMessages() {
|
||||
assertNull(AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.READY))
|
||||
assertEquals(
|
||||
"请到“设置”检查无障碍服务",
|
||||
"请到 设置 → 无障碍,把 GoAuto 关闭后重新打开",
|
||||
AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.ENABLED_WAITING),
|
||||
)
|
||||
assertEquals(
|
||||
"请到“设置”开启",
|
||||
"请到 设置 → 无障碍 开启 GoAuto",
|
||||
AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.DISABLED),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun accessibilityCardTitleAndColorFollowReadiness() {
|
||||
assertEquals("已开启", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.READY))
|
||||
assertEquals("未开启,任务已暂停", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.DISABLED))
|
||||
assertEquals("未连接,任务已暂停", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.ENABLED_WAITING))
|
||||
assertEquals(R.color.agent_primary_light, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.READY))
|
||||
assertEquals(R.color.agent_error, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.DISABLED))
|
||||
assertEquals(R.color.agent_error, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.ENABLED_WAITING))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.DeviceConnectionPolicy
|
||||
import cn.ilapage.goauto.agent.ui.DeviceConnectionReading
|
||||
import cn.ilapage.goauto.agent.ui.WirelessDebugState
|
||||
import java.net.InetAddress
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceConnectionPolicyTest {
|
||||
private fun ip(text: String) = InetAddress.getByName(text)
|
||||
|
||||
@Test fun wifiWithIpv4ReturnsAddress() {
|
||||
assertEquals("192.168.0.93", DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("192.168.0.93"))))
|
||||
}
|
||||
|
||||
@Test fun nonWifiReturnsNull() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(false, listOf(ip("10.0.0.2"))))
|
||||
}
|
||||
|
||||
@Test fun wifiWithOnlyIpv6ReturnsNull() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"))))
|
||||
}
|
||||
|
||||
@Test fun wifiWithMultipleAddressesPicksIpv4() {
|
||||
assertEquals(
|
||||
"172.16.1.5",
|
||||
DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"), ip("172.16.1.5"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun loopbackIpv4IsSkipped() {
|
||||
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("127.0.0.1"))))
|
||||
}
|
||||
|
||||
@Test fun debugRawValuesMapToState() {
|
||||
assertEquals(WirelessDebugState.ENABLED, DeviceConnectionPolicy.debugState(1))
|
||||
assertEquals(WirelessDebugState.DISABLED, DeviceConnectionPolicy.debugState(0))
|
||||
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(2))
|
||||
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(null))
|
||||
}
|
||||
|
||||
@Test fun displayEnabledUsesPrimaryColor() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading("192.168.0.93", WirelessDebugState.ENABLED))
|
||||
assertEquals("Wi-Fi IP:192.168.0.93", d.wifiText)
|
||||
assertEquals("无线调试:已开启", d.debugText)
|
||||
assertEquals(R.color.agent_primary_light, d.debugColor)
|
||||
}
|
||||
|
||||
@Test fun displayDisabledUsesWarningColorAndNoWifiText() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.DISABLED))
|
||||
assertEquals("Wi-Fi IP:未连接 Wi-Fi", d.wifiText)
|
||||
assertEquals("无线调试:未开启", d.debugText)
|
||||
assertEquals(R.color.agent_warning, d.debugColor)
|
||||
}
|
||||
|
||||
@Test fun displayUnknownUsesMutedColor() {
|
||||
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.UNKNOWN))
|
||||
assertEquals("无线调试:无法读取", d.debugText)
|
||||
assertEquals(R.color.agent_text_muted, d.debugColor)
|
||||
}
|
||||
}
|
||||
+794
-16
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorConfig
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorResult
|
||||
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
|
||||
@@ -1105,10 +1106,50 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
|
||||
fun `same-frame sizes with one normalized name keep a single value and mark conflict`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL ¥10", "XL ¥12")),
|
||||
sizePages = listOf(listOf("XL ¥10", "XL ¥12", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertEquals(listOf("XL", "S"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
assertFalse(payload.missing.contains("size"))
|
||||
assertEquals(listOf("XL", "S"), payload.skus.map { it.specs.getValue("size") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `size price appearing between passes keeps the dimension and marks conflict once`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertEquals(listOf("XL", "S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `residual currency in a size still rejects the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL ¥10 热卖", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
@@ -1120,9 +1161,151 @@ class PddProductDetailCollectorTest {
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertTrue(payload.dimensions.none { it.key == "size" })
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
assertFalse(payload.missing.contains("spec_dimension_conflict:size"))
|
||||
assertTrue(payload.skus.all { "size" !in it.specs })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty normalized size still rejects the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("¥10", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val payload = requireNotNull(
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
|
||||
)
|
||||
|
||||
assertTrue(payload.dimensions.none { it.key == "size" })
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuation sizes drop the trailing price like the parsed path`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色"),
|
||||
sizePages = listOf(listOf("S", "M"), listOf("L ¥10", "XL")),
|
||||
hideDimensionHeadingsAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("S", "M", "L", "XL"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuation conflict is seen before de-duplication and keeps one value`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色"),
|
||||
sizePages = listOf(listOf("S", "M"), listOf("XL ¥10", "XL", "L")),
|
||||
hideDimensionHeadingsAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("S", "M", "XL", "L"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuation size with residual currency rejects the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色"),
|
||||
sizePages = listOf(listOf("S", "M"), listOf("L ¥10 热卖", "XL")),
|
||||
hideDimensionHeadingsAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val payload = requireNotNull(
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
|
||||
)
|
||||
|
||||
assertTrue(payload.dimensions.none { it.key == "size" })
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalization conflict trace carries only counts and enum values`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
|
||||
|
||||
val lines = traces.filter { it.startsWith("spec-normalize ") }
|
||||
assertEquals(1, lines.size)
|
||||
assertEquals(
|
||||
"spec-normalize dim=size outcome=conflict_kept reason=normalized_conflict path=parsed pass=1 sameFrame=false " +
|
||||
"onlyTrailingPriceDiff=true rawVariants=2 candidates=2 badgeCaptionMerged=0",
|
||||
lines.single(),
|
||||
)
|
||||
assertTrue(lines.none { it.contains("XL") || it.contains("¥") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalization invalid trace reports reason without node text`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("黑色"),
|
||||
sizePages = listOf(listOf("XL ¥10 热卖", "S")),
|
||||
prices = mapOf("黑色" to 1000L),
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
|
||||
|
||||
val line = traces.single { it.startsWith("spec-normalize ") }
|
||||
assertTrue(line.contains("dim=size outcome=invalid_rejected reason=unsafe_currency path=parsed"))
|
||||
assertTrue(line.contains("candidates=2"))
|
||||
assertTrue(!line.contains("热卖") && !line.contains("XL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-frame color conflict trace flags sameFrame and badge count`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色 ¥10", "红色 ¥12"),
|
||||
prices = mapOf("红色 ¥10" to 1000L),
|
||||
imageColorCards = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
|
||||
|
||||
val line = traces.first { it.startsWith("spec-normalize dim=color") }
|
||||
assertTrue(line.contains("outcome=conflict_kept"))
|
||||
assertTrue(line.contains("sameFrame=true"))
|
||||
assertTrue(line.contains("onlyTrailingPriceDiff=true"))
|
||||
assertTrue(line.contains("rawVariants=2"))
|
||||
assertTrue(line.endsWith("badgeCaptionMerged=0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser counts merged badge captions without exposing text`() {
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence())
|
||||
assertEquals(2, parsed.badgeCaptionMerged)
|
||||
|
||||
val plain = PddScreenParser.parse(
|
||||
badgeCardsSnapshot(listOf(badgeCard(3, 36, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"))),
|
||||
config(), GOODS_ID, evidence(),
|
||||
)
|
||||
assertEquals(0, plain.badgeCaptionMerged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing color prices are normalized across dimension price and sku`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
@@ -1143,10 +1326,32 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate colors after price cleanup reject color dependents`() {
|
||||
fun `duplicate colors after price cleanup keep one value and mark conflict`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色 ¥10", "红色 ¥12"),
|
||||
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L),
|
||||
colors = listOf("红色 ¥10", "红色 ¥12", "蓝色"),
|
||||
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L, "蓝色" to 1500L),
|
||||
imageColorCards = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.dimensions.first { it.key == "color" }.values)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(1000L, payload.colorPrices.first { it.color == "红色" }.priceCent)
|
||||
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:color" })
|
||||
assertFalse(payload.missing.contains("spec_dimension_invalid:color"))
|
||||
assertEquals(listOf("红色 ¥10", "蓝色"), driver.clickedLabels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `residual currency in a color still rejects color dependents`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = listOf("红色 ¥10 热卖", "蓝色"),
|
||||
prices = mapOf("红色 ¥10 热卖" to 1000L, "蓝色" to 1500L),
|
||||
imageColorCards = true,
|
||||
)
|
||||
var clock = 0L
|
||||
@@ -1160,6 +1365,7 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(payload.colorPrices.isEmpty())
|
||||
assertTrue(payload.skus.isEmpty())
|
||||
assertTrue(payload.missing.contains("spec_dimension_invalid:color"))
|
||||
assertFalse(payload.missing.contains("spec_dimension_conflict:color"))
|
||||
assertTrue(result.colorImages.isEmpty())
|
||||
}
|
||||
|
||||
@@ -1212,7 +1418,7 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 60_000))))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
@@ -2092,6 +2298,325 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun gridColors(count: Int) = (1..count).map { "款%02d色".format(it) }
|
||||
|
||||
private fun gridConfig() = config().copy(
|
||||
timeoutsMs = config().timeoutsMs + ("overall" to 600_000),
|
||||
limits = config().limits + mapOf("specHorizontalSwipes" to 12, "specVerticalSwipes" to 3, "stableEdgeReads" to 2),
|
||||
)
|
||||
|
||||
private fun sharedGridCollects47Colors(step: Int) {
|
||||
// 47 colors in 2 rows x 24 columns sharing one horizontal container.
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
assertEquals(listOf(24, 23), grid.map { it.size })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridVisibleColumns = 4,
|
||||
gridStep = step,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
|
||||
assertEquals(47, payload.colorPrices.size)
|
||||
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
|
||||
// 47 colors x 2 sizes, each SKU carrying its own color's price.
|
||||
assertEquals(94, payload.skus.size)
|
||||
assertEquals(setOf("S", "M"), payload.skus.map { it.specs.getValue("size") }.toSet())
|
||||
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
// The shared container is swept once: no reverse sweep away from an edge.
|
||||
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
assertTrue(driver.gridSwipeLog.count { it.first == SwipeDirection.LEFT } <= 12)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(47, event.clickableColorCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container two row grid collects all colors when swipes overlap`() = sharedGridCollects47Colors(step = 3)
|
||||
|
||||
@Test
|
||||
fun `shared container two row grid collects all colors when swipes move whole pages`() = sharedGridCollects47Colors(step = 4)
|
||||
|
||||
private fun sharedPixelGridCollects(count: Int, step: Int, maxSwipes: Int, jitter: Boolean = false) {
|
||||
// Shared container, ~3.5 columns per screen, pixel-sized swipes whose
|
||||
// last move is shorter than one column; partly visible columns count.
|
||||
val colors = gridColors(count)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = step,
|
||||
gridClickJitter = jitter,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
|
||||
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
val horizontal = driver.gridSwipeLog.size
|
||||
assertTrue("horizontal swipes $horizontal > $maxSwipes ${driver.gridSwipeLog}", horizontal <= maxSwipes)
|
||||
assertEquals(count, payload.colorPrices.size)
|
||||
assertEquals(count * 2, payload.skus.size)
|
||||
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container with click layout jitter still skips the reverse sweep`() =
|
||||
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12, jitter = true)
|
||||
|
||||
@Test
|
||||
fun `shared container nine colors with click layout jitter does not sweep back`() =
|
||||
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6, jitter = true)
|
||||
|
||||
private fun midStartGrid(
|
||||
failAttempts: Set<Int>,
|
||||
initialOffset: Int = 2000,
|
||||
traces: MutableList<String> = mutableListOf(),
|
||||
captureCostMillis: Long = 0L,
|
||||
elapsed: LongArray = LongArray(1),
|
||||
): Pair<FakeCollectorDriver, PddCollectorResult> {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
var clock = 0L
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
captureCostMillis = captureCostMillis,
|
||||
captureAdvance = { clock += it },
|
||||
gridInitialOffset = initialOffset,
|
||||
gridRightFailAttempts = failAttempts,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
elapsed[0] = clock
|
||||
return driver to result
|
||||
}
|
||||
|
||||
private fun assertAll47Collected(result: PddCollectorResult) {
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals("completed", payload.status)
|
||||
val collected = payload.dimensions.first { it.key == "color" }.values
|
||||
assertEquals("collected ${collected.size}/47 colors", gridColors(47).toSet(), collected.toSet())
|
||||
assertEquals(47, payload.colorPrices.size)
|
||||
assertTrue(payload.missing.isEmpty())
|
||||
}
|
||||
|
||||
private class SettleRun(
|
||||
val driver: FakeCollectorDriver,
|
||||
val result: PddCollectorResult,
|
||||
val traces: List<String>,
|
||||
val elapsedMs: Long,
|
||||
)
|
||||
|
||||
// The grid keeps coasting until HORIZONTAL_SETTLE_MILLIS-ish of simulated
|
||||
// clock time has passed since the last movement (timeBased), mirroring the
|
||||
// ~1-1.3s measured on a device.
|
||||
private fun inertiaGrid(timeBased: Boolean): SettleRun {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
|
||||
var clock = 0L
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
prices = prices,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
gridSettleClock = if (timeBased) ({ clock }) else null,
|
||||
gridSettleMillis = if (timeBased) 1000L else 0L,
|
||||
gridStrictClickPosition = true,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
val traces = mutableListOf<String>()
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
return SettleRun(driver, result, traces, clock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color grid waits a fixed time after a horizontal swipe without extra parses`() {
|
||||
val calm = inertiaGrid(timeBased = false)
|
||||
val coasting = inertiaGrid(timeBased = true)
|
||||
println("SIM_ELAPSED_47_COLORS=${coasting.elapsedMs}ms settles=${coasting.traces.count { it.startsWith("color-settle") }}")
|
||||
|
||||
assertAll47Collected(coasting.result)
|
||||
assertTrue(coasting.traces.none { it.startsWith("color-click-miss") })
|
||||
val settles = coasting.traces.filter { it.startsWith("color-settle") }
|
||||
assertTrue(settles.isNotEmpty())
|
||||
settles.forEach { assertEquals("color-settle waitedMs=1200", it) }
|
||||
// No polling reads: the same captures and clicks as a grid that never coasts.
|
||||
assertEquals(calm.driver.captureCount, coasting.driver.captureCount)
|
||||
assertEquals(calm.driver.clickCount, coasting.driver.clickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset from the right end with scattered anchor failures still confirms the left end`() {
|
||||
val traces = mutableListOf<String>()
|
||||
val elapsed = LongArray(1)
|
||||
val (driver, result) = midStartGrid(
|
||||
failAttempts = setOf(1, 3, 5, 7),
|
||||
initialOffset = 4675,
|
||||
traces = traces,
|
||||
captureCostMillis = 900L,
|
||||
elapsed = elapsed,
|
||||
)
|
||||
println("SIM_ELAPSED_RIGHT_START=${elapsed[0]}ms")
|
||||
assertTrue("elapsed ${elapsed[0]}", elapsed[0] < BASELINE_RIGHT_START_ELAPSED_MS * 7 / 10)
|
||||
|
||||
assertAll47Collected(result)
|
||||
assertTrue(traces.toString(), traces.any { it.startsWith("color-start confirmed=true") && it.contains("anchorRetries=4") })
|
||||
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid start with one anchor failure while resetting retries and still skips the reverse sweep`() {
|
||||
val (driver, result) = midStartGrid(failAttempts = setOf(2))
|
||||
|
||||
assertAll47Collected(result)
|
||||
// The retry used a different color node than the failed attempt.
|
||||
val rights = driver.swipes.filter { it.first == SwipeDirection.RIGHT }
|
||||
assertTrue(rights[0].second?.label != rights[1].second?.label)
|
||||
// Reset reached the left edge, then the sweep ran left-to-right once.
|
||||
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid start whose reset cannot be confirmed keeps the full snake sweep`() {
|
||||
val (driver, result) = midStartGrid(failAttempts = setOf(1, 2, 3))
|
||||
|
||||
assertAll47Collected(result)
|
||||
// Reverse sweep from the right edge happened after the LEFT sweep.
|
||||
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
|
||||
assertTrue(driver.gridSwipeLog.drop(firstLeft).any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container with a row that fails to follow keeps the full snake sweep`() {
|
||||
val colors = gridColors(47)
|
||||
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
horizontalGrid = grid,
|
||||
gridPixelStep = 640,
|
||||
gridSwipeWhereOnlyFirstRowMoves = 3,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared container nine colors with partial last swipe does not sweep back`() =
|
||||
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6)
|
||||
|
||||
@Test
|
||||
fun `shared container forty seven colors with partial last swipe stays within twelve swipes`() =
|
||||
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12)
|
||||
|
||||
@Test
|
||||
fun `independent row grid keeps the snake sweep and does not claim a shared container`() {
|
||||
val colors = gridColors(20)
|
||||
val grid = listOf(colors.take(10), colors.drop(10))
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
horizontalGrid = grid,
|
||||
gridVisibleColumns = 4,
|
||||
gridStep = 3,
|
||||
gridSharedContainer = false,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val collected = requireNotNull(result.payload).dimensions.first { it.key == "color" }.values
|
||||
// Row 0 is swept to its end; row 1 only exposes its first window because
|
||||
// it starts at its own left edge, exactly as before.
|
||||
assertEquals((colors.take(10) + colors.drop(10).take(4)).toSet(), collected.toSet())
|
||||
assertEquals(14, collected.size)
|
||||
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `row disappearing keeps reflow reason after later empty vertical reads`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeCollectorDriver(
|
||||
colors = colors,
|
||||
colorPages = listOf(colors.take(4), colors.takeLast(2)),
|
||||
rowSize = 2,
|
||||
sizePages = listOf(listOf("S"), listOf("M")),
|
||||
hideColorHeadingAfterFirstVerticalPage = true,
|
||||
)
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
|
||||
.collect(GOODS_ID, rule(gridConfig()))
|
||||
|
||||
assertTrue(result.successful)
|
||||
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
|
||||
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, event.reason)
|
||||
assertTrue(requireNotNull(event.colorTrailingEmptyReadCount) > 0)
|
||||
assertEquals(null, event.colorHorizontalIncomplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single row disjoint reflow keeps reflow reason`() {
|
||||
val colors = listOf("A色", "B色", "C色", "D色")
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(
|
||||
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
|
||||
{ clock }, { clock += it }, taskId = 370, diagnostic = events::add,
|
||||
).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.reason)
|
||||
}
|
||||
|
||||
private class FakeCollectorDriver(
|
||||
colors: List<String> = listOf("红色"),
|
||||
sizes: List<String> = listOf("S"),
|
||||
@@ -2146,6 +2671,39 @@ class PddProductDetailCollectorTest {
|
||||
// restored to top. Heading count drops but color/size values are
|
||||
// untouched, so this must NOT be treated as a collapsed panel.
|
||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
||||
// #370: horizontal multi-row color grid. Each swipe moves gridStep
|
||||
// columns; gridSharedContainer moves all rows together, otherwise only
|
||||
// the anchored row. Left/right swipes are logged with the largest
|
||||
// column offset before the gesture.
|
||||
private val horizontalGrid: List<List<String>>? = null,
|
||||
private val gridVisibleColumns: Int = 4,
|
||||
private val gridStep: Int = 3,
|
||||
private val gridSharedContainer: Boolean = true,
|
||||
// #370: when set, shared-container swipes move this many pixels (not whole
|
||||
// columns) and columns partly inside the 835px viewport count as visible.
|
||||
private val gridPixelStep: Int? = null,
|
||||
// #370: after each click the grid nodes shift a few pixels, like the
|
||||
// small layout change seen on a real device after selecting a color.
|
||||
private val gridClickJitter: Boolean = false,
|
||||
// #370: on this (1-based) horizontal swipe only the first row moves.
|
||||
private val gridSwipeWhereOnlyFirstRowMoves: Int = 0,
|
||||
// #370: the shared grid starts scrolled this many pixels to the right.
|
||||
private val gridInitialOffset: Int = 0,
|
||||
// #370: 1-based RIGHT swipe attempts that fail with anchorMissing.
|
||||
private val gridRightFailAttempts: Set<Int> = emptySet(),
|
||||
// #370: after a horizontal swipe that moved the grid, the next N captures
|
||||
// still show it coasting (nodes drift 30px per remaining capture).
|
||||
private val gridInertiaCaptures: Int = 0,
|
||||
// #370: time-based coasting: captures within gridSettleMillis of the last
|
||||
// grid movement (by gridSettleClock) show the nodes drifted by 30px.
|
||||
private val gridSettleClock: (() -> Long)? = null,
|
||||
private val gridSettleMillis: Long = 0L,
|
||||
// #370: each capture() (a parse) costs this much simulated time.
|
||||
private val captureCostMillis: Long = 0L,
|
||||
private val captureAdvance: ((Long) -> Unit)? = null,
|
||||
// #370: a click is NOT_FOUND when the node is not where the last capture
|
||||
// placed it (position at click time differs from the parsed target).
|
||||
private val gridStrictClickPosition: Boolean = false,
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -2155,6 +2713,15 @@ class PddProductDetailCollectorTest {
|
||||
var backCount = 0
|
||||
var entryClickCount = 0
|
||||
var restoreGestures = 0
|
||||
val gridSwipeLog = mutableListOf<Pair<SwipeDirection, Int>>()
|
||||
private val gridOffsets = IntArray(horizontalGrid?.size ?: 0) { gridInitialOffset }
|
||||
private var rightAttempts = 0
|
||||
private var inertiaRemaining = 0
|
||||
private var currentDrift = 0
|
||||
// Parses between a spec swipe and the next click (discovery) cost captureCostMillis.
|
||||
private var costlyParses = false
|
||||
private var lastGridMoveAt = Long.MIN_VALUE / 2
|
||||
private var lastSwipeFailure = "none"
|
||||
private var selected: String? = initialSelectedColor
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
@@ -2167,7 +2734,15 @@ class PddProductDetailCollectorTest {
|
||||
private var adviceOpen = false
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (captureCostMillis > 0 && costlyParses) captureAdvance?.invoke(captureCostMillis)
|
||||
return captureFrame(advanceInertia = true)
|
||||
}
|
||||
|
||||
private fun captureFrame(advanceInertia: Boolean): UiSnapshot {
|
||||
captureCount++
|
||||
currentDrift = inertiaRemaining * 30 +
|
||||
if (gridSettleClock != null && gridSettleClock.invoke() - lastGridMoveAt < gridSettleMillis) 30 else 0
|
||||
if (advanceInertia && inertiaRemaining > 0) inertiaRemaining--
|
||||
fixedSnapshot?.let { return it }
|
||||
if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
@@ -2224,21 +2799,33 @@ class PddProductDetailCollectorTest {
|
||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
||||
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
val gridPlaced = horizontalGrid?.flatMapIndexed { r, row ->
|
||||
if (gridPixelStep != null) {
|
||||
row.mapIndexed { c, color -> Triple(color, r, c) }.filter { (_, _, c) ->
|
||||
val left = 30 + c * 230 - gridOffsets[r]
|
||||
left < GRID_VIEWPORT_RIGHT && left + 190 > 0
|
||||
}
|
||||
} else {
|
||||
row.drop(gridOffsets[r]).take(gridVisibleColumns).mapIndexed { c, color -> Triple(color, r, c) }
|
||||
}
|
||||
}
|
||||
val visibleColors = gridPlaced?.map { it.first }
|
||||
?: colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val colorRowCount = horizontalGrid?.size ?: ((visibleColors.size + rowSize - 1) / rowSize)
|
||||
val placedColors = gridPlaced ?: visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.mapIndexed { index, color -> Triple(color, index / rowSize, index % rowSize) }
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.forEachIndexed { index, color ->
|
||||
val row = index / rowSize
|
||||
val column = index % rowSize
|
||||
placedColors.forEach { (color, row, column) ->
|
||||
val path = "scroll/color-$color-$captureCount"
|
||||
val left = 30 + column * 230
|
||||
val shift = if (gridPixelStep != null) gridOffsets[row] else 0
|
||||
val jitter = if (gridClickJitter && horizontalGrid != null) (clickCount % 4) * 2 else 0
|
||||
val left = 30 + column * 230 - shift + jitter + currentDrift
|
||||
val top = 470 + row * 90
|
||||
if (imageColorCards) {
|
||||
nodes += SnapshotNode(
|
||||
@@ -2254,7 +2841,7 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
} else {
|
||||
nodes += node(
|
||||
path, color, left, top, 220 + column * 230, top + 70,
|
||||
path, color, left, top, 220 + column * 230 - shift + jitter + currentDrift, top + 70,
|
||||
clickable = true, selected = displayedSelected() == color, parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
@@ -2312,11 +2899,13 @@ class PddProductDetailCollectorTest {
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clickCount++
|
||||
costlyParses = false
|
||||
clickedLabels += target.label
|
||||
if (target.label in failedClicks) return FreshActionResult.FAILED
|
||||
val current = capture().nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
|
||||
val current = captureFrame(advanceInertia = false).nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
|
||||
if (current.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (current.size > 1) return FreshActionResult.AMBIGUOUS
|
||||
if (gridStrictClickPosition && current.single().bounds != target.bounds) return FreshActionResult.NOT_FOUND
|
||||
if (!panelOpen && target.label == "单独购买") {
|
||||
entryClickCount++
|
||||
when {
|
||||
@@ -2355,8 +2944,43 @@ class PddProductDetailCollectorTest {
|
||||
private fun displayedSelected(): String? =
|
||||
if (staleSelectionEvidence && previousSelected != null) previousSelected else selected
|
||||
|
||||
override fun specRowSwipeFailureReason(): String = lastSwipeFailure
|
||||
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
swipes += direction to anchor
|
||||
costlyParses = true
|
||||
lastSwipeFailure = "none"
|
||||
if (direction == SwipeDirection.RIGHT && horizontalGrid != null) {
|
||||
rightAttempts++
|
||||
if (rightAttempts in gridRightFailAttempts) {
|
||||
lastSwipeFailure = "anchorMissing"
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (horizontalGrid != null && (direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT)) {
|
||||
gridSwipeLog += direction to (gridOffsets.maxOrNull() ?: 0)
|
||||
val unit = gridPixelStep ?: gridStep
|
||||
val delta = if (direction == SwipeDirection.LEFT) unit else -unit
|
||||
val moved = if (gridSharedContainer) horizontalGrid.indices.toList()
|
||||
else listOf((((anchor?.bounds?.top ?: 470) - 470) / 90).coerceIn(0, horizontalGrid.lastIndex))
|
||||
val sharedMax = if (gridPixelStep != null) {
|
||||
30 + (horizontalGrid.maxOf { it.size } - 1) * 230 + 190 - GRID_VIEWPORT_RIGHT
|
||||
} else {
|
||||
horizontalGrid.maxOf { it.size } - gridVisibleColumns
|
||||
}
|
||||
val brokenSwipe = gridSwipeWhereOnlyFirstRowMoves > 0 &&
|
||||
gridSwipeLog.size == gridSwipeWhereOnlyFirstRowMoves
|
||||
val offsetsBefore = gridOffsets.toList()
|
||||
moved.filter { !brokenSwipe || it == 0 }.forEach { r ->
|
||||
val max = if (gridSharedContainer) sharedMax else horizontalGrid[r].size - gridVisibleColumns
|
||||
gridOffsets[r] = (gridOffsets[r] + delta).coerceIn(0, maxOf(0, max))
|
||||
}
|
||||
if (gridOffsets.toList() != offsetsBefore) {
|
||||
inertiaRemaining = gridInertiaCaptures
|
||||
gridSettleClock?.let { lastGridMoveAt = it.invoke() }
|
||||
}
|
||||
return true
|
||||
}
|
||||
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
|
||||
when (direction) {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
@@ -2391,8 +3015,162 @@ class PddProductDetailCollectorTest {
|
||||
}
|
||||
}
|
||||
|
||||
// #379: purchase-1 card shape: clickable ViewGroup > (View, full-name ImageView,
|
||||
// "打开大图" ImageView, non-clickable LinearLayout > clickable caption TextView).
|
||||
private fun badgeCard(index: Int, left: Int, fullName: String, caption: String): List<SnapshotNode> {
|
||||
fun described(path: String, parent: String?, text: String?, description: String?, bounds: NodeBounds, className: String, clickable: Boolean) =
|
||||
SnapshotNode(
|
||||
path, parent, text, description, null, className, bounds,
|
||||
clickable, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
|
||||
)
|
||||
val card = "scroll/card$index"
|
||||
val right = left + 316
|
||||
return listOf(
|
||||
described(card, "scroll", null, fullName, NodeBounds(left, 470, right, 690), "android.view.ViewGroup", true),
|
||||
described("$card/bg", card, null, null, NodeBounds(left, 470, right, 690), "android.view.View", false),
|
||||
described("$card/image", card, null, fullName, NodeBounds(left, 470, right, 640), "android.widget.ImageView", true),
|
||||
described("$card/big", card, null, "打开大图", NodeBounds(left, 470, left + 111, 581), "android.widget.ImageView", true),
|
||||
described("$card/box", card, null, null, NodeBounds(left, 630, right, 690), "android.widget.LinearLayout", false),
|
||||
described("$card/box/caption", "$card/box", caption, null, NodeBounds(left, 630, right, 690), "android.widget.TextView", true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun badgeCardsSnapshot(cards: List<List<SnapshotNode>>): UiSnapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("summary", "已选:", 20, 300, 700, 360),
|
||||
node("title", "确认款式", 20, 370, 300, 410),
|
||||
node("scroll", "", 0, 380, 1080, 1900, scrollable = true),
|
||||
node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll"),
|
||||
) + cards.flatten() + listOf(
|
||||
node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll"),
|
||||
node("scroll/size-s", "S 75斤内穿", 30, 770, 220, 840, clickable = true, parentPath = "scroll"),
|
||||
node("scroll/size-m", "M 80-90斤", 260, 770, 450, 840, clickable = true, parentPath = "scroll"),
|
||||
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
private fun purchase1Cards(captionOne: String = "黑色 微喇春秋加...\n ¥11.8 零差评") = listOf(
|
||||
badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", captionOne),
|
||||
badgeCard(2, 382, " 黑色 微喇春夏款长裤 ¥9.8", " 黑色 微喇春夏...\n ¥9.8 最多人选择"),
|
||||
badgeCard(3, 728, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `price plus badge caption merges into the outer card and yields three colors`() {
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(
|
||||
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
|
||||
colors.map { it.text },
|
||||
)
|
||||
assertEquals(colors.size, colors.map { it.text }.distinct().size)
|
||||
assertTrue(colors.none { it.text.contains('¥') || it.text.contains('¥') })
|
||||
assertEquals("黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", colors[0].rawText)
|
||||
assertTrue(colors.all { it.node.path.endsWith("/caption") || it.node.path.startsWith("scroll/card") })
|
||||
// Size dimension is unaffected.
|
||||
assertEquals(listOf("S 75斤内穿", "M 80-90斤"), parsed.dimensions.first { it.key == "size" }.values.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `price plus badge cards collect three colors without invalid marker`() {
|
||||
val base = FakeCollectorDriver(colors = listOf("占位"), sizePages = listOf(listOf("S 75斤内穿", "M 80-90斤")))
|
||||
val driver = object : PddCollectorDriver by base {
|
||||
override fun capture(): UiSnapshot {
|
||||
val snapshot = base.capture()
|
||||
return snapshot.copy(
|
||||
nodes = snapshot.nodes.filterNot { it.path.startsWith("scroll/color-") && it.path != "scroll/color-heading" } +
|
||||
purchase1Cards().flatten(),
|
||||
)
|
||||
}
|
||||
}
|
||||
var clock = 0L
|
||||
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(
|
||||
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
|
||||
payload.dimensions.first { it.key == "color" }.values,
|
||||
)
|
||||
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption amount differing from the outer card price is not merged`() {
|
||||
val parsed = PddScreenParser.parse(
|
||||
badgeCardsSnapshot(purchase1Cards("黑色 微喇春秋加...\n ¥11.9 零差评")),
|
||||
config(), GOODS_ID, evidence(),
|
||||
)
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(4, colors.size)
|
||||
assertTrue(colors.any { it.text.contains('¥') })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption with currency or an overlong badge is not merged`() {
|
||||
for (caption in listOf("黑色 微喇春秋加...\n ¥11.8 零¥差评", "黑色 微喇春秋加...\n ¥11.8 零差评零差评零差评", "黑色 微喇春秋加...\n ¥11.8 零 差评")) {
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards(caption)), config(), GOODS_ID, evidence())
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(caption, 4, colors.size)
|
||||
assertTrue(caption, colors.any { it.text.contains('¥') })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption is not merged when the outer card has no price`() {
|
||||
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤", "黑色 微喇春秋加...\n ¥11.8 零差评"))
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(2, colors.size)
|
||||
assertTrue(colors.any { it.text.contains('¥') })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption must still be a prefix of the outer name`() {
|
||||
val parsed = PddScreenParser.parse(
|
||||
badgeCardsSnapshot(purchase1Cards("红色 微喇春秋加...\n ¥11.8 零差评")),
|
||||
config(), GOODS_ID, evidence(),
|
||||
)
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(4, colors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `badge caption does not merge when the card holds a second clickable text`() {
|
||||
val card = badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加...\n ¥11.8 零差评")
|
||||
val extra = SnapshotNode(
|
||||
"scroll/card1/box/other", "scroll/card1/box", "黑色", null, null, "android.widget.TextView",
|
||||
NodeBounds(36, 630, 100, 660), clickable = true, scrollable = false, selected = false,
|
||||
checked = false, enabled = true, visible = true,
|
||||
)
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(listOf(card + extra)), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertTrue(colors.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated caption without badge still merges as before`() {
|
||||
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加薄绒长裤 (适合 ¥11.8"))
|
||||
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
|
||||
|
||||
val colors = parsed.dimensions.first { it.key == "color" }.values
|
||||
assertEquals(listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)"), colors.map { it.text })
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val GOODS_ID = "719834019024"
|
||||
private const val GRID_VIEWPORT_RIGHT = 835
|
||||
// Simulated elapsed time of the right-start 47 color scenario at 9619c38.
|
||||
private const val BASELINE_RIGHT_START_ELAPSED_MS = 127_550L
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddManualOpenTest {
|
||||
private class Harness(
|
||||
val busy: String? = null,
|
||||
val direct: () -> Boolean = { true },
|
||||
val browser: () -> Boolean = { true },
|
||||
) {
|
||||
val calls = mutableListOf<String>()
|
||||
var preferDirect: Boolean? = null
|
||||
val coordinator = PddManualOpenCoordinator(
|
||||
busyReason = { calls += "busy"; busy },
|
||||
invalidateHandoff = { calls += "invalidate" },
|
||||
launch = { _, prefer, diagnostic ->
|
||||
calls += "launch"
|
||||
preferDirect = prefer
|
||||
PddLaunchFallback.open(prefer, direct = { calls += "direct"; direct() },
|
||||
browser = { calls += "browser"; browser() }, diagnostic = diagnostic)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun linkUsesGoodsIdOnly() {
|
||||
assertEquals("https://mobile.yangkeduo.com/goods.html?goods_id=825093359197", PddManualOpenCoordinator.link("825093359197"))
|
||||
}
|
||||
|
||||
@Test fun linkRejectsBlankOrNonDigitGoodsId() {
|
||||
listOf(null, "", " ", "12a", "1 2", "12&x=1", "-1", "123").forEach { assertNull(it, PddManualOpenCoordinator.link(it)) }
|
||||
}
|
||||
|
||||
@Test fun invalidGoodsIdNeitherChecksBusyNorInvalidatesNorOpens() {
|
||||
val h = Harness()
|
||||
assertEquals(PddManualOpenResult.InvalidGoodsId, h.coordinator.run("abc", purchase = false))
|
||||
assertTrue(h.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun collectionButtonOnlyUsesBrowser() {
|
||||
val h = Harness(direct = { error("unexpected") })
|
||||
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = false))
|
||||
assertEquals(false, h.preferDirect)
|
||||
assertFalse(h.calls.contains("direct"))
|
||||
assertTrue(h.calls.contains("browser"))
|
||||
}
|
||||
|
||||
@Test fun purchaseButtonPrefersDirectAndStopsWhenAccepted() {
|
||||
val h = Harness(browser = { error("unexpected") })
|
||||
assertEquals(PddManualOpenResult.Opened(null), h.coordinator.run("1", purchase = true))
|
||||
assertEquals(true, h.preferDirect)
|
||||
assertFalse(h.calls.contains("browser"))
|
||||
}
|
||||
|
||||
@Test fun purchaseDirectExceptionFallsBackToBrowserWithSimpleClassName() {
|
||||
val h = Harness(direct = { throw SecurityException("secret url and address") })
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器(SecurityException)"), result)
|
||||
val text = PddManualOpenCoordinator.message(result)
|
||||
assertFalse(text.contains("secret"))
|
||||
assertFalse(text.contains("http"))
|
||||
assertTrue(text.endsWith(PddManualOpenCoordinator.OPENED_HINT))
|
||||
}
|
||||
|
||||
@Test fun purchaseDirectRejectedFallbackNoticeOmitsParenthesesWithoutClass() {
|
||||
val h = Harness(direct = { false })
|
||||
assertEquals(PddManualOpenResult.Opened("拼多多直接打开失败,已改用浏览器"), h.coordinator.run("1", purchase = true))
|
||||
}
|
||||
|
||||
@Test fun allPathsFailingReportsOpenFailure() {
|
||||
val h = Harness(direct = { throw IllegalStateException("x") }, browser = { false })
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Failed, result)
|
||||
assertEquals("无法打开拼多多链接", PddManualOpenCoordinator.message(result))
|
||||
}
|
||||
|
||||
@Test fun fallbackNoticeNeedsAcceptedBrowser() {
|
||||
val events = listOf(
|
||||
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
|
||||
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED),
|
||||
)
|
||||
assertNull(PddManualOpenCoordinator.fallbackNotice(events))
|
||||
}
|
||||
|
||||
@Test fun busyAtClickTimeDoesNotInvalidateOrOpen() {
|
||||
val h = Harness(busy = DeviceBusyPolicy.TASK)
|
||||
val result = h.coordinator.run("1", purchase = true)
|
||||
assertEquals(PddManualOpenResult.Busy("设备正在执行任务"), result)
|
||||
assertEquals(listOf("busy"), h.calls)
|
||||
assertEquals("设备正在执行任务,暂不能打开拼多多", PddManualOpenCoordinator.message(result))
|
||||
}
|
||||
|
||||
@Test fun handoffIsInvalidatedBeforeAnyLaunch() {
|
||||
val h = Harness()
|
||||
h.coordinator.run("1", purchase = true)
|
||||
assertEquals(listOf("busy", "invalidate", "launch", "direct"), h.calls)
|
||||
}
|
||||
|
||||
@Test fun handoffIsInvalidatedEvenWhenOpeningFails() {
|
||||
val h = Harness(direct = { false }, browser = { false })
|
||||
h.coordinator.run("1", purchase = true)
|
||||
assertTrue(h.calls.indexOf("invalidate") in 0 until h.calls.indexOf("launch"))
|
||||
}
|
||||
|
||||
@Test fun idleInputsAreNotBusy() {
|
||||
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs()))
|
||||
}
|
||||
|
||||
@Test fun runningTaskIsBusy() {
|
||||
assertEquals("设备正在执行任务", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(taskActive = true)))
|
||||
}
|
||||
|
||||
@Test fun repurchaseRoundIsBusyIncludingBetweenTasks() {
|
||||
// Between two tasks every task input is false but the round is still busy.
|
||||
assertEquals("重购进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = true)))
|
||||
}
|
||||
|
||||
@Test fun backfillIsBusy() {
|
||||
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(backfillRunning = true)))
|
||||
}
|
||||
|
||||
@Test fun currentPageCollectionIsBusy() {
|
||||
assertEquals("当前页采集进行中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(currentPageCollecting = true, taskActive = true)))
|
||||
}
|
||||
|
||||
@Test fun unuploadedOrRecoverablePurchaseIsBusy() {
|
||||
assertEquals("有未上传的采购结果", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(unuploadedPurchase = true)))
|
||||
}
|
||||
|
||||
@Test fun repurchaseLocalIdleViewIgnoresOwnRoundButKeepsOtherWork() {
|
||||
// The service builds the repurchase-idle inputs with repurchaseBusy=false: the round never blocks itself.
|
||||
assertNull(DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false)))
|
||||
assertEquals("订单回填中", DeviceBusyPolicy.reason(DeviceBusyPolicy.Inputs(repurchaseBusy = false, backfillRunning = true)))
|
||||
}
|
||||
|
||||
@Test fun collectionCooldownIsNotAnInput() {
|
||||
// A cooldown only blocks claiming new collection work; it leaves every busy input false.
|
||||
assertTrue(DeviceBusyPolicy.Inputs::class.java.declaredFields.none { it.name.contains("cooldown", ignoreCase = true) })
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 5508fe99e1040dff61d3bc6dbdf08ce0a1fd02ad
|
||||
synchronized_at: 2026-10-10T03:10:31Z
|
||||
wiki_revision: 21d6cb0a4d8829ac2dbc4947e7ef7718f237fd2d
|
||||
synchronized_at: 2026-10-12T15:15:52Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -150,6 +150,8 @@ Android Portal/Agent
|
||||
| 管理端闭环页面 | `web/src/views/goauto/` |
|
||||
| 备货采购服务端路径 | `purchase_task.task_type` 与迁移 `1787885300000_stock_purchase.go`;`POST /api/admin/v1/purchase-tasks/stock` 由 `server/app/goauto/purchase/service.go` 校验 PDD 当前可选规格并固化 `direct_select`,复用既有任务状态机和设备/账号互斥;重试、替换和 SYB 回填显式排除 `stock` |
|
||||
| Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 |
|
||||
| 采购任务创建人与列表筛选(#382) | 迁移 `1791700000000_purchase_task_creator.go` 新增 `purchase_task.created_by`(索引)、`created_source`;`purchase/handler.go` `creatorFromContext` 在 Admin 创建/备货/批量创建与客户端密钥请求时写入,Agent 重试路径 `retry.go` `batchRetryCreate` 写 `agent`;`GET /api/admin/v1/purchase-tasks` 新增 `deviceId`(数字或 `unassigned`)、`createdBy`(数字、`agent`、`unrecorded`),列表/详情返回 `createdBy`、`createdByName`、`createdSource`;`GET /api/admin/v1/purchase-tasks/filter-options`(`admin_query.go`)返回任务中出现过的设备与创建人 |
|
||||
| SYB 已取消订单拦截(#381) | `purchase/return_match_block.go` `sybCancelled`/`rejectIfSYBCancelled`,错误码 `PURCHASE_SYB_CANCELLED`;拦截点:`service.go` 创建、`batch.go` 预览、`ai_match_eligibility.go`、`collection_eligibility.go`、`task/image_search.go`、`process_stage.go`(阶段 `syb_cancelled`);`returnmatch.participatingStages` 不含该阶段 |
|
||||
| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key);采购员菜单硬排除,仍只可通过受控 API 查看启用状态 |
|
||||
| GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 12 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。`web/src/router/index.js` 只保留公共路由,`web/src/store/modules/permission.js` 在登录时根据 `GET /api/v1/menurole` 返回结果注册业务路由与侧栏;退出、凭据异常和切换角色会清除旧动态路由,未授权深链接回退工作台。菜单可见性与 API 授权彼此独立 |
|
||||
| Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent |
|
||||
@@ -359,6 +361,7 @@ PddProductDetailCollector
|
||||
|
||||
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
|
||||
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
|
||||
- #384:`Inventory` 新增 `GET/POST /api/client/v1/agent-app-releases`(devices 模块 `release_upload`)与 `PUT /api/client/v1/agent-app-releases/current`(`release_activate`),复用 `apprelease.Handler`;不注册下载。`gateway.go` `bodyLimit` 只对 APK 上传放宽到 `apprelease.MaxAPKBytes`+1MB,其余端点仍为 16MB;`apprelease/handler.go` `operatorID` 对客户端请求取 `AuthorizedBy`。Web `views/goauto/client-keys` 动作标签与 `release_activate` 风险提示。
|
||||
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
|
||||
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
|
||||
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
|
||||
|
||||
@@ -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: d427a46283561df71714929c13b7156ee42debea
|
||||
synchronized_at: 2026-10-10T03:47:23Z
|
||||
wiki_revision: e937060afcf088930d8ec3cda537ef1199265e74
|
||||
synchronized_at: 2026-10-13T10:02:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -220,6 +220,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
- 采购规则的 `probeSpecs.dimensionAliases` 是只读规格维度标题的服务端下发事实源;颜色别名可由管理员增加“组合”等 PDD 页面文案,尺码别名单独维护。该字段只影响探测和 AI 匹配输入,不能改变 `selectSpec` 的精确点击目标;旧任务快照缺省时才使用 Agent 内置兼容值。
|
||||
- PDD 采集规则的 `collector.textAliases` 是规格面板、维度标题、选择摘要、评价入口、售罄主商品证据和购买入口等只读识别文案的服务端下发事实源;缺省字段只为旧快照兼容使用 Agent 内置默认值。颜色/尺码分类不得再用代码字面量扩大 `dimensionAliases`。订单和支付文字可以参与只读页面判定,但 Agent 内不可配置的创建订单/确认订单/支付/付款点击拒绝清单始终优先,规则不能放宽;修改地址和收货地址在只读及点击字段中都禁止。
|
||||
- PDD 颜色采集对首屏已识别为多行或网格的颜色区执行动态行发现:每次动作后重新解析全部可见行,单列不做无效横向滑动,并复用规则中的 `specVerticalSwipes` 与 `stableEdgeReads` 有界纵向翻页;颜色结束后规格面板归顶,再进入尺码发现。选项重排或已选项从无障碍节点消失时,不得静默跳过仍未尝试的可用颜色。
|
||||
- #370 第 2 阶段(合并 main `26562fc`,2026-10-12;测试机回归通过,采购机未装机):横向颜色网格每次横滑后按颜色文字与位置逐帧重新定位同一行;多行共用一个横滑容器时用选项位置签名判断网格是否移动,且只有确认起点已在最左端时才跳过逐行蛇形反扫;横滑后固定等待 1.2 秒再读取;滑回最左端使用独立额度(`specHorizontalSwipes` × 2),锚点暂时丢失的重试不计入次数但有连续与累计上限。异常终止保留最早原因,仍不得静默跳过未尝试的可用颜色。只改 Android Agent 采集遍历,不改 Admin 图搜、自动关联和服务端。
|
||||
- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。
|
||||
- 系统当前不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作不能配置为点击目标;只读识别字段可以包含支付或订单文字。后续若新增支付能力,必须由独立工单和显式控制重新评估。
|
||||
|
||||
@@ -427,6 +428,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
|
||||
- Agent 采购历史必须分开显示任务原始目标规格与服务端最终映射的执行规格;映射缺失不等于目标规格缺失。本地历史缓存同时保存两组字段,自动化始终只消费执行规格。
|
||||
- “采购规则”是“采采管理”下与“采集规则”同级的管理员菜单,采购员角色没有菜单和 API 权限。
|
||||
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
|
||||
- #384 起,客户端 API Key 也可经显式授权上传与设为当前(见「客户端密钥上传 Agent 版本(#384)」);Admin 流程不变。
|
||||
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
|
||||
|
||||
## Android 采购规格动作安全执行(#214)
|
||||
@@ -719,6 +721,16 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
### 采购手机环境前提
|
||||
执行采购的 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)
|
||||
|
||||
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
||||
@@ -800,10 +812,10 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
实现绑定 `d6639a4`;2026-10-05 已合并 main `4567a24`,在线迁移并发布至 122.228.200.167。旧记录保持未获取,随后按既有正常同步范围更新;不新增采购拦截。
|
||||
|
||||
- “SYB状态”仅显示来源取消标记:`isCancel=1` 为“已取消”,`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
|
||||
- “SYB状态”仅显示来源取消标记:`isCancel=1/2` 均为“已取消”(#389;仅包含该修复的服务端版本),`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
|
||||
- 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。
|
||||
- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
|
||||
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截另行评估。
|
||||
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截已由 #381 实现(见「SYB 已取消订单不进入 PDD 流程(#381)」)。
|
||||
- “匹配退货”仅是原“匹配到的退货商品”的表头缩短和列宽调整,内容、权限、匹配/使用/取消规则不变。允许少量表格横向滚动。
|
||||
|
||||
## SYB 字母尺码与体重连写识别(#358)
|
||||
@@ -960,7 +972,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
|
||||
|
||||
## 设备离线诊断的事务边界(#378,实现基线)
|
||||
|
||||
Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;未发布或装机;本节不代表线上/手机行为已经更新。
|
||||
Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378。2026-10-12 Server 随 main `15bab9c` 发布到 122.228.200.167 并执行迁移 `1791600000000`;Android 部分尚未装采购机。
|
||||
|
||||
默认心跳 15 秒、离线阈值 45 秒、离线扫描 15 秒不变。扫描逐设备独立事务:锁定最新设备/任务状态后,将设备离线、相关任务和 attempt 收敛、对应状态事件一起提交。锁争用时本设备不作任何部分修改,跳过后继续其他设备,下轮再检查;不自动重试采购或换机。
|
||||
|
||||
@@ -970,3 +982,86 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
|
||||
Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任务不一致心跳→符合条件才领任务的顺序。诊断failure是本轮观察到的同步错误,不等同于业务任务失败;恢复内部暂缓处理的API错误也记录,但不改变原恢复占位、后续flush/claim规则,不增加采购重试或付款动作。
|
||||
|
||||
可恢复诊断存储异常不阻断同步,取消和致命错误不记成假成功。同步记录独立保留24小时/6000轮,不占采集50条额度;旧进行中记录只表示中断/不完整,不伪造原现场、失败原因或成功。报告确认不会清除发送期间新增失败;本地确认失败或响应丢失允许重复,报告不能用于改变设备在线或采购状态。
|
||||
|
||||
## 规格规范化同名冲突与徽标说明合并(#379)
|
||||
|
||||
合并 main `4039fda`(2026-10-12);单元测试覆盖,测试机回归通过;采购1(PDD 8.26.0)徽标形态的真机验证待装机时完成。普通采集与采购规格探测共用同一采集器。
|
||||
|
||||
- 变更 #160 的同名冲突规则:同一维度内不同原文规范化后同名(同帧或跨帧,例如尾价时有时无)时不再整维作废,只保留一个值(先出现的原文及其价格、图片),在 missing 写 `spec_dimension_conflict:<dimensionKey>`,任务为 `completed_partial`。用户接受「重复或合并规格」的风险,不接受「整组没有尺码/颜色」。
|
||||
- 规范化后为空或仍含 `¥/¥` 的值维持整维拒绝,写 `spec_dimension_invalid:<dimensionKey>`,不放宽;服务端规范化防线不变。
|
||||
- 尺码续页路径与正常路径一样去掉尾价,冲突检查在去重之前完成。
|
||||
- #362 截短卡片合并扩展:说明文字形如「截短名[...|…] ¥金额 徽标」(徽标 1–8 个字符,不含货币符号、数字和空白)时,金额须等于外层完整名的尾价,去掉省略号后须为完整名的严格前缀,并满足 #362 全部结构条件,才与外层卡片合并;任一条件不满足行为不变。不维护徽标词表。
|
||||
- 残余风险:两个真实不同、规范化后同名、且从未同屏出现的选项会被合并为一个;同屏出现时下单因 `SPEC_TARGET_AMBIGUOUS` 失败,不会任选。
|
||||
- 未覆盖:说明文字只有徽标、不带价格(如「…零差评」)时仍可能多出一个截短重复颜色,不导致作废。
|
||||
|
||||
## Agent 详情页在拼多多中打开商品(#375)
|
||||
|
||||
合并 main `82ed666`(2026-10-12);测试机真机验证通过,采购机未装机。
|
||||
|
||||
- 采集详情与采购详情显示描边按钮「在拼多多中打开」,用任务商品编号拼 `https://mobile.yangkeduo.com/goods.html?goods_id=<编号>`;编号为空或非纯数字时不显示。用途仅为人工查看商品,不复现后台执行过程。
|
||||
- 采集按钮沿用采集的浏览器打开方式;采购按钮沿用采购方式先直达 PDD,失败回退浏览器,并提示异常简单类名。人工入口不代点浏览器中的「打开拼多多APP」,不在 PDD 内点击任何控件;提示不含链接和异常消息原文。
|
||||
- 打开由 Agent 服务执行:点击时再判断设备是否忙,忙则不打开;不忙时先作废 #373 一次性交接凭证再打开,下一次采购必须重新打开本任务链接。
|
||||
- 设备忙:重购一轮进行中(含两个任务之间)、订单回填中、当前页采集进行中、设备正在执行任务(含图搜)、有未上传或待恢复的采购结果;任务结束后的冷却期不算忙。忙时按钮置灰并显示原因,状态约每 2 秒发布,与 `repurchaseLocalIdle` 共用同一判断。
|
||||
- 不暂停领任务,不改调度、心跳和租约;查看期间来了新任务由 Agent 接管屏幕,打开后提示「打开期间如有新任务,Agent 会接管屏幕」。不上传打开结果,不写日志或诊断。
|
||||
|
||||
## SYB 已取消订单不进入 PDD 流程(#381)
|
||||
|
||||
合并 main `c5709a0`,2026-10-12 随 `15bab9c` 发布到线上(Server + Web),待业务验收。
|
||||
|
||||
- `syb_product.syb_is_cancelled = true`(包含 #389 的版本同步取得 `isCancel=1/2`;旧版只识别 1)的明细:不能创建采购任务(`PURCHASE_SYB_CANCELLED`「SYB 订单已取消,无需采购」),批量采购预览、AI 规格匹配资格、批量采集资格给出同一原因,图搜采集批量逐项拒绝,不参与退货匹配。NULL(未获取)与 false 不拦截,不推断。
|
||||
- 处理阶段新增 `syb_cancelled`「已取消」,优先级:已有任务或订单结果 → 退货匹配 → 已取消 → 无需采购(#340)→ 其余;同时被过滤规则标记的行显示「已取消」,创建接口仍拒绝(返回码可能是 `PURCHASE_PDD_EXCLUDED`)。
|
||||
- 同步照常导入并更新取消状态;订单恢复为未取消后阶段与资格自动恢复。
|
||||
- 不拦截已存在的采购任务(Claim/Start、下单边界与状态机不变);已下单的已取消明细需人工售后。
|
||||
- 取消状态只随同步日期范围(当天与前一天创建的货运单)刷新,更早订单在 SYB 被取消后仍按上次状态处理。
|
||||
|
||||
## 采购任务创建人与采购管理筛选(#382)
|
||||
|
||||
合并 main `0893b55`,2026-10-12 发布并执行迁移 `1791700000000`,待业务验收。
|
||||
|
||||
- `purchase_task.created_by`(可空)与 `created_source` 只在新建任务时写入一次:Admin 单个创建、备货、批量创建为 `admin` + 登录账号;客户端密钥接口为 `client_api` + 授权该密钥的账号;Agent 重试/重购为 `agent`、创建人为空。幂等重放不改写;Admin 批量重试是原地重置原任务,不新建任务、不改创建人。
|
||||
- 迁移前的历史任务 `created_source` 为空,显示「未记录」,不回填、不从操作日志反推。
|
||||
- Admin 采购管理可按设备(含「未分派」)和创建人(含「Agent 发起」「未记录」)筛选,导出沿用当前筛选;列表与详情显示创建人(Agent 显示「Agent」)。不改变可见范围:采购员仍可看到全部任务。
|
||||
|
||||
## Admin 采集任务按设备筛选(#383)
|
||||
|
||||
合并 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 发布上线,待业务验收。
|
||||
|
||||
- `devices` 模块新增两个独立动作,必须在密钥上显式勾选,默认不授予;只读或读写授权不会获得:
|
||||
- `release_upload`「上传 Agent 版本」:查看版本列表、上传 APK;
|
||||
- `release_activate`「设置当前 Agent 版本」:把已上传版本设为当前。设为当前后所有设备启动或手动检查时会提示更新,授权页对此显示风险提示。
|
||||
- 不开放 APK 下载给客户端 API。上传沿用 Admin 的 Manifest 解析、SHA-256、大小与 versionCode 唯一校验;创建人记为授权该密钥的账号。每次调用写客户端审计,不记录文件内容。
|
||||
- 设备端安装仍由 Android 系统人工确认;签名与已安装版本不一致的 APK 无法覆盖安装,设为当前前须核对目标设备签名。
|
||||
|
||||
|
||||
## SYB 数值取消标记兼容(#389)
|
||||
|
||||
实现绑定 `5f329d231157c62f92e2c1348e57ae7ca561fef3`,工单分支 `fix/389-syb-cancel-enum`;未合并 main、未发布,线上现有明细尚未因此修正。
|
||||
|
||||
- 来源货运单列表数值 `isCancel`:0「正常」→ false,1「取消」/2「已取消」→ true;同单明细共用该订单级标记。只接受原有 JSON 数字解码,不将其他非零值、字符串、布尔或缺失/null 当作取消。未知值保留已存状态及同步时间。
|
||||
- 按用户 2026-10-13 确认,以 SYB 取消标记为准,即使平台 `orderStatus` 仍是待出货、已发货或运送中,1/2 仍表示本系统的已取消;反之,只有平台“申请取消”文字而数值为 0 时不推断取消。前端枚举及整单取消动作可证明 2 的含义,不推测 1 必定由哪个平台生成。
|
||||
- 已知值随既有同步更新取消状态及取得时间,0 可恢复未取消。#381 的新建/展示期限制沿用,不新增领取/开始/下单边界拦截,不自动停止已有 pending/running 采购任务。日期范围和定时周期不变。
|
||||
- 线上有界重同步须单独授权;此前在受控环境保留受影响明细原值与时间的最小恢复依据,同步后只读报告新增已取消且仍有未完成任务的明细,由用户决定处理。上述生产数据不写入仓库、工单、Wiki 或消息。
|
||||
- 回退代码不会自动恢复已经同步的 true;后续纠正须有来源事实及独立授权,不通过一律映射 false 来掩盖错误。
|
||||
|
||||
## 采购规格面板近正方形滚动区域(#390)
|
||||
|
||||
实现绑定工单分支 `fix/390-panel-scroll-ratio`,Agent `0.9.73 / code86`;未合并、未安装或发布,真机展开顶部地址区域的效果未验证。
|
||||
|
||||
- 仅采购 `PurchaseLiveAutomation.purchasePanelScrollTargets` 的形状条件调整为“高度不小于宽度的 80%”,用于改地址前寻找入口、保存地址后恢复当前面板证据。不是所有纵向滚动容器的通用规则,普通采集器不改。
|
||||
- 继续要求节点可见、启用、可滚动,宽度至少为视口估算宽度的 60%、高度至少为视口估算高度的 20%,且滚动候选必须唯一。扁平横条、尺寸不足及多候选仍明确失败,不选择最大区域兜底。
|
||||
- 向下滑动的方向、距离、时长、最多五轮采样和视口不变停止规则保持原样;两个调用点的原有失败路径均保留。地址编辑/保存、规格、金额、提交按钮与一次性下单检查不改,不涉及付款。
|
||||
- 合成回归与失败现场的只读结构尺寸核对只能证明该几何候选不再被错误排除,不能证明在真实 PDD 页面内滑动一定能展开顶部地址。安装及实际采购须另行授权。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: d06fa26d85339641e3d67e5ee6314205173c24c5
|
||||
synchronized_at: 2026-10-10T03:10:51Z
|
||||
wiki_revision: 50f5923623db34cddb9ea152238c11669a25eb95
|
||||
synchronized_at: 2026-10-12T08:31:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -220,3 +220,27 @@ ORDER BY id;
|
||||
- 一轮中观察到步骤失败(包括恢复内部暂缓处理的500/409),诊断可记failure,即使业务随后恢复连接;这不是采集/采购任务失败,也不能据此重下单。已正常收敛的明确拒收仍按#377处理。
|
||||
- timeout_connect/timeout_response/timeout_unknown根据请求实际阶段分类,未知阶段不猜。诊断故障只输出sync_diagnostic_persistence_failed或sync_diagnostic_context_failed固定提示;不附错误正文、URL或SQL参数。
|
||||
- reported只表示Agent已收到该报告请求的成功响应并在本地确认,不保证Server历史写入成功;响应丢失或确认失败可能重复补报。
|
||||
|
||||
## Android 颜色横向网格遍历(#370 第 2 阶段)
|
||||
|
||||
实现已合并 main(`26562fc`)。遍历过程只写 `Log.i`(标签 `GoAutoCollector`),不落库、不上报;排查时在已授权的 Debug 设备上实时抓取,用完删除:
|
||||
|
||||
```powershell
|
||||
adb -s <serial> logcat -v time -s GoAutoCollector:V
|
||||
```
|
||||
|
||||
- `color-start confirmed= swipes= anchorRetries= end=`:滑回最左端的结果;`confirmed=false` 时不会跳过逐行反扫。
|
||||
- `color-row row= dir= match= moved= others= sharedMoves= sharedBroken= uncertain= signatureReads=`:每次横滑后的行定位与是否移动;`others=moved` 表示多行共用一个横滑容器。
|
||||
- `color-row-end row= end=`:该行结束原因(如 `edge` 到达边缘)。
|
||||
- `color-settle waitedMs=1200`:横滑后的固定等待。
|
||||
- `color-click-miss`:点击目标未生效。
|
||||
- 漏色时先核对同一设备、Agent/PDD 版本和规则快照,再看 `color-row-end` 是否过早出现或 `color-start` 是否未确认。日志缓冲较小时可能被覆盖,缺少日志不能证明没执行。
|
||||
|
||||
## 采购探测报「没有找到可采购的 PDD 颜色或尺码」(#379)
|
||||
|
||||
服务端只看规格候选是否为空,不读 missing,该提示可能掩盖「某维度被整维作废」的真实原因:
|
||||
|
||||
1. 查同一次探测或采集结果的 missing:有 `spec_dimension_invalid:color|size` 表示该维度被整维清空(规范化后为空或仍含货币符号);`spec_dimension_conflict:*` 表示同名冲突已保留一个值,维度仍可用。
|
||||
2. 已知成因(2026-10-12,采购1 / PDD 8.26.0 / 商品 715527668284):颜色卡片的截短说明文字在价格后带「零差评」「最多人选择」等徽标,旧版本无法与外层卡片合并,说明值仍含 `¥` 导致颜色整维作废。#379 后满足条件的徽标说明会合并;同一商品在其他设备或 PDD 版本上可能不出现徽标,不能用测试机结果否定。
|
||||
3. 实时日志 `spec-normalize dim= outcome= reason= path= pass= sameFrame= onlyTrailingPriceDiff= rawVariants= candidates= badgeCaptionMerged=`:`outcome=invalid_rejected` 对应整维作废,`reason=unsafe_currency|empty`;`outcome=conflict_kept` 对应同名冲突保留;`badgeCaptionMerged` 为本帧合并的徽标卡片数。只含计数和枚举,不含规格原文。每维每类只记录首次。
|
||||
4. 若需看规格面板原文,只在用户手动打开面板后做一次只读 uiautomator 导出并过滤地址、电话,用完立即删除;不得为打开面板点击购买类按钮。
|
||||
|
||||
@@ -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: 1dc3c7550c0c4da4291e08b840170d861042e97a
|
||||
synchronized_at: 2026-10-10T03:10:58Z
|
||||
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
|
||||
@@ -421,6 +429,7 @@ POST /api/agent/v1/tasks/{taskId}/result
|
||||
- size 规格值只允许剥离位于文本尾部的 ¥ / ¥ 数字价格片段,括号说明、体重区间和其他规格身份文本原样保留;Android 与服务端使用相同语义。
|
||||
- 服务端在结果完整性校验前执行规范化。规范化后为空、同维度重复、仍残留货币符号或无法由最小安全规则确定时,整维不接受,并原子丢弃引用该维度的 SKU;color 被拒时同时丢弃 colorPrices。
|
||||
- 服务端强制将任务收敛为 completed_partial,并在 missing 增加稳定值 spec_dimension_invalid:<dimensionKey>,例如 spec_dimension_invalid:size。客户端应把它展示为对应规格维度无法安全采集,不得视为完整档案,也不得自行猜测或补齐候选。
|
||||
- #379 起 Android 在提交前处理规范化同名冲突:同一维度不同原文规范化后同名时只提交一个值,并在 missing 增加稳定值 `spec_dimension_conflict:<dimensionKey>`(任务为 completed_partial,不新增字段)。它表示该维度可能把规范化后同名的选项合并为一个,维度本身仍可用;残留货币符号或空值仍由 Android 整维清空并写 `spec_dimension_invalid:<dimensionKey>`。服务端只保存该 missing 值、不校验其种类;图搜自动关联白名单仍只含 reviewCount、salesText、shopName。
|
||||
- 被拒维度不得以污染值覆盖商品已有档案;历史任务和既有商品数据不自动清洗,须在修复后的 Agent 上重新采集。
|
||||
|
||||
## Android:提交失败
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: 5979a14250c96c3c92348a947c2bc8d3a078c9f4
|
||||
synchronized_at: 2026-09-29T08:18:40Z
|
||||
wiki_revision: 3f44b3ed91ff19c592bba4e2dbbef28d971633da
|
||||
synchronized_at: 2026-10-13T10:03:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -272,7 +272,7 @@ ID,或某张货运单返回空商品明细,都视为不完整并停止当前
|
||||
| `shopName` | `<店铺名>` | 蝦皮店铺 |
|
||||
| `productName` | `純棉上衣` | **只有一个商品名**,一单多商品时不完整 |
|
||||
| `orderQty` / `detailQty` | `2` / `2` | 商品件数 |
|
||||
| `isCancel` | `0` | 是否取消 |
|
||||
| `isCancel` | `0` / `1` / `2` | 订单级取消标记:0 正常,1 取消,2 已取消。包含 #389 的服务端将 1/2 均映射 true、0 映射 false;未知值保留旧值,不从 status/orderStatus 推断。旧版只识别 0/1。 |
|
||||
| `expCompany` | `蝦皮店到店` | 物流方式 |
|
||||
| `receiver` / `receiverTel` / `receiverAddr` | — | **收件人信息,属个人数据** |
|
||||
|
||||
@@ -709,3 +709,10 @@ POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stoc
|
||||
客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。
|
||||
|
||||
GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。
|
||||
|
||||
|
||||
### isCancel 枚举核验(#389,2026-10-13)
|
||||
|
||||
来源前端 `/sys/js/app.226509c4.js` 的 `cances` 定义 0「正常」、1「取消」、2「已取消」;`/sys/js/stock.b4a33aea.js` 用该枚举渲染状态,整单取消对选中的货运单 ID 设值 2,恢复设值 0,平台取消是另一项可选动作。此次只读核验,没有调用取消或恢复接口。该事实不构成本系统新增调用这些写接口的授权。
|
||||
|
||||
经用户确认,GoAuto 的取消判断以该数值为准,不受平台订单/物流文字影响;含 #389 的实现 `5f329d231157c62f92e2c1348e57ae7ca561fef3` 尚未合并发布。字段类型、同步日期范围、数据库及下游 API 不变;来源未知值不覆盖既有状态和时间,不能靠重新同步未知值撤销已写入的 true。
|
||||
|
||||
@@ -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: b50250e0d61eff0b33f468e7cdf74139fe76bb65
|
||||
synchronized_at: 2026-10-10T03:10:43Z
|
||||
wiki_revision: 19eaaa2853544e73f4abc5b788b571377e87296b
|
||||
synchronized_at: 2026-10-13T03:49:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -347,6 +347,25 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 换机生成的调试签名与旧机不同会导致 `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更新。
|
||||
|
||||
### 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,诊断阶段)
|
||||
|
||||
@@ -409,3 +428,44 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
|
||||
- 本地Android验证(cwd仓库根,JDK17及现有Android SDK):`./android/gradlew.bat -p android :app:testDebugUnitTest --tests '*HeartbeatReport*Test' --tests '*AgentTransportTimeoutTest' --tests '*SyncDiagnostic*Test' --tests '*HandledRecoveryDiagnosticTest' --tests '*AgentDiagnosticStoreMigrationTest' --tests '*AgentSyncCycleTest' :app:assembleDebug :app:compileReleaseUnitTestKotlin`。发布前还应执行#377受影响的outbox、运行占位、互斥、重购等回归;SQLite JDBC验证不等同于真机升级。
|
||||
- Android兼容性测试在`android/build/diagnostics378-client-reports.json`生成纯合成HTTP捕获报告;在server目录将该绝对路径注入`GOAUTO_378_ANDROID_REPORTS`,执行`go test ./app/goauto/device -run TestAndroidGeneratedHeartbeatReports -count=1 -v`,使用真实validator/handler验证三类报告与重放。未设置路径时该项明确skip,不得据此宣称跨端通过;产物不提交Git。
|
||||
- 线上迁移、发布、重启、装机仍需单独授权;本地构建/夹具不代表真机或线上30分钟心跳验收。
|
||||
- 2026-10-12 Server 已随 main `15bab9c` 发布并执行迁移 `1791600000000`(新建 `agent_heartbeat_log`、`agent_device_status_event`),重启后心跳历史开始写入;Android 部分未装采购机。
|
||||
|
||||
|
||||
## #372 v3 导出店铺列的纯 Web 发布(2026-10-10)
|
||||
|
||||
- 用户授权合并 #372 v3 并部署线上。`feat/372-export-shop` 以 `--no-ff` 合并为 main `3d5e964`;Web 单元测试 93/93 通过,`build:prod` 通过。相对上一线上 Web(`4ad59bd`),Web 只有 v3 的导出改动。
|
||||
- **main 已含 #378 的 Server 代码和迁移,本次没有部署**:线上 Server 仍是 `72a15f8` 构建的二进制。#378 的 Server 发布和迁移需另行授权。
|
||||
- 发布目录 `/home/goauto/releases/20261010-3d5e964-372v3-web`,只替换 dist。goauto-server 与 config 从上一发布逐字复制(cmp/diff 一致),static/temp/var 沿用原目录,旧 js/css 哈希资源保留。index.html SHA256 `b900a40ec4691f0fb2c3d89e1e20826e7ca3205db311a33c24087767a515979a`。
|
||||
- 只原子切换 current,不重启 GoAuto/Nginx;切换前后 PID 4786 不变,服务 active。
|
||||
- 按内容验收:`/` 与 `/login` 返回新 index,10 项 JS/CSS 200,线上导出模块的 SYB 表头含「店铺」,health 200,验证码 code=200,未登录业务接口 401。登录后的实际导出待业务验收。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261010-4ad59bd-372v2-web`,不需要重启。
|
||||
|
||||
## #381/#382/#383 + #378 Server 发布与迁移(2026-10-12)
|
||||
|
||||
- 用户授权按顺序推送、合并、发布、更新 Wiki,并要求「如果线上有采集和采购任务进行着,等待无任务时再重启」;发布前用户选择按 main 发布(含此前未发布的 #378 Server 与迁移)。
|
||||
- main `15bab9c`(#381 `c5709a0`、#382 `0893b55`、#383 `15bab9c`,基于 `16e3d35`)。合并后 `go test ./...`:除既有失败外通过(`sybimport` 基线已有失败;`apprelease` 的 `TestParseBuiltAgentAPKWhenAvailable` 仅在本地存在 Debug APK 时运行,断言旧版本号 0.9.41,属既有过时用例);Linux amd64 `CGO_ENABLED=0` 构建、Web `build:prod` 通过。
|
||||
- 发布目录 `/home/goauto/releases/20261012-15bab9c-381-382-383`:goauto-server SHA256 `f34864441203e4dfba51c06d0c9db6c6648c7ef34c7c2eb78a61b2d869ff3695`(运行进程一致),index.html SHA256 `a4c85aa8c91bdae2d11eda0756ee7482cafbb83b8e1782d61a2f99df0ff5d853`;config 与上一发布 diff 一致,static/temp/var 链接沿用,旧 js/css 哈希资源保留(`cp -rn`)。
|
||||
- 备份:`/home/goauto/backups/20261012-381-382-383/database.sql`(0600,single-transaction,`/opt/mysql84-root/usr/bin/mysqldump`;账号缺 PROCESS 权限只导致表空间信息未导出,可加 `--no-tablespaces` 消除提示)。
|
||||
- 迁移:待执行为 `1791600000000`、`1791700000000`,执行成功(退出码 0),新会话回读 `sys_migration`、新列与索引 `idx_purchase_task_created_by`、两张新表;采购任务 2623 行完好。
|
||||
- `[必须]` 本机 systemd 为 219,`systemd-run` 不支持 `--wait/--pipe`;`/etc/goauto/goauto.env` 含括号与引号,不能用 shell `source`。迁移使用 root 私有脚本 `/root/goauto-migrate-20261012.sh` 逐行读取 `KEY=VALUE`(去掉成对引号,不经 shell 求值)后执行 `goauto-server migrate -c config/settings.yml`;日志存备份目录,不输出凭据。
|
||||
- 迁移前与切换前确认无执行中的采集/采购/SYB 同步/AI 解析/自动匹配;1 个 pending 采购任务保持原状。current 原子切换后重启 `goauto.service`,active;Nginx 未改。
|
||||
- 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 200,验证码 code=200,未登录访问 `yeeke-returns`、`purchase-tasks/filter-options`、带 `deviceId=unassigned` 的采购列表均为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线,`agent_heartbeat_log` 开始写入。
|
||||
- 回滚:current 原子切回 `/home/goauto/releases/20261010-3d5e964-372v3-web` 并重启;新增表与列保留不影响旧代码,不回退数据库。
|
||||
|
||||
## #384 客户端密钥上传 Agent 版本发布(2026-10-12)
|
||||
|
||||
- 用户授权建单、实施并部署上线。main `07800cf`(`19484f0` 服务端、`6107c23` Web),无迁移。clientapi/apprelease/clientkey/access 测试、jest client-keys、Linux 构建与 `build:prod` 通过。
|
||||
- 发布目录 `/home/goauto/releases/20261012-07800cf-384`:goauto-server SHA256 `58f4a0a49f203f46a948262e771841993442bc66999126d5e82ed98d56b62131`(运行进程一致),index.html SHA256 `67c5fcee7063c2d43b61d7ca301390437c16b5f0c34ecaf1072f77fba350098a`;config 一致,static/temp/var 沿用,旧哈希资源保留。无迁移,未做数据库备份。
|
||||
- 发布时有 1 个采购执行中、32 个排队。按用户要求用服务器脚本 `/root/goauto-switch-384.sh` 等待:采集与采购执行中数量为 0,10 秒后复查仍为 0,再立即确认一次后原子切换 current 并重启(最长等待 3 小时,超时不切换)。18:34:38 切换,`goauto.service` active,日志 `/home/goauto/backups/20261012-384-switch.log`。
|
||||
- 按内容验收:`/`、`/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` 并重启(先确认无执行中任务);无数据库变化。
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -72,13 +73,21 @@ func (h Handler) Upload(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), uint64(user.GetUserId(c)), stream)
|
||||
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), operatorID(c), stream)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"code": http.StatusOK, "data": result})
|
||||
}
|
||||
|
||||
// operatorID returns the authorizing user for client API keys and the JWT user otherwise.
|
||||
func operatorID(c *gin.Context) uint64 {
|
||||
if id, ok := clientprincipal.Get(c); ok {
|
||||
return id.AuthorizedBy
|
||||
}
|
||||
return uint64(user.GetUserId(c))
|
||||
}
|
||||
func (h Handler) SetCurrent(c *gin.Context) {
|
||||
var req SetCurrentRequest
|
||||
if err := decode(c, &req); err != nil {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package apprelease
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/common/clientprincipal"
|
||||
)
|
||||
|
||||
// buildTestAPK returns a minimal zip whose binary AndroidManifest.xml carries
|
||||
// the manifest versionCode/versionName attributes that parseAPK reads.
|
||||
func buildTestAPK(t *testing.T, versionCode uint32, versionName string) []byte {
|
||||
t.Helper()
|
||||
pool := []string{"manifest", "versionCode", "versionName", versionName}
|
||||
var strs bytes.Buffer
|
||||
offsets := make([]uint32, len(pool))
|
||||
for i, s := range pool {
|
||||
offsets[i] = uint32(strs.Len())
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteString(s)
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
for strs.Len()%4 != 0 {
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
headerSize := 28
|
||||
poolSize := headerSize + len(pool)*4 + strs.Len()
|
||||
var m bytes.Buffer
|
||||
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
|
||||
le(uint16(0x0003))
|
||||
le(uint16(8))
|
||||
le(uint32(0)) // total size, patched below
|
||||
le(uint16(0x0001))
|
||||
le(uint16(headerSize))
|
||||
le(uint32(poolSize))
|
||||
le(uint32(len(pool)))
|
||||
le(uint32(0))
|
||||
le(uint32(0x100))
|
||||
le(uint32(headerSize + len(pool)*4))
|
||||
le(uint32(0))
|
||||
for _, o := range offsets {
|
||||
le(o)
|
||||
}
|
||||
m.Write(strs.Bytes())
|
||||
attrs := 2
|
||||
le(uint16(0x0102))
|
||||
le(uint16(16))
|
||||
le(uint32(36 + attrs*20))
|
||||
le(uint32(1)) // line
|
||||
le(uint32(0xffffffff)) // comment
|
||||
le(uint32(0xffffffff)) // ns
|
||||
le(uint32(0)) // name -> manifest
|
||||
le(uint16(20)) // attributeStart
|
||||
le(uint16(20)) // attributeSize
|
||||
le(uint16(attrs))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
attr := func(name uint32, raw uint32, typ byte, data uint32) {
|
||||
le(uint32(0xffffffff))
|
||||
le(name)
|
||||
le(raw)
|
||||
le(uint16(8))
|
||||
le(byte(0))
|
||||
le(typ)
|
||||
le(data)
|
||||
}
|
||||
attr(1, 0xffffffff, 0x10, versionCode)
|
||||
attr(2, 3, 0x03, 3)
|
||||
raw := m.Bytes()
|
||||
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
w, err := zw.Create("AndroidManifest.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = w.Write(raw)
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func uploadRequest(t *testing.T, filename string, content []byte) *http.Request {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
part, _ := mw.CreateFormFile("file", filename)
|
||||
_, _ = part.Write(content)
|
||||
_ = mw.WriteField("releaseNotes", "test")
|
||||
_ = mw.Close()
|
||||
req := httptest.NewRequest("POST", "/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestUploadCreatorUsesClientAuthorizerOrJWTUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := releaseTestDB(t)
|
||||
h := Handler{DB: db, StorageDir: t.TempDir()}
|
||||
router := gin.New()
|
||||
router.POST("/upload", func(c *gin.Context) {
|
||||
if c.GetHeader("X-Test-Client") != "" {
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 5, RequestID: "r", AuthorizedBy: 42})
|
||||
} else {
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"identity": float64(7)})
|
||||
}
|
||||
h.Upload(c)
|
||||
})
|
||||
send := func(client bool, filename string, content []byte) *httptest.ResponseRecorder {
|
||||
req := uploadRequest(t, filename, content)
|
||||
if client {
|
||||
req.Header.Set("X-Test-Client", "1")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
if rr := send(true, "agent.apk", buildTestAPK(t, 101, "1.0.1")); rr.Code != 201 {
|
||||
t.Fatalf("client upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := send(false, "agent.apk", buildTestAPK(t, 102, "1.0.2")); rr.Code != 201 {
|
||||
t.Fatalf("admin upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var rows []models.AgentAppRelease
|
||||
if err := db.Order("version_code").Find(&rows).Error; err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
if rows[0].CreatedBy != 42 || rows[1].CreatedBy != 7 {
|
||||
t.Fatalf("createdBy client=%d admin=%d", rows[0].CreatedBy, rows[1].CreatedBy)
|
||||
}
|
||||
if rr := send(true, "agent.zip", buildTestAPK(t, 103, "1.0.3")); rr.Code != 422 {
|
||||
t.Fatalf("non-apk accepted %d", rr.Code)
|
||||
}
|
||||
rr := send(true, "again.apk", buildTestAPK(t, 101, "1.0.1"))
|
||||
var res struct{ Code string }
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &res)
|
||||
if rr.Code != 409 || res.Code != "AGENT_RELEASE_CONFLICT" {
|
||||
t.Fatalf("duplicate versionCode %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
@@ -80,7 +81,7 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit(e))
|
||||
original := c.Writer
|
||||
buffer := &responseBuffer{ResponseWriter: original, status: 200}
|
||||
c.Writer = buffer
|
||||
@@ -104,6 +105,15 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimit keeps 16MB for every endpoint except the Agent APK upload, which
|
||||
// needs room for the APK itself plus multipart overhead (same bound as Admin).
|
||||
func bodyLimit(e Endpoint) int64 {
|
||||
if e.Method == "POST" && e.Path == "/agent-app-releases" {
|
||||
return apprelease.MaxAPKBytes + (1 << 20)
|
||||
}
|
||||
return 16 << 20
|
||||
}
|
||||
|
||||
type responseBuffer struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// releaseAPK builds a minimal APK whose binary manifest carries the version fields.
|
||||
func releaseAPK(t *testing.T, code uint32, name string) []byte {
|
||||
t.Helper()
|
||||
pool := []string{"manifest", "versionCode", "versionName", name}
|
||||
var strs bytes.Buffer
|
||||
offsets := make([]uint32, len(pool))
|
||||
for i, s := range pool {
|
||||
offsets[i] = uint32(strs.Len())
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteString(s)
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
for strs.Len()%4 != 0 {
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
var m bytes.Buffer
|
||||
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
|
||||
le(uint16(3))
|
||||
le(uint16(8))
|
||||
le(uint32(0))
|
||||
le(uint16(1))
|
||||
le(uint16(28))
|
||||
le(uint32(28 + len(pool)*4 + strs.Len()))
|
||||
le(uint32(len(pool)))
|
||||
le(uint32(0))
|
||||
le(uint32(0x100))
|
||||
le(uint32(28 + len(pool)*4))
|
||||
le(uint32(0))
|
||||
for _, o := range offsets {
|
||||
le(o)
|
||||
}
|
||||
m.Write(strs.Bytes())
|
||||
le(uint16(0x0102))
|
||||
le(uint16(16))
|
||||
le(uint32(36 + 40))
|
||||
le(uint32(1))
|
||||
le(uint32(0xffffffff))
|
||||
le(uint32(0xffffffff))
|
||||
le(uint32(0))
|
||||
le(uint16(20))
|
||||
le(uint16(20))
|
||||
le(uint16(2))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
attr := func(n, raw uint32, typ byte, data uint32) {
|
||||
le(uint32(0xffffffff))
|
||||
le(n)
|
||||
le(raw)
|
||||
le(uint16(8))
|
||||
le(byte(0))
|
||||
le(typ)
|
||||
le(data)
|
||||
}
|
||||
attr(1, 0xffffffff, 0x10, code)
|
||||
attr(2, 3, 0x03, 3)
|
||||
raw := m.Bytes()
|
||||
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
w, _ := zw.Create("AndroidManifest.xml")
|
||||
_, _ = w.Write(raw)
|
||||
_ = zw.Close()
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func multipartUpload(content []byte, filename string) (*bytes.Buffer, string) {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
part, _ := mw.CreateFormFile("file", filename)
|
||||
_, _ = part.Write(content)
|
||||
_ = mw.Close()
|
||||
return &body, mw.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestAgentReleaseActionsAreSeparateAndExplicit(t *testing.T) {
|
||||
var devices clientkey.Module
|
||||
for _, m := range Catalog(Inventory()) {
|
||||
if m.Key == "devices" {
|
||||
devices = m
|
||||
}
|
||||
}
|
||||
if strings.Join(devices.Actions, ",") != "release_activate,release_upload" || devices.Writable {
|
||||
t.Fatalf("devices catalog=%+v", devices)
|
||||
}
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := apprelease.Handler{DB: db, StorageDir: t.TempDir()}
|
||||
router := gin.New()
|
||||
found := 0
|
||||
for _, e := range Inventory() {
|
||||
if !strings.HasPrefix(e.Path, "/agent-app-releases") {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
switch e.Method + e.Path {
|
||||
case "GET/agent-app-releases":
|
||||
e.Handle = h.List
|
||||
case "POST/agent-app-releases":
|
||||
e.Handle = h.Upload
|
||||
case "PUT/agent-app-releases/current":
|
||||
e.Handle = h.SetCurrent
|
||||
default:
|
||||
t.Fatalf("unexpected release route %s", keyFor(e))
|
||||
}
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
|
||||
}
|
||||
if found != 3 {
|
||||
t.Fatalf("release routes=%d", found)
|
||||
}
|
||||
newKey := func(grant clientkey.Grant) string {
|
||||
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{grant}, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
do := func(token, method, path, contentType string, body *bytes.Buffer) *httptest.ResponseRecorder {
|
||||
if body == nil {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
req := httptest.NewRequest(method, "https://example.test/api/client/v1"+path, body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
upload := func(token string, code uint32) *httptest.ResponseRecorder {
|
||||
body, ct := multipartUpload(releaseAPK(t, code, "9.9.9"), "agent.apk")
|
||||
return do(token, "POST", "/agent-app-releases", ct, body)
|
||||
}
|
||||
setCurrent := func(token string, id uint64) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(map[string]any{"requestId": uuid.NewString(), "releaseId": id})
|
||||
return do(token, "PUT", "/agent-app-releases/current", "application/json", bytes.NewBuffer(raw))
|
||||
}
|
||||
|
||||
// A key with only devices read must be rejected everywhere (devices has no
|
||||
// write capability, so a write grant cannot even be created).
|
||||
if _, _, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Write: true}}, 42); err == nil {
|
||||
t.Fatal("devices write grant must be invalid")
|
||||
}
|
||||
for name, token := range map[string]string{
|
||||
"devices read": newKey(clientkey.Grant{Module: "devices"}),
|
||||
} {
|
||||
if rr := do(token, "GET", "/agent-app-releases", "", nil); rr.Code != 403 {
|
||||
t.Fatalf("%s list %d", name, rr.Code)
|
||||
}
|
||||
if rr := upload(token, 1); rr.Code != 403 {
|
||||
t.Fatalf("%s upload %d", name, rr.Code)
|
||||
}
|
||||
if rr := setCurrent(token, 1); rr.Code != 403 {
|
||||
t.Fatalf("%s activate %d", name, rr.Code)
|
||||
}
|
||||
}
|
||||
uploader := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_upload"}})
|
||||
if rr := do(uploader, "GET", "/agent-app-releases", "", nil); rr.Code != 200 {
|
||||
t.Fatalf("uploader list %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := upload(uploader, 201); rr.Code != 201 {
|
||||
t.Fatalf("uploader upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var row models.AgentAppRelease
|
||||
if err := db.First(&row).Error; err != nil || row.CreatedBy != 42 {
|
||||
t.Fatalf("createdBy=%d err=%v", row.CreatedBy, err)
|
||||
}
|
||||
if rr := setCurrent(uploader, row.ID); rr.Code != 403 {
|
||||
t.Fatalf("uploader activated %d", rr.Code)
|
||||
}
|
||||
activator := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_activate"}})
|
||||
if rr := upload(activator, 202); rr.Code != 403 {
|
||||
t.Fatalf("activator uploaded %d", rr.Code)
|
||||
}
|
||||
if rr := setCurrent(activator, row.ID); rr.Code != 200 {
|
||||
t.Fatalf("activator set current %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, e := range Inventory() {
|
||||
if strings.Contains(e.Path, "download") {
|
||||
t.Fatal("download must not be a client route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReleaseUploadBodyLimitIsScopedToUploadEndpoint(t *testing.T) {
|
||||
if got := bodyLimit(Endpoint{Method: "POST", Path: "/agent-app-releases"}); got != apprelease.MaxAPKBytes+(1<<20) {
|
||||
t.Fatalf("upload limit %d", got)
|
||||
}
|
||||
for _, e := range Inventory() {
|
||||
if e.Method == "POST" && e.Path == "/agent-app-releases" {
|
||||
continue
|
||||
}
|
||||
if got := bodyLimit(e); got != 16<<20 {
|
||||
t.Fatalf("%s limit %d", keyFor(e), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReleaseUploadAcceptsBodyAbove16MB(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Endpoint{Method: "POST", Path: "/agent-app-releases", Module: "devices", Capability: "release_upload", Handle: apprelease.Handler{DB: db, StorageDir: t.TempDir()}.Upload}
|
||||
router := gin.New()
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
|
||||
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Actions: []string{"release_upload"}}}, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 17MB of non-APK data passes the transport limit and fails only at APK parsing.
|
||||
body, ct := multipartUpload(bytes.Repeat([]byte{'x'}, 17<<20), "big.apk")
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.test/api/client/v1"+e.Path, body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 422 || !strings.Contains(rr.Body.String(), "文件不是有效 APK") {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var audits []clientkey.Audit
|
||||
if err = db.Where("event = ?", "request").Find(&audits).Error; err != nil || len(audits) != 1 || audits[0].Status != 422 {
|
||||
t.Fatalf("audit=%+v err=%v", audits, err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package clientapi
|
||||
import (
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/product"
|
||||
@@ -39,9 +40,13 @@ func Inventory() []Endpoint {
|
||||
pr := purchaserule.Handler{}
|
||||
t := task.Handler{}
|
||||
buy := purchase.Handler{}
|
||||
release := apprelease.Handler{}
|
||||
return []Endpoint{
|
||||
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
|
||||
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
|
||||
{"GET", "/agent-app-releases", access.ModuleDevices, "release_upload", release.List},
|
||||
{"POST", "/agent-app-releases", access.ModuleDevices, "release_upload", release.Upload},
|
||||
{"PUT", "/agent-app-releases/current", access.ModuleDevices, "release_activate", release.SetCurrent},
|
||||
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
|
||||
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
|
||||
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
|
||||
|
||||
@@ -170,8 +170,13 @@ type PurchaseTask struct {
|
||||
CancelReason *string `json:"cancelReason" gorm:"size:500"`
|
||||
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
|
||||
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// CreatedBy/CreatedSource 只在新建任务时写入一次(#382):admin=管理端账号,
|
||||
// client_api=客户端密钥的授权人,agent=设备重试(CreatedBy 为空);空串表示
|
||||
// 迁移前的历史任务,不回填。
|
||||
CreatedBy *uint64 `json:"createdBy" gorm:"index"`
|
||||
CreatedSource string `json:"createdSource" gorm:"size:16;not null;default:''"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (PurchaseTask) TableName() string { return "purchase_task" }
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -29,6 +31,35 @@ type AdminListRequest struct {
|
||||
// SYB products createdFrom/createdTo filter) over order_submitted_at.
|
||||
OrderSubmittedFrom string
|
||||
OrderSubmittedTo string
|
||||
// DeviceID is a numeric device id or "unassigned" (#382).
|
||||
DeviceID string
|
||||
// CreatedBy is a numeric user id, "agent" or "unrecorded" (#382).
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
const (
|
||||
adminFilterUnassigned = "unassigned"
|
||||
adminFilterAgent = "agent"
|
||||
adminFilterUnrecorded = "unrecorded"
|
||||
)
|
||||
|
||||
// parseAdminIDFilter returns (id, keyword, error); at most one of id/keyword is
|
||||
// set. Empty input means the filter is absent.
|
||||
func parseAdminIDFilter(raw, label string, keywords ...string) (uint64, string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, "", nil
|
||||
}
|
||||
for _, keyword := range keywords {
|
||||
if raw == keyword {
|
||||
return 0, keyword, nil
|
||||
}
|
||||
}
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, "", fail(CodeInvalidRequest, label+" 无效")
|
||||
}
|
||||
return id, "", nil
|
||||
}
|
||||
|
||||
// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
|
||||
@@ -78,6 +109,9 @@ type AdminTaskItem struct {
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
DeviceName string `json:"deviceName,omitempty"`
|
||||
CreatedBy *uint64 `json:"createdBy,omitempty"`
|
||||
CreatedByName string `json:"createdByName,omitempty"`
|
||||
CreatedSource string `json:"createdSource"`
|
||||
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
||||
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
||||
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
||||
@@ -219,6 +253,14 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
deviceID, deviceKeyword, err := parseAdminIDFilter(req.DeviceID, "deviceId", adminFilterUnassigned)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
createdByID, createdByKeyword, err := parseAdminIDFilter(req.CreatedBy, "createdBy", adminFilterAgent, adminFilterUnrecorded)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
|
||||
if submittedFrom != nil {
|
||||
query = query.Where("order_submitted_at >= ?", *submittedFrom)
|
||||
@@ -229,6 +271,18 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if req.TaskID > 0 {
|
||||
query = query.Where("id = ?", req.TaskID)
|
||||
}
|
||||
if deviceID > 0 {
|
||||
query = query.Where("device_id = ?", deviceID)
|
||||
} else if deviceKeyword == adminFilterUnassigned {
|
||||
query = query.Where("device_id IS NULL")
|
||||
}
|
||||
if createdByID > 0 {
|
||||
query = query.Where("created_by = ?", createdByID)
|
||||
} else if createdByKeyword == adminFilterAgent {
|
||||
query = query.Where("created_source = ?", CreatedSourceAgent)
|
||||
} else if createdByKeyword == adminFilterUnrecorded {
|
||||
query = query.Where("created_source = ?", "")
|
||||
}
|
||||
if req.Status == adminStatusSYBWritebackSucceeded {
|
||||
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
|
||||
} else if req.Status != "" {
|
||||
@@ -264,6 +318,10 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
}
|
||||
matching, err := s.matchingViews(ctx, tasks)
|
||||
if err != nil {
|
||||
return AdminListResponse{}, err
|
||||
@@ -279,6 +337,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
|
||||
}
|
||||
for _, task := range tasks {
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
applyCreatorName(&item, creatorNames)
|
||||
item.OrderWriteback = writebacks[task.ID]
|
||||
item.Matching = matching[task.ID]
|
||||
if task.ShopeeProductID != nil {
|
||||
@@ -327,6 +386,10 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
if err != nil {
|
||||
return AdminDetailResponse{}, err
|
||||
}
|
||||
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), []models.PurchaseTask{task})
|
||||
if err != nil {
|
||||
return AdminDetailResponse{}, err
|
||||
}
|
||||
var attempts []models.PurchaseTaskAttempt
|
||||
if err := s.DB.WithContext(ctx).Where("task_id = ?", task.ID).Order("attempt_number ASC, id ASC").Find(&attempts).Error; err != nil {
|
||||
return AdminDetailResponse{}, internal(err)
|
||||
@@ -345,6 +408,7 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
|
||||
return AdminDetailResponse{}, matchErr
|
||||
}
|
||||
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
||||
applyCreatorName(&item, creatorNames)
|
||||
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
|
||||
if wbErr != nil {
|
||||
return AdminDetailResponse{}, wbErr
|
||||
@@ -384,11 +448,107 @@ func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]strin
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// userDisplayNames returns sys_user display names (nickname first, then
|
||||
// username) for the given ids. Ids without a user row are absent from the map.
|
||||
func userDisplayNames(db *gorm.DB, ids []uint64) (map[uint64]string, error) {
|
||||
names := make(map[uint64]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var rows []struct {
|
||||
UserID uint64
|
||||
Username string
|
||||
NickName string
|
||||
}
|
||||
if err := db.Table("sys_user").Select("user_id, username, nick_name").Where("user_id IN ?", ids).Scan(&rows).Error; err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(row.NickName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(row.Username)
|
||||
}
|
||||
names[row.UserID] = name
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func loadCreatorNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) {
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
seen := make(map[uint64]bool)
|
||||
for _, task := range tasks {
|
||||
if task.CreatedBy != nil && !seen[*task.CreatedBy] {
|
||||
ids = append(ids, *task.CreatedBy)
|
||||
seen[*task.CreatedBy] = true
|
||||
}
|
||||
}
|
||||
return userDisplayNames(db, ids)
|
||||
}
|
||||
|
||||
func applyCreatorName(item *AdminTaskItem, names map[uint64]string) {
|
||||
if item.CreatedBy != nil {
|
||||
item.CreatedByName = names[*item.CreatedBy]
|
||||
}
|
||||
}
|
||||
|
||||
// AdminFilterOption lists the devices and creators that actually appear on
|
||||
// purchase tasks, so purchasers need no access to device or user management.
|
||||
type AdminFilterOption struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type AdminFilterOptionsResponse struct {
|
||||
Devices []AdminFilterOption `json:"devices"`
|
||||
Creators []AdminFilterOption `json:"creators"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminFilterOptions(ctx context.Context) (AdminFilterOptionsResponse, error) {
|
||||
db := s.DB.WithContext(ctx)
|
||||
var deviceIDs, creatorIDs []uint64
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("device_id IS NOT NULL").Distinct().Pluck("device_id", &deviceIDs).Error; err != nil {
|
||||
return AdminFilterOptionsResponse{}, internal(err)
|
||||
}
|
||||
if err := db.Model(&models.PurchaseTask{}).Where("created_by IS NOT NULL").Distinct().Pluck("created_by", &creatorIDs).Error; err != nil {
|
||||
return AdminFilterOptionsResponse{}, internal(err)
|
||||
}
|
||||
result := AdminFilterOptionsResponse{Devices: []AdminFilterOption{}, Creators: []AdminFilterOption{}}
|
||||
if len(deviceIDs) > 0 {
|
||||
var devices []models.AgentDevice
|
||||
if err := db.Select("id, name").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
|
||||
return AdminFilterOptionsResponse{}, internal(err)
|
||||
}
|
||||
for _, device := range devices {
|
||||
result.Devices = append(result.Devices, AdminFilterOption{ID: device.ID, Name: device.Name})
|
||||
}
|
||||
}
|
||||
names, err := userDisplayNames(db, creatorIDs)
|
||||
if err != nil {
|
||||
return AdminFilterOptionsResponse{}, err
|
||||
}
|
||||
for _, id := range creatorIDs {
|
||||
name := names[id]
|
||||
if name == "" {
|
||||
name = "用户#" + strconv.FormatUint(id, 10)
|
||||
}
|
||||
result.Creators = append(result.Creators, AdminFilterOption{ID: id, Name: name})
|
||||
}
|
||||
for _, options := range [][]AdminFilterOption{result.Devices, result.Creators} {
|
||||
sort.Slice(options, func(i, j int) bool {
|
||||
if options[i].Name != options[j].Name {
|
||||
return options[i].Name < options[j].Name
|
||||
}
|
||||
return options[i].ID < options[j].ID
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem {
|
||||
item := AdminTaskItem{
|
||||
ID: task.ID, TaskType: task.TaskType, ExecutionMode: task.ExecutionMode, Status: task.Status,
|
||||
SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID,
|
||||
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
|
||||
DeviceID: task.DeviceID, CreatedBy: task.CreatedBy, CreatedSource: task.CreatedSource, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
|
||||
ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot,
|
||||
PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot,
|
||||
TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot,
|
||||
|
||||
@@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if sybCancelled(syb) {
|
||||
return disabled(sybCancelledReason)
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
return disabled("该商品已标记为无需 PDD 采购")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type BatchCreateRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
DeviceID *uint64 `json:"deviceId,omitempty"`
|
||||
creator creatorStamp
|
||||
}
|
||||
|
||||
type BatchPreviewItem struct {
|
||||
@@ -313,7 +314,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc
|
||||
SYBProductID: &sybID, DeviceID: req.DeviceID,
|
||||
ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent,
|
||||
MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent,
|
||||
Currency: preview.Currency, RuleSnapshot: rawRule,
|
||||
Currency: preview.Currency, RuleSnapshot: rawRule, creator: req.creator,
|
||||
})
|
||||
if createErr != nil {
|
||||
preview.Eligible = false
|
||||
@@ -403,6 +404,12 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
// is the single computation shared by BatchPreview, BatchCreate's
|
||||
// pre-check and quick-replace's ValidateQuickReplacement, so this one
|
||||
// check covers all of them.
|
||||
// #381: a cancelled SYB order is blocked the same way and takes precedence
|
||||
// for the reason shown (matches the process stage priority).
|
||||
if sybCancelled(syb) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeSYBCancelled, sybCancelledReason, ""
|
||||
return item
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", ""
|
||||
return item
|
||||
|
||||
@@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc
|
||||
item.CollectionDisabledReason = "SYB 商品不存在或已删除"
|
||||
return
|
||||
}
|
||||
if sybCancelled(syb) {
|
||||
item.CollectionDisabledReason = sybCancelledReason
|
||||
return
|
||||
}
|
||||
if syb.PDDExcluded {
|
||||
item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购"
|
||||
return
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/common/clientprincipal"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ensureSysUserTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec("CREATE TABLE IF NOT EXISTS sys_user (user_id integer primary key, username text, nick_name text)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func addSysUser(t *testing.T, db *gorm.DB, id uint64, username, nick string) {
|
||||
t.Helper()
|
||||
ensureSysUserTable(t, db)
|
||||
if err := db.Exec("INSERT INTO sys_user(user_id, username, nick_name) VALUES(?,?,?)", id, username, nick).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func stockRequest(f fixture) StockCreateRequest {
|
||||
return StockCreateRequest{
|
||||
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
|
||||
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
|
||||
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
|
||||
}
|
||||
}
|
||||
|
||||
func stockFixture(t *testing.T, db *gorm.DB, f fixture) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func extraDevice(name string) models.AgentDevice {
|
||||
return models.AgentDevice{Name: name, InstallID: uuid.NewString(), Manufacturer: "T", Model: "T", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7",
|
||||
Status: "online", TokenDigest: uuid.NewString(), TokenIssuedAt: time.Now()}
|
||||
}
|
||||
|
||||
func uptr(v uint64) *uint64 { return &v }
|
||||
|
||||
func TestCreatorFromContextMapsCallerToSourceAndUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
newCtx := func() *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
return c
|
||||
}
|
||||
jwtCtx := newCtx()
|
||||
jwtCtx.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(7), "rolekey": "purchaser"})
|
||||
if got := creatorFromContext(jwtCtx); got.Source != CreatedSourceAdmin || got.By == nil || *got.By != 7 {
|
||||
t.Fatalf("jwt creator: %+v", got)
|
||||
}
|
||||
clientCtx := newCtx()
|
||||
clientprincipal.Set(clientCtx, clientprincipal.Identity{KeyID: 3, AuthorizedBy: 9})
|
||||
if got := creatorFromContext(clientCtx); got.Source != CreatedSourceClientAPI || got.By == nil || *got.By != 9 {
|
||||
t.Fatalf("client creator: %+v", got)
|
||||
}
|
||||
anonymous := newCtx()
|
||||
if got := creatorFromContext(anonymous); got.Source != CreatedSourceAdmin || got.By != nil {
|
||||
t.Fatalf("zero operator must be NULL: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEntriesStampCreatorOnceAndReplayKeepsIt(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
stockFixture(t, db, f)
|
||||
service := testService(db)
|
||||
|
||||
req := stockRequest(f)
|
||||
req.creator = creatorStamp{By: uptr(11), Source: CreatedSourceAdmin}
|
||||
first, replayed, err := service.CreateStock(context.Background(), req)
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("create stock: %v replayed=%v", err, replayed)
|
||||
}
|
||||
if first.CreatedBy == nil || *first.CreatedBy != 11 || first.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("stock creator not stamped: %+v %v", first.CreatedBy, first.CreatedSource)
|
||||
}
|
||||
req.creator = creatorStamp{By: uptr(99), Source: CreatedSourceClientAPI}
|
||||
replay, replayed, err := service.CreateStock(context.Background(), req)
|
||||
if err != nil || !replayed || replay.ID != first.ID {
|
||||
t.Fatalf("stock replay: %v", err)
|
||||
}
|
||||
var stored models.PurchaseTask
|
||||
if err = db.First(&stored, first.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreatedBy == nil || *stored.CreatedBy != 11 || stored.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("replay rewrote creator: %+v %v", stored.CreatedBy, stored.CreatedSource)
|
||||
}
|
||||
|
||||
single, err := createLive(t, service, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if single.CreatedBy != nil || single.CreatedSource != "" {
|
||||
t.Fatalf("unstamped create must stay unrecorded: %+v %q", single.CreatedBy, single.CreatedSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateStampsCreatorAndReplayDoesNotRewrite(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
request := BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID,
|
||||
creator: creatorStamp{By: uptr(21), Source: CreatedSourceClientAPI}}
|
||||
first, err := service.BatchCreate(context.Background(), request)
|
||||
if err != nil || first.CreatedCount != 1 || first.Items[0].TaskID == nil {
|
||||
t.Fatalf("batch create: %+v %v", first, err)
|
||||
}
|
||||
request.creator = creatorStamp{By: uptr(22), Source: CreatedSourceAdmin}
|
||||
replay, err := service.BatchCreate(context.Background(), request)
|
||||
if err != nil || replay.CreatedCount != 1 || !replay.Items[0].Replayed {
|
||||
t.Fatalf("batch replay: %+v %v", replay, err)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, *first.Items[0].TaskID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task.CreatedBy == nil || *task.CreatedBy != 21 || task.CreatedSource != CreatedSourceClientAPI {
|
||||
t.Fatalf("batch creator: %+v %q", task.CreatedBy, task.CreatedSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRetryTaskIsAgentSourceWithoutCreator(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "当前黑色", "target_size": "均碼"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var created, source models.PurchaseTask
|
||||
if err = db.First(&created, result.TaskID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.CreatedBy != nil || created.CreatedSource != CreatedSourceAgent {
|
||||
t.Fatalf("agent retry creator: %+v %q", created.CreatedBy, created.CreatedSource)
|
||||
}
|
||||
if err = db.First(&source, failed.ID).Error; err != nil || source.CreatedSource != "" {
|
||||
t.Fatalf("source task must be untouched: %q %v", source.CreatedSource, err)
|
||||
}
|
||||
}
|
||||
|
||||
func callAdminHandler(t *testing.T, db *gorm.DB, handler func(Handler, *gin.Context), method, target, body string, setup func(*gin.Context)) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
setup(c)
|
||||
handler(Handler{DB: db}, c)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAdminCreateStockHandlerStampsJWTAndClientCreators(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
stockFixture(t, db, f)
|
||||
body := func() string {
|
||||
raw, _ := json.Marshal(stockRequest(f))
|
||||
return string(raw)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(*gin.Context)
|
||||
by uint64
|
||||
source string
|
||||
}{
|
||||
{"admin", func(c *gin.Context) {
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(5), "rolekey": "purchaser"})
|
||||
}, 5, CreatedSourceAdmin},
|
||||
{"client", func(c *gin.Context) {
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 1, AuthorizedBy: 8})
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "admin"})
|
||||
}, 8, CreatedSourceClientAPI},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := callAdminHandler(t, db, Handler.AdminCreateStock, http.MethodPost, "/api/admin/v1/purchase-tasks/stock", body(), tc.setup)
|
||||
var out struct {
|
||||
Data models.PurchaseTask `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != http.StatusOK || out.Data.ID == 0 {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var stored models.PurchaseTask
|
||||
if err := db.First(&stored, out.Data.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CreatedBy == nil || *stored.CreatedBy != tc.by || stored.CreatedSource != tc.source {
|
||||
t.Fatalf("stored creator: %+v %q", stored.CreatedBy, stored.CreatedSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedCreatorRows inserts tasks with distinct devices/creators/sources.
|
||||
func seedCreatorRows(t *testing.T, db *gorm.DB, f fixture) (devB models.AgentDevice, ids map[string]uint64) {
|
||||
t.Helper()
|
||||
s := testService(db)
|
||||
base := createAdminQueryTask(t, s, f)
|
||||
devB = extraDevice("Pixel")
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&devB).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = map[string]uint64{}
|
||||
add := func(key string, device *uint64, by *uint64, source string, status string) {
|
||||
row := base
|
||||
row.ID = 0
|
||||
row.CreateRequestID = uuid.NewString()
|
||||
row.DeviceID, row.CreatedBy, row.CreatedSource, row.Status = device, by, source, status
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[key] = row.ID
|
||||
}
|
||||
add("adminA_devA", &f.device.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
|
||||
add("adminA_devB", &devB.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
|
||||
add("adminB_unassigned", nil, uptr(2), CreatedSourceAdmin, models.PurchaseTaskStatusCancelled)
|
||||
add("agent_devA", &f.device.ID, nil, CreatedSourceAgent, models.PurchaseTaskStatusFailed)
|
||||
add("legacy_devB", &devB.ID, nil, "", models.PurchaseTaskStatusFailed)
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", base.ID).Update("created_source", "").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids["base"] = base.ID
|
||||
return devB, ids
|
||||
}
|
||||
|
||||
func listIDs(t *testing.T, s *Service, req AdminListRequest) map[uint64]bool {
|
||||
t.Helper()
|
||||
out, err := s.AdminList(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[uint64]bool{}
|
||||
for _, item := range out.Items {
|
||||
got[item.ID] = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func sameIDs(t *testing.T, got map[uint64]bool, ids map[string]uint64, keys ...string) {
|
||||
t.Helper()
|
||||
if len(got) != len(keys) {
|
||||
t.Fatalf("got %v want keys %v (%v)", got, keys, ids)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !got[ids[key]] {
|
||||
t.Fatalf("missing %s in %v (%v)", key, got, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListDeviceAndCreatedByFilters(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
addSysUser(t, db, 1, "alice", "爱丽丝")
|
||||
addSysUser(t, db, 2, "bob", "")
|
||||
devB, ids := seedCreatorRows(t, db, f)
|
||||
s := testService(db)
|
||||
devA := f.device.ID
|
||||
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA)}), ids, "base", "adminA_devA", "agent_devA")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID)}), ids, "adminA_devB", "legacy_devB")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: "unassigned"}), ids, "adminB_unassigned")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1"}), ids, "adminA_devA", "adminA_devB")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "agent"}), ids, "agent_devA")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "unrecorded"}), ids, "base", "legacy_devB")
|
||||
// Combined with each other and with existing filters.
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID), CreatedBy: "1"}), ids, "adminA_devB")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA), CreatedBy: "unrecorded"}), ids, "base")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "2", Status: models.PurchaseTaskStatusCancelled, DeviceID: "unassigned"}), ids, "adminB_unassigned")
|
||||
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1", Status: models.PurchaseTaskStatusCancelled}), ids)
|
||||
|
||||
for _, bad := range []AdminListRequest{
|
||||
{DeviceID: "abc"}, {DeviceID: "0"}, {DeviceID: "-1"}, {DeviceID: "agent"},
|
||||
{CreatedBy: "abc"}, {CreatedBy: "0"}, {CreatedBy: "unassigned"},
|
||||
} {
|
||||
if _, err := s.AdminList(context.Background(), bad); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("%+v accepted: %v", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := s.AdminList(context.Background(), AdminListRequest{PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range out.Items {
|
||||
switch item.ID {
|
||||
case ids["adminA_devA"]:
|
||||
if item.CreatedBy == nil || *item.CreatedBy != 1 || item.CreatedByName != "爱丽丝" || item.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("nick name item: %+v", item)
|
||||
}
|
||||
case ids["adminB_unassigned"]:
|
||||
if item.CreatedByName != "bob" {
|
||||
t.Fatalf("username fallback: %q", item.CreatedByName)
|
||||
}
|
||||
case ids["agent_devA"]:
|
||||
if item.CreatedBy != nil || item.CreatedSource != CreatedSourceAgent || item.CreatedByName != "" {
|
||||
t.Fatalf("agent item: %+v", item)
|
||||
}
|
||||
case ids["legacy_devB"]:
|
||||
if item.CreatedSource != "" {
|
||||
t.Fatalf("legacy item: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
detail, err := s.AdminDetail(context.Background(), ids["adminA_devA"])
|
||||
if err != nil || detail.Task.CreatedByName != "爱丽丝" || detail.Task.CreatedSource != CreatedSourceAdmin {
|
||||
t.Fatalf("detail creator: %+v %v", detail.Task, err)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v uint64) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAdminListHandlerRejectsInvalidDeviceAndCreatedBy(t *testing.T) {
|
||||
db := testDB(t)
|
||||
for _, query := range []string{"deviceId=abc", "createdBy=abc", "deviceId=0"} {
|
||||
w := callAdminHandler(t, db, Handler.AdminList, http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, "", func(c *gin.Context) {
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
|
||||
})
|
||||
if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), CodeInvalidRequest) {
|
||||
t.Fatalf("%s: %d %s", query, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFilterOptionsListsOnlyUsedDevicesAndCreatorsSorted(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
|
||||
addSysUser(t, db, 1, "zed", "")
|
||||
addSysUser(t, db, 2, "amy", "Amy")
|
||||
addSysUser(t, db, 3, "unused", "Unused")
|
||||
devB, ids := seedCreatorRows(t, db, f)
|
||||
_ = ids
|
||||
unused := extraDevice("Unused")
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&unused).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := testService(db).AdminFilterOptions(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Devices) != 2 || out.Devices[0].Name != "Pixel" || out.Devices[0].ID != devB.ID || out.Devices[1].ID != f.device.ID {
|
||||
t.Fatalf("devices: %+v", out.Devices)
|
||||
}
|
||||
if len(out.Creators) != 2 || out.Creators[0].Name != "Amy" || out.Creators[0].ID != 2 || out.Creators[1].Name != "zed" {
|
||||
t.Fatalf("creators: %+v", out.Creators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFilterOptionsEmptyReturnsEmptyArrays(t *testing.T) {
|
||||
empty, err := testService(testDB(t)).AdminFilterOptions(context.Background())
|
||||
if err != nil || empty.Devices == nil || empty.Creators == nil || len(empty.Devices)+len(empty.Creators) != 0 {
|
||||
t.Fatalf("empty options must be empty arrays: %+v %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFilterOptionsHandlerAccess(t *testing.T) {
|
||||
db := testDB(t)
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
w := callAdminHandler(t, db, Handler.AdminFilterOptions, http.MethodGet, "/api/admin/v1/purchase-tasks/filter-options", "", func(c *gin.Context) {
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if want && w.Code != http.StatusOK {
|
||||
t.Fatalf("role %q denied: %d %s", role, w.Code, w.Body.String())
|
||||
}
|
||||
if !want && w.Code != http.StatusForbidden {
|
||||
t.Fatalf("role %q allowed: %d", role, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOptionsRouteDoesNotCollideWithTaskID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
group := engine.Group("/api/admin/v1/purchase-tasks")
|
||||
group.GET("", func(c *gin.Context) { c.String(200, "list") })
|
||||
group.GET("/filter-options", func(c *gin.Context) { c.String(200, "options") })
|
||||
group.GET("/:taskId", func(c *gin.Context) { c.String(200, "detail") })
|
||||
for path, want := range map[string]string{"/api/admin/v1/purchase-tasks/filter-options": "options", "/api/admin/v1/purchase-tasks/12": "detail"} {
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Body.String() != want {
|
||||
t.Fatalf("%s routed to %q", path, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ func (h Handler) AdminList(c *gin.Context) {
|
||||
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
|
||||
OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
|
||||
OrderSubmittedTo: c.Query("orderSubmittedTo"),
|
||||
DeviceID: c.Query("deviceId"),
|
||||
CreatedBy: c.Query("createdBy"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -66,6 +68,22 @@ func (h Handler) AdminList(c *gin.Context) {
|
||||
writeAdminData(c, response)
|
||||
}
|
||||
|
||||
func (h Handler) AdminFilterOptions(c *gin.Context) {
|
||||
if !allowedOperator(c) {
|
||||
return
|
||||
}
|
||||
service, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.AdminFilterOptions(c.Request.Context())
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
writeAdminData(c, response)
|
||||
}
|
||||
|
||||
func (h Handler) AdminCreateStock(c *gin.Context) {
|
||||
if !allowedOperator(c) {
|
||||
return
|
||||
@@ -78,6 +96,7 @@ func (h Handler) AdminCreateStock(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.creator = creatorFromContext(c)
|
||||
record, replayed, err := service.CreateStock(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -118,6 +137,7 @@ func (h Handler) AdminCreate(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.creator = creatorFromContext(c)
|
||||
record, replayed, err := service.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -178,6 +198,7 @@ func (h Handler) AdminBatchCreate(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.creator = creatorFromContext(c)
|
||||
response, err := service.BatchCreate(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
@@ -550,6 +571,20 @@ func writeError(c *gin.Context, err error) {
|
||||
}
|
||||
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
|
||||
}
|
||||
|
||||
// creatorFromContext maps the authenticated caller to the #382 creator stamp.
|
||||
// A zero operator id is stored as NULL rather than a fake user.
|
||||
func creatorFromContext(c *gin.Context) creatorStamp {
|
||||
stamp := creatorStamp{Source: CreatedSourceAdmin}
|
||||
if _, ok := clientprincipal.Get(c); ok {
|
||||
stamp.Source = CreatedSourceClientAPI
|
||||
}
|
||||
if id := operatorID(c); id > 0 {
|
||||
stamp.By = &id
|
||||
}
|
||||
return stamp
|
||||
}
|
||||
|
||||
func operatorID(c *gin.Context) uint64 {
|
||||
if id, ok := clientprincipal.Get(c); ok {
|
||||
return id.AuthorizedBy
|
||||
|
||||
@@ -31,7 +31,12 @@ const (
|
||||
// pdd_purchase_excluded: it never needs a PDD purchase. It only yields to
|
||||
// an active return match (checked first, same priority order #338 uses
|
||||
// for the pipeline below it) — see processStageFromDataset.
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
ProcessStagePDDExcluded = "pdd_excluded"
|
||||
// ProcessStageSYBCancelled is #381's stage for a cancelled SYB order
|
||||
// (syb_is_cancelled = true). It yields to an existing task/order result
|
||||
// and an active return match, and wins over pdd_excluded. It is not in
|
||||
// returnmatch.participatingStages, so cancelled rows are not matched.
|
||||
ProcessStageSYBCancelled = "syb_cancelled"
|
||||
processActionOpenPDDLink = "open_pdd_link"
|
||||
)
|
||||
|
||||
@@ -49,6 +54,7 @@ var processStageLabels = map[string]string{
|
||||
ProcessStageReturnPending: "退货待确认",
|
||||
ProcessStageReturnUsed: "已用退货",
|
||||
ProcessStagePDDExcluded: "无需采购",
|
||||
ProcessStageSYBCancelled: "已取消",
|
||||
}
|
||||
|
||||
type ProcessStageResult struct {
|
||||
@@ -157,6 +163,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
|
||||
}
|
||||
|
||||
// #381: a cancelled SYB order comes next, before the #340 exclusion mark.
|
||||
if syb, ok := dataset.sybByID[id]; ok && sybCancelled(syb) {
|
||||
return stage(ProcessStageSYBCancelled, "SYB 订单已取消,无需 PDD 采购", "")
|
||||
}
|
||||
|
||||
// #340: an excluded row's mark also only overrides after the active-task
|
||||
// and active-return-match checks above, and before every other pipeline
|
||||
// branch below.
|
||||
|
||||
@@ -196,6 +196,7 @@ func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (
|
||||
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
|
||||
Currency: candidate.preview.Currency,
|
||||
RuleSnapshot: rawRule,
|
||||
creator: creatorStamp{Source: CreatedSourceAgent},
|
||||
})
|
||||
item := response.Items[candidate.resultIndex]
|
||||
if createErr != nil {
|
||||
|
||||
@@ -29,6 +29,24 @@ func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
// sybCancelled reports #381's SYB cancelled mark: only an explicit true
|
||||
// blocks; NULL (not fetched) and false do not.
|
||||
func sybCancelled(syb models.SYBProduct) bool {
|
||||
return syb.SYBIsCancelled != nil && *syb.SYBIsCancelled
|
||||
}
|
||||
|
||||
// sybCancelledReason is the shared user-facing reason for #381 blocks.
|
||||
const sybCancelledReason = "SYB 订单已取消,无需采购"
|
||||
|
||||
// rejectIfSYBCancelled implements #381's hard purchase-creation block for a
|
||||
// cancelled SYB order (single and batch share s.Create).
|
||||
func rejectIfSYBCancelled(syb models.SYBProduct) error {
|
||||
if sybCancelled(syb) {
|
||||
return fail(CodeSYBCancelled, sybCancelledReason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB
|
||||
// product marked pdd_purchase_excluded must never get a purchase task,
|
||||
// single or batch (BatchCreate calls s.Create per row, so this one check
|
||||
|
||||
@@ -29,6 +29,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
agent.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot)
|
||||
admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", h.AdminList)
|
||||
admin.GET("/filter-options", h.AdminFilterOptions)
|
||||
admin.POST("/batch-preview", h.AdminBatchPreview)
|
||||
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
|
||||
admin.POST("/batch", h.AdminBatchCreate)
|
||||
|
||||
@@ -184,6 +184,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
if err := rejectIfPDDExcluded(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectIfSYBCancelled(syb); err != nil {
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
@@ -346,6 +349,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
|
||||
PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot,
|
||||
Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID,
|
||||
CreatedBy: req.creator.By, CreatedSource: req.creator.Source,
|
||||
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected}
|
||||
if err := tx.Create(&out).Error; err != nil {
|
||||
if isDuplicate(err) {
|
||||
@@ -374,7 +378,7 @@ func (s *Service) CreateStock(ctx context.Context, req StockCreateRequest) (mode
|
||||
DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID,
|
||||
TargetColor: req.Color, TargetSize: req.Size, Quantity: req.Quantity,
|
||||
MinUnitPriceCent: req.MinUnitPriceCent, MaxUnitPriceCent: req.MaxUnitPriceCent,
|
||||
Currency: "CNY", RuleSnapshot: rule,
|
||||
Currency: "CNY", RuleSnapshot: rule, creator: req.creator,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #381: a cancelled SYB order (syb_is_cancelled = true) must be blocked by the
|
||||
// PDD entry points; NULL and false must not block.
|
||||
|
||||
func setSYBCancelled(t *testing.T, db *gorm.DB, id uint64, value any) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", id).Update("syb_is_cancelled", value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_RejectsWhenSYBCancelled(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
|
||||
_, err := createLive(t, s, f)
|
||||
if se, ok := asServiceError(err); !ok || se.Code != CodeSYBCancelled {
|
||||
t.Fatalf("expected CodeSYBCancelled, got %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("no task may be created, got %d", count)
|
||||
}
|
||||
|
||||
// Excluded as well: creation is still rejected.
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := createLive(t, s, f); err == nil {
|
||||
t.Fatalf("cancelled+excluded creation must still be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_NotBlockedWhenSYBCancelledFalse(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setSYBCancelled(t, db, f.syb.ID, false)
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("false must not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_NotBlockedWhenSYBCancelledNull(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if f.syb.SYBIsCancelled != nil {
|
||||
t.Fatalf("fixture must have NULL cancel state")
|
||||
}
|
||||
if _, err := createLive(t, s, f); err != nil {
|
||||
t.Fatalf("NULL must not block: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreview_CancelledRowReportsReason(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
// Excluded as well: the cancelled reason and stage win.
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("pdd_purchase_excluded", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
||||
if err != nil || len(resp.Items) != 1 {
|
||||
t.Fatalf("preview: %v %+v", err, resp)
|
||||
}
|
||||
item := resp.Items[0]
|
||||
if item.Eligible || item.CollectionEligible || item.AIMatchEligible {
|
||||
t.Fatalf("cancelled row must not be eligible: %+v", item)
|
||||
}
|
||||
if item.ReasonCode != CodeSYBCancelled || item.Reason != sybCancelledReason {
|
||||
t.Fatalf("expected cancelled reason, got %+v", item)
|
||||
}
|
||||
if item.CollectionDisabledReason != sybCancelledReason || item.AIMatchDisabledReason != sybCancelledReason {
|
||||
t.Fatalf("expected cancelled disabled reasons, got %+v", item)
|
||||
}
|
||||
if item.ProcessStage != ProcessStageSYBCancelled || item.ProcessStageLabel != "已取消" {
|
||||
t.Fatalf("expected syb_cancelled stage, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreate_SkipsCancelledRowNotWholeBatch(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f1 := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f1.pdd.ID)
|
||||
syb2 := models.SYBProduct{OrderCode: "SYB-CANCEL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
|
||||
if err := db.Create(&syb2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setSYBCancelled(t, db, f1.syb.ID, true)
|
||||
|
||||
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
|
||||
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
|
||||
t.Fatalf("expected 1 created + 1 failed, got %+v", resp)
|
||||
}
|
||||
for _, item := range resp.Items {
|
||||
if item.SYBProductID == f1.syb.ID && item.Created {
|
||||
t.Fatalf("cancelled row must not be created: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStage_CancelledPriorityAndRecovery(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
stageOf := func() ProcessStageResult {
|
||||
t.Helper()
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return stages[f.syb.ID]
|
||||
}
|
||||
before := stageOf()
|
||||
if before.Stage == ProcessStageSYBCancelled {
|
||||
t.Fatalf("unexpected cancelled stage before marking: %+v", before)
|
||||
}
|
||||
if !ValidProcessStage(ProcessStageSYBCancelled) {
|
||||
t.Fatalf("syb_cancelled must be a valid filter value")
|
||||
}
|
||||
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
if got := stageOf(); got.Stage != ProcessStageSYBCancelled || got.Label != "已取消" {
|
||||
t.Fatalf("expected syb_cancelled, got %+v", got)
|
||||
}
|
||||
|
||||
// An active return match wins over cancelled.
|
||||
match := models.ReturnMatch{SYBProductID: f.syb.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now()}
|
||||
if err := db.Create(&match).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stageOf(); got.Stage != ProcessStageReturnPending {
|
||||
t.Fatalf("return match must win over cancelled, got %+v", got)
|
||||
}
|
||||
if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID).
|
||||
Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Recovery: false restores the original stage.
|
||||
setSYBCancelled(t, db, f.syb.ID, false)
|
||||
if got := stageOf(); got.Stage != before.Stage {
|
||||
t.Fatalf("recovery should restore %s, got %+v", before.Stage, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStage_ExistingTaskWinsOverCancelled(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := testService(db)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setSYBCancelled(t, db, f.syb.ID, true)
|
||||
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := stages[f.syb.ID]; got.Stage != ProcessStageTaskCreated || got.ActiveTaskID == nil || *got.ActiveTaskID != task.ID {
|
||||
t.Fatalf("existing task must win over cancelled, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,11 @@ const (
|
||||
// time and is recorded as 「无需 PDD 采购」, so it must never start any
|
||||
// PDD flow, regardless of the rest of its pipeline state.
|
||||
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
// CodeSYBCancelled is returned by the PDD entry points when the SYB order
|
||||
// is cancelled (syb_is_cancelled = true, #381). NULL (unknown) and false
|
||||
// never block. Existing purchase tasks are not affected.
|
||||
CodeSYBCancelled = "PURCHASE_SYB_CANCELLED"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
@@ -92,8 +96,23 @@ type CreateRequest struct {
|
||||
// immutable source-task value instead of accepting a client-supplied
|
||||
// identity snapshot.
|
||||
shopeeOrderNoSnapshotOverride string
|
||||
// creator is server-only (#382): written once when a task row is created.
|
||||
creator creatorStamp
|
||||
}
|
||||
|
||||
// creatorStamp records who created a purchase task. It is never decoded from
|
||||
// request bodies; the handler or the internal caller fills it.
|
||||
type creatorStamp struct {
|
||||
By *uint64
|
||||
Source string
|
||||
}
|
||||
|
||||
const (
|
||||
CreatedSourceAdmin = "admin"
|
||||
CreatedSourceClientAPI = "client_api"
|
||||
CreatedSourceAgent = "agent"
|
||||
)
|
||||
|
||||
// StockCreateRequest is intentionally detached from SYB/Shopee identities.
|
||||
// The server supplies the reviewed purchase rule and derives the immutable
|
||||
// reference price from the selected color in the collected PDD archive.
|
||||
@@ -108,6 +127,7 @@ type StockCreateRequest struct {
|
||||
Quantity int64 `json:"quantity"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
creator creatorStamp
|
||||
}
|
||||
|
||||
type MatchingView struct {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// #381: a cancelled SYB order has stage syb_cancelled, which is not a
|
||||
// participating stage, so return matching must skip it; recovery (false)
|
||||
// makes it eligible again.
|
||||
func TestBatchMatch_CancelledRowSkippedAndRecovers(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
|
||||
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
syb := seedSYB(t, db, "SYB-CANCEL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
||||
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("syb_is_cancelled", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 0 || len(resp.Items) != 1 || resp.Items[0].Matched {
|
||||
t.Fatalf("cancelled row must not be matched: %+v", resp)
|
||||
}
|
||||
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("syb_is_cancelled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MatchedCount != 1 {
|
||||
t.Fatalf("after recovery the row must be matchable: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@ package sybimport
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// SYB returns numeric isCancel: 1 cancelled, 0 not cancelled. Do not infer
|
||||
// anything from status/orderStatus, missing fields or undocumented values.
|
||||
// SYB's numeric isCancel enum is 0 (正常), 1 (取消), 2 (已取消).
|
||||
// Both 1 and 2 mean cancelled; other nonzero values remain unknown. Do not
|
||||
// infer anything from status/orderStatus or missing fields.
|
||||
func parseSYBIsCancelled(raw map[string]any) *bool {
|
||||
var number float64
|
||||
switch value := raw["isCancel"].(type) {
|
||||
@@ -18,9 +19,9 @@ func parseSYBIsCancelled(raw map[string]any) *bool {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if number != 0 && number != 1 {
|
||||
if number != 0 && number != 1 && number != 2 {
|
||||
return nil
|
||||
}
|
||||
cancelled := number == 1
|
||||
cancelled := number == 1 || number == 2
|
||||
return &cancelled
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ package sybimport
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,23 +18,42 @@ import (
|
||||
|
||||
func TestParseSYBIsCancelled(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{`{"isCancel":1}`, "true"}, {`{"isCancel":0}`, "false"},
|
||||
{`{"isCancel":1}`, "true"}, {`{"isCancel":2}`, "true"}, {`{"isCancel":0}`, "false"},
|
||||
{`{"isCancel":0.0}`, "false"}, {`{"isCancel":1.0}`, "true"}, {`{"isCancel":2.0}`, "true"},
|
||||
{`{"isCancel":2e0}`, "true"},
|
||||
{`{}`, "null"}, {`{"isCancel":null}`, "null"},
|
||||
{`{"isCancel":2}`, "null"}, {`{"isCancel":-1}`, "null"},
|
||||
{`{"isCancel":3}`, "null"}, {`{"isCancel":-1}`, "null"},
|
||||
{`{"isCancel":0.5}`, "null"}, {`{"isCancel":"1"}`, "null"},
|
||||
{`{"isCancel":true}`, "null"}, {`{"status":79,"orderStatus":"已取消"}`, "null"},
|
||||
{`{"isCancel":1.5}`, "null"}, {`{"isCancel":2.5}`, "null"},
|
||||
{`{"isCancel":"2"}`, "null"}, {`{"isCancel":"0"}`, "null"}, {`{"isCancel":""}`, "null"},
|
||||
{`{"isCancel":true}`, "null"}, {`{"isCancel":false}`, "null"},
|
||||
{`{"status":79,"orderStatus":"已取消","platformorderStatus":"CANCELLED"}`, "null"},
|
||||
{`{"isCancel":0,"status":79,"orderStatus":"已取消","platformorderStatus":"CANCELLED"}`, "false"},
|
||||
{`{"isCancel":0,"orderStatus":"申请取消"}`, "false"},
|
||||
{`{"isCancel":1,"orderStatus":"已发货"}`, "true"},
|
||||
{`{"isCancel":2,"orderStatus":"已发货"}`, "true"},
|
||||
{`{"isCancel":2,"orderStatus":"运送中"}`, "true"},
|
||||
{`{"isCancel":2,"orderStatus":"待出货"}`, "true"},
|
||||
} {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.input), &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := json.Marshal(parseSYBIsCancelled(raw))
|
||||
if string(got) != tc.want {
|
||||
t.Errorf("%s: %s, want %s", tc.input, got, tc.want)
|
||||
for _, useNumber := range []bool{false, true} {
|
||||
var raw map[string]any
|
||||
decoder := json.NewDecoder(strings.NewReader(tc.input))
|
||||
if useNumber {
|
||||
decoder.UseNumber()
|
||||
}
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := json.Marshal(parseSYBIsCancelled(raw))
|
||||
if string(got) != tc.want {
|
||||
t.Errorf("UseNumber=%t %s: %s, want %s", useNumber, tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := parseSYBIsCancelled(map[string]any{"isCancel": json.Number("1")}); got == nil || !*got {
|
||||
t.Fatal("json.Number")
|
||||
for _, value := range []any{json.Number("invalid"), json.Number("1e999"), json.Number("NaN"), math.NaN(), math.Inf(1)} {
|
||||
if got := parseSYBIsCancelled(map[string]any{"isCancel": value}); got != nil {
|
||||
t.Errorf("invalid numeric value %v must remain unknown", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,15 +64,29 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
detail := sybclient.StockDetail{ID: row.ID, Code: row.Code, Details: []sybclient.DetailItem{
|
||||
{ID: 711, ProductID: 701, ProductQty: 1, ProductSpec: "黑色,XL"},
|
||||
{ID: 712, ProductID: 702, ProductQty: 1, ProductSpec: "白色,L"},
|
||||
{ID: 713, ProductID: 703, ProductQty: 1, ProductSpec: "蓝色,M"},
|
||||
}}
|
||||
untouchedTime := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
|
||||
unrelated := models.SYBProduct{OrderCode: "MOCK-UNRELATED", DetailID: 721, StockID: 72,
|
||||
Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`,
|
||||
SYBIsCancelled: boolPointer(false), SYBStatusSyncedAt: &untouchedTime}
|
||||
if err := db.Create(&unrelated).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var previous *time.Time
|
||||
var existingTask models.PurchaseTask
|
||||
var firstDetailID uint64
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want *bool
|
||||
raw string
|
||||
want *bool
|
||||
valid bool
|
||||
}{
|
||||
{`{}`, nil}, {`{"isCancel":1}`, boolPointer(true)},
|
||||
{`{"isCancel":null}`, boolPointer(true)}, {`{"isCancel":7}`, boolPointer(true)},
|
||||
{`{"isCancel":0}`, boolPointer(false)}, {`{}`, boolPointer(false)},
|
||||
{`{}`, nil, false}, {`{"isCancel":0}`, boolPointer(false), true},
|
||||
{`{"isCancel":2,"orderStatus":"已发货"}`, boolPointer(true), true},
|
||||
{`{"isCancel":2}`, boolPointer(true), true},
|
||||
{`{"isCancel":null}`, boolPointer(true), false}, {`{"isCancel":7}`, boolPointer(true), false},
|
||||
{`{"isCancel":"2"}`, boolPointer(true), false}, {`{}`, boolPointer(true), false},
|
||||
{`{"isCancel":0}`, boolPointer(false), true}, {`{}`, boolPointer(false), false},
|
||||
} {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.raw), &values); err != nil {
|
||||
@@ -59,17 +96,43 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
for key, value := range values {
|
||||
row.Raw[key] = value
|
||||
}
|
||||
beforeSync := time.Now().UTC()
|
||||
if err := applyStockDetail(ctx, db, row, detail, &SyncReport{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterSync := time.Now().UTC()
|
||||
var records []models.SYBProduct
|
||||
if err := db.Order("id").Find(&records).Error; err != nil {
|
||||
if err := db.Where("order_code = ?", row.Code).Order("id").Find(&records).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatal("must keep both details")
|
||||
if len(records) != 3 {
|
||||
t.Fatal("must keep all three details without duplicates")
|
||||
}
|
||||
firstDetailID = records[0].ID
|
||||
if existingTask.ID == 0 {
|
||||
product := models.PDDProduct{GoodsID: "389001", URL: "https://example.invalid/389001", SpecsJSON: `{}`}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existingTask = models.PurchaseTask{SYBProductID: &records[0].ID, PDDProductID: product.ID,
|
||||
ExecutionMode: models.PurchaseExecutionModeLive, Status: models.PurchaseTaskStatusPending,
|
||||
Quantity: 1, Currency: "TWD", CreateRequestID: "mock-389-existing-task",
|
||||
SpecDecisionSnapshot: `{}`, RuleSnapshot: `{}`, RequiredCapabilitiesJSON: `[]`}
|
||||
if err := db.Create(&existingTask).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&existingTask, existingTask.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
var current models.PurchaseTask
|
||||
if err := db.First(¤t, existingTask.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(current, existingTask) {
|
||||
t.Fatal("cancellation sync must leave the existing purchase task unchanged")
|
||||
}
|
||||
}
|
||||
valid := parseSYBIsCancelled(values) != nil
|
||||
for _, record := range records {
|
||||
got, _ := json.Marshal(record.SYBIsCancelled)
|
||||
want, _ := json.Marshal(tc.want)
|
||||
@@ -82,15 +145,25 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
if tc.want != nil && record.SYBStatusSyncedAt == nil {
|
||||
t.Fatal("known must have timestamp")
|
||||
}
|
||||
if !valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
|
||||
if !tc.valid && previous != nil && !record.SYBStatusSyncedAt.Equal(*previous) {
|
||||
t.Fatal("missing/invalid must preserve time")
|
||||
}
|
||||
if tc.valid && (record.SYBStatusSyncedAt.Before(beforeSync) || record.SYBStatusSyncedAt.After(afterSync)) {
|
||||
t.Fatal("valid values, including repeats, must refresh the sync timestamp")
|
||||
}
|
||||
if records[0].SYBStatusSyncedAt != nil && !record.SYBStatusSyncedAt.Equal(*records[0].SYBStatusSyncedAt) {
|
||||
t.Fatal("same order must use same timestamp for all details")
|
||||
}
|
||||
if record.PDDExcluded {
|
||||
t.Fatal("display status must not exclude procurement")
|
||||
}
|
||||
}
|
||||
if records[0].SYBStatusSyncedAt != nil && !records[0].SYBStatusSyncedAt.Equal(*records[1].SYBStatusSyncedAt) {
|
||||
t.Fatal("same order must use same timestamp")
|
||||
var other models.SYBProduct
|
||||
if err := db.First(&other, unrelated.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.SYBIsCancelled == nil || *other.SYBIsCancelled || other.SYBStatusSyncedAt == nil || !other.SYBStatusSyncedAt.Equal(untouchedTime) {
|
||||
t.Fatal("sync must not alter another order's cancellation status or timestamp")
|
||||
}
|
||||
previous = records[0].SYBStatusSyncedAt
|
||||
}
|
||||
@@ -116,7 +189,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Data.Items) != 2 {
|
||||
if len(payload.Data.Items) != 4 {
|
||||
t.Fatal("list count")
|
||||
}
|
||||
for _, item := range payload.Data.Items {
|
||||
@@ -135,7 +208,7 @@ func TestSyncCancellationDisplayAndListContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", "/products/1", nil))
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", fmt.Sprintf("/products/%d", firstDetailID), nil))
|
||||
if response.Code != 200 {
|
||||
t.Fatalf("detail: %d", response.Code)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func (handler Handler) AdminList(c *gin.Context) {
|
||||
@@ -176,7 +177,7 @@ func (handler Handler) AdminCancel(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.Cancel(c.Request.Context(), id, request)
|
||||
response, err := service.CancelOwnedBy(c.Request.Context(), id, request, ownerScope(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -194,7 +195,7 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.BatchCancel(c.Request.Context(), request)
|
||||
response, err := service.BatchCancelOwnedBy(c.Request.Context(), request, ownerScope(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -202,6 +203,37 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
|
||||
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 {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
|
||||
@@ -13,6 +13,20 @@ import (
|
||||
|
||||
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.
|
||||
type CancelResponse struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
@@ -31,6 +45,9 @@ type BatchCancelRequest struct {
|
||||
// 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用
|
||||
// 「筛选 + 一个按钮」想避免的事(#298)。
|
||||
GoodsID string `json:"goodsId,omitempty"`
|
||||
// DeviceID 按设备限定取消范围(#387)。管理员可不传(范围与之前一致,含
|
||||
// 未指定设备的任务);非管理员必须传,且必须是归属自己的设备。
|
||||
DeviceID uint64 `json:"deviceId,omitempty"`
|
||||
}
|
||||
|
||||
type BatchCancelSkippedItem struct {
|
||||
@@ -57,6 +74,13 @@ type BatchCancelResponse struct {
|
||||
// failure #292 already paid for once. The semantics are "stop what has not
|
||||
// started, let what is running finish".
|
||||
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 {
|
||||
return CancelResponse{}, err
|
||||
}
|
||||
@@ -69,6 +93,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
||||
}
|
||||
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 {
|
||||
response = CancelResponse{TaskID: taskID, Cancelled: true}
|
||||
return nil
|
||||
@@ -88,11 +121,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
|
||||
// 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。
|
||||
// RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。
|
||||
now := service.Now()
|
||||
result := 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).
|
||||
Updates(map[string]any{
|
||||
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
|
||||
})
|
||||
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)
|
||||
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,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
@@ -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) {
|
||||
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)
|
||||
if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch {
|
||||
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 != "" {
|
||||
query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%")
|
||||
}
|
||||
if request.DeviceID > 0 {
|
||||
query = query.Where("device_id = ?", request.DeviceID)
|
||||
}
|
||||
var candidates []models.CollectionTask
|
||||
// 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。
|
||||
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
|
||||
case device.CodeTokenInvalid:
|
||||
status = http.StatusUnauthorized
|
||||
case device.CodeDeviceDisabled:
|
||||
case device.CodeDeviceDisabled, CodeForbidden:
|
||||
status = http.StatusForbidden
|
||||
case CodeTaskNotFound:
|
||||
status = http.StatusNotFound
|
||||
|
||||
@@ -159,6 +159,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
|
||||
continue
|
||||
}
|
||||
// #340: hard-exclude before grouping/creating any image search task.
|
||||
// #381: cancelled SYB order, same hard block.
|
||||
if row.SYBIsCancelled != nil && *row.SYBIsCancelled {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_SYB_CANCELLED", Message: "SYB 订单已取消,无需采购"})
|
||||
continue
|
||||
}
|
||||
if row.PDDExcluded {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"})
|
||||
continue
|
||||
|
||||
@@ -42,3 +42,34 @@ func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) {
|
||||
t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// #381: a cancelled SYB order is rejected per row with PURCHASE_SYB_CANCELLED
|
||||
// without fetching an image or creating a task.
|
||||
func TestBatchCreateImageSearchRejectsCancelledRow(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
rule := models.CollectionRule{Name: "image-search-cancelled", ContentJSON: v2TaskRuleSnapshot()}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "cancel-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancelled := true
|
||||
syb := models.SYBProduct{OrderCode: "ORD-CANCEL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/c.jpg", ParseStatus: "success", RawJSON: "{}", SYBIsCancelled: &cancelled}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
|
||||
t.Fatalf("must not fetch image for a cancelled row")
|
||||
return ImageSearchImage{}, nil
|
||||
}
|
||||
response, err := service.BatchCreateImageSearch(context.Background(), ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.SuccessCount != 0 || len(response.Items) != 1 || response.Items[0].Code != "PURCHASE_SYB_CANCELLED" {
|
||||
t.Fatalf("expected PURCHASE_SYB_CANCELLED, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
DefaultLeaseDuration = 2 * time.Minute
|
||||
DefaultTaskTimeout = 120
|
||||
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
CodeTaskNotFound = "TASK_NOT_FOUND"
|
||||
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
|
||||
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), MigratePurchaseTaskCreator)
|
||||
}
|
||||
|
||||
// MigratePurchaseTaskCreator only adds the #382 creator columns to
|
||||
// purchase_task: nullable created_by (indexed) and created_source defaulting
|
||||
// to the empty string. Existing rows are not rewritten or backfilled. Each step is guarded so
|
||||
// a partial run (MySQL DDL commits implicitly) can be re-run safely.
|
||||
func MigratePurchaseTaskCreator(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
for _, column := range []string{"CreatedBy", "CreatedSource"} {
|
||||
if !m.HasColumn(&models.PurchaseTask{}, column) {
|
||||
if err := m.AddColumn(&models.PurchaseTask{}, column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !m.HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") {
|
||||
if err := m.CreateIndex(&models.PurchaseTask{}, "CreatedBy"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestMigratePurchaseTaskCreatorAddsColumnsKeepsLegacyRowsAndIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, _ := db.DB()
|
||||
defer conn.Close()
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text NOT NULL)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("INSERT INTO purchase_task(id,status) VALUES(1,'failed')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = MigratePurchaseTaskCreator(db, "test_382"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, column := range []string{"CreatedBy", "CreatedSource"} {
|
||||
if !db.Migrator().HasColumn(&models.PurchaseTask{}, column) {
|
||||
t.Fatalf("missing column %s", column)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") {
|
||||
t.Fatal("missing created_by index")
|
||||
}
|
||||
var row struct {
|
||||
Status string
|
||||
CreatedBy *uint64
|
||||
CreatedSource string
|
||||
}
|
||||
if err = db.Table("purchase_task").Select("status, created_by, created_source").Where("id = 1").Scan(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Status != "failed" || row.CreatedBy != nil || row.CreatedSource != "" {
|
||||
t.Fatalf("legacy row changed: %+v", row)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&common.Migration{}).Where("version = ?", "test_382").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("version not idempotent")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function listPurchaseTasks(params) {
|
||||
}
|
||||
})
|
||||
}
|
||||
export function getPurchaseTaskFilterOptions() { return request({ url: '/api/admin/v1/purchase-tasks/filter-options', method: 'get' }) }
|
||||
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
|
||||
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
|
||||
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<div v-for="m in group.modules" :key="m.key" class="module-row">
|
||||
<div class="module-main"><el-checkbox :model-value="!!grants[m.key]" :disabled="readonly || saving" @change="value => selectModule(m.key, value)">{{ m.title }}</el-checkbox><el-radio-group v-if="grants[m.key]" v-model="grants[m.key].write" :disabled="readonly || saving" size="small" :aria-label="`${m.title}访问方式`"><el-radio-button :value="false">只读</el-radio-button><el-radio-button :value="true" :disabled="!m.writable">读写</el-radio-button></el-radio-group></div>
|
||||
<div v-if="grants[m.key] && m.actions.length" class="actions"><span class="muted">独立动作:</span><el-checkbox-group v-model="grants[m.key].actions" :disabled="readonly || saving"><el-checkbox v-for="action in m.actions" :key="action" :value="action">{{ actionName(action) }}</el-checkbox></el-checkbox-group></div>
|
||||
<p v-if="grants[m.key] && grants[m.key].actions.includes('release_activate')" class="risk" role="note">设为当前后,所有设备会提示更新</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -51,7 +52,7 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listClientKeys, clientKeyModules, createClientKey, editClientKey, disableClientKey } from '@/api/goauto/client-keys'
|
||||
|
||||
const actionLabels = { sync: '立即同步', import: '导入', match: '匹配 / 确认', collect: '创建 / 重新采集', purchase: '创建 / 重试采购', writeback: '回写', delete: '删除', reparse: '重新解析', activate: '设置当前规则' }
|
||||
const actionLabels = { sync: '立即同步', import: '导入', match: '匹配 / 确认', collect: '创建 / 重新采集', purchase: '创建 / 重试采购', writeback: '回写', delete: '删除', reparse: '重新解析', activate: '设置当前规则', release_upload: '上传 Agent 版本', release_activate: '设置当前 Agent 版本' }
|
||||
const snapshot = grants => JSON.stringify(Object.values(grants).map(g => ({ ...g, actions: [...g.actions].sort() })).sort((a, b) => a.module.localeCompare(b.module)))
|
||||
const errorText = error => error.response?.data?.message || '请求失败,请检查网络后重试'
|
||||
|
||||
@@ -114,6 +115,7 @@ h2 { margin: 0 0 12px; }
|
||||
.module-row { padding: 10px 0; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.module-main { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.actions { padding: 4px 0 0 22px; }
|
||||
.risk { margin: 4px 0 0; padding-left: 22px; color: var(--el-color-warning); font-size: 13px; }
|
||||
.notice, .el-pagination { margin: 16px 0; }
|
||||
.validation { color: var(--el-color-danger); }
|
||||
.secret { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; background: var(--el-fill-color-light); }
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
<el-form-item label="Goods ID"><el-input v-model="query.goodsId" clearable placeholder="输入 goods_id" @keyup.enter="handleQuery" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="来源"><el-select v-model="query.source" clearable placeholder="全部来源" style="width:160px"><el-option v-for="item in sources" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="设备"><el-select v-model="query.deviceId" clearable filterable placeholder="全部设备" style="width:160px"><el-option v-for="item in deviceFilterOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</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>
|
||||
<el-table v-loading="loading" :data="tasks" border stripe empty-text="暂无采集任务">
|
||||
@@ -46,7 +47,7 @@
|
||||
|
||||
<el-dialog v-model="cancelBatch.open" title="取消未开始的任务" width="680px" :close-on-click-modal="false">
|
||||
<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" />
|
||||
<div v-loading="cancelBatch.previewLoading">
|
||||
<p>共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:</p>
|
||||
@@ -103,47 +104,62 @@ export default {
|
||||
name: 'GoAutoCollectionTasks', setup() { return { Plus, RefreshLeft, Search } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, tasks: [], total: 0, query: { page: 1, pageSize: 20, goodsId: '', status: '', source: '' },
|
||||
loading: false, tasks: [], total: 0, query: { page: 1, pageSize: 20, goodsId: '', status: '', source: '', deviceId: null },
|
||||
statuses: [{ value: 'pending', label: '待执行' }, { value: 'running', label: '执行中' }, { value: 'completed', label: '已完成' }, { value: 'completed_partial', label: '部分完成' }, { value: 'failed', label: '失败' }, { value: 'cancelled', label: '已取消' }],
|
||||
sources: [{ value: 'admin', label: '手动创建' }, { value: 'agent_current_page', label: '拍照临时采集' }, { value: 'image_search', label: '图搜采集' }],
|
||||
createDialog: { open: false, saving: false }, form: { pddProductId: null, ruleId: null, deviceId: null },
|
||||
formRules: { pddProductId: [{ required: true, message: '请选择 PDD 商品', trigger: 'change' }], ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
options: { products: [], rules: [], devices: [] }, detail: { open: false, loading: false, data: null },
|
||||
pendingCancelCount: 0,
|
||||
pendingCancelCount: 0, deviceFilterOptions: [],
|
||||
cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 }
|
||||
}
|
||||
},
|
||||
created() { this.getList(); this.refreshPendingCancelCount() },
|
||||
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() },
|
||||
watch: {
|
||||
// `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID
|
||||
// 那一维,按 goods_id 筛出两条、按钮却显示并取消三十几条——那正是当初
|
||||
// 放弃「两个固定按钮」、改用「筛选 + 一个按钮」想避免的事(#298)。
|
||||
'query.source'() { this.refreshPendingCancelCount() },
|
||||
'query.status'() { this.refreshPendingCancelCount() },
|
||||
'query.goodsId'() { this.refreshPendingCancelCount() }
|
||||
'query.goodsId'() { this.refreshPendingCancelCount() },
|
||||
'query.deviceId'() { this.refreshPendingCancelCount() }
|
||||
},
|
||||
methods: {
|
||||
async getList() { this.loading = true; try { const r = await listCollectionTasks(this.query); this.tasks = r.data.items; this.total = r.data.total } finally { this.loading = false } },
|
||||
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '', source: '' }; this.getList() },
|
||||
// 设备下拉含已停用设备;加载失败只留空下拉,不影响列表查询。
|
||||
async loadDeviceFilterOptions() { try { const r = await listDevices({ page: 1, pageSize: 100 }); this.deviceFilterOptions = r.data.items } catch { this.deviceFilterOptions = [] } },
|
||||
async getList() { this.loading = true; try { const r = await listCollectionTasks({ ...this.query, deviceId: this.query.deviceId || undefined }); this.tasks = r.data.items; this.total = r.data.total } finally { this.loading = false } },
|
||||
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '', source: '', deviceId: null }; this.getList() },
|
||||
statusLabel(value) { return this.statuses.find(item => item.value === value)?.label || value }, statusType(value) { return { pending: 'info', running: 'warning', completed: 'success', completed_partial: 'warning', failed: 'danger', cancelled: 'info' }[value] || 'info' },
|
||||
sourceLabel(value) { return this.sources.find(item => item.value === value)?.label || value || '' },
|
||||
isTerminal(value) { return ['completed', 'completed_partial', 'failed'].includes(value) }, specsText(specs) { return Object.entries(specs || {}).map(([key, value]) => `${key}: ${value}`).join(' / ') },
|
||||
// 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选
|
||||
// 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个
|
||||
// 与实际可取消数量无关的数字误导采购员。
|
||||
// #387:设备维度也参与取消范围;采购员必须先选设备,且服务端只允许取消本人设备的任务。
|
||||
cancelScopeStatus() { return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
|
||||
async refreshPendingCancelCount() {
|
||||
const status = this.cancelScopeStatus()
|
||||
if (!status) { this.pendingCancelCount = 0; return }
|
||||
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
|
||||
if (!status || this.cancelNeedsDevice) { this.pendingCancelCount = 0; return }
|
||||
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
|
||||
},
|
||||
async openCancelBatch() {
|
||||
if (this.cancelNeedsDevice) { ElMessage.warning('请先在「设备」筛选中选择要取消任务的设备'); return }
|
||||
const status = this.cancelScopeStatus()
|
||||
if (!status) return
|
||||
this.cancelBatch = { open: true, previewLoading: true, submitting: false, preview: [], previewTotal: 0 }
|
||||
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.previewTotal = r.data.total
|
||||
} finally { this.cancelBatch.previewLoading = false }
|
||||
@@ -153,7 +169,7 @@ export default {
|
||||
if (!status) return
|
||||
this.cancelBatch.submitting = true
|
||||
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('、')})` : ''
|
||||
ElMessage.success(`已取消 ${response.data.cancelledCount} 个任务${skippedText}`)
|
||||
if (response.data.hasMore) {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="设备"><el-select v-model="query.deviceId" clearable filterable placeholder="全部设备" style="width:170px"><el-option label="未分派" value="unassigned" /><el-option v-for="item in filterOptions.devices" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="创建人"><el-select v-model="query.createdBy" clearable filterable placeholder="全部创建人" style="width:170px"><el-option label="Agent 发起" value="agent" /><el-option label="未记录" value="unrecorded" /><el-option v-for="item in filterOptions.creators" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></el-form-item>
|
||||
@@ -30,6 +32,7 @@
|
||||
<el-table-column label="PDD 商品" min-width="190"><template #default="{ row }"><div class="primary ellipsis">{{ row.pddTitleSnapshot || '标题未记录' }}</div><div class="muted">goods_id {{ row.pddGoodsIdSnapshot || '—' }}</div></template></el-table-column>
|
||||
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
|
||||
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
|
||||
<el-table-column label="创建人" min-width="110"><template #default="{ row }">{{ creatorLabel(row) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
|
||||
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
|
||||
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div v-if="row.pddOrderNo" class="muted">{{ orderInformationComplete(row) ? '信息完整' : '订单信息待补全' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
|
||||
@@ -56,6 +59,7 @@
|
||||
<el-descriptions-item label="数量">{{ detail.task.quantity }} 件</el-descriptions-item><el-descriptions-item label="映射来源">{{ specSourceLabel(detail.task.specSource) }} <el-button v-if="detail.task.shopeeProductId" type="primary" link @click="openShopeeMapping">查看映射</el-button></el-descriptions-item>
|
||||
<el-descriptions-item label="允许单价">{{ priceText(detail.task.minUnitPriceCent, detail.task.currency) }} ~ {{ priceText(detail.task.maxUnitPriceCent, detail.task.currency) }}</el-descriptions-item><el-descriptions-item label="参考单价">{{ priceText(detail.task.referenceUnitPriceCent, detail.task.currency) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="设备">{{ detail.task.deviceName || '尚未分派' }}</el-descriptions-item><el-descriptions-item label="地址后缀">{{ detail.task.addressSuffix || '未生成' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人">{{ creatorLabel(detail.task) }}</el-descriptions-item><el-descriptions-item label="创建来源">{{ createdSourceLabel(detail.task.createdSource) }}</el-descriptions-item>
|
||||
</el-descriptions></section>
|
||||
|
||||
<section v-if="detail.matching"><h3>规格匹配</h3>
|
||||
@@ -153,7 +157,7 @@
|
||||
import { parsePurchaseOrderFilter } from '@/utils/purchase-order-filter'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
|
||||
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, getPurchaseTaskFilterOptions, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { saveExcel } from '@/utils/save-excel'
|
||||
import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, PURCHASE_EXPORT_NUMBER_COLS, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export'
|
||||
@@ -175,7 +179,8 @@ export default {
|
||||
exporting: false, exportProgress: { done: 0, steps: 0 },
|
||||
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
|
||||
orderWriteback: { open: false, saving: false, items: [] },
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] },
|
||||
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' },
|
||||
filterOptions: { devices: [], creators: [] },
|
||||
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
|
||||
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
|
||||
retrySelection: [],
|
||||
@@ -214,6 +219,7 @@ export default {
|
||||
beforeUnmount() { this.clearSnapshots() },
|
||||
deactivated() { this.detail.open = false; this.clearSnapshots() },
|
||||
created() {
|
||||
this.loadFilterOptions()
|
||||
const taskId = Number(this.$route.query.taskId)
|
||||
if (Number.isInteger(taskId) && taskId > 0) {
|
||||
this.query.taskId = String(taskId)
|
||||
@@ -241,7 +247,17 @@ export default {
|
||||
return params
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' }; this.load() },
|
||||
// #382: options only list devices/creators that appear on purchase tasks; failure leaves the static options usable.
|
||||
async loadFilterOptions() {
|
||||
try { const r = await getPurchaseTaskFilterOptions(); this.filterOptions = { devices: r.data.devices || [], creators: r.data.creators || [] } } catch (error) { this.filterOptions = { devices: [], creators: [] } }
|
||||
},
|
||||
createdSourceLabel(source) { return { admin: '管理端', client_api: '客户端接口', agent: 'Agent' }[source] || '未记录' },
|
||||
creatorLabel(task) {
|
||||
if (task.createdSource === 'agent') return 'Agent'
|
||||
if (!task.createdSource) return '未记录'
|
||||
return task.createdByName || (task.createdBy ? `用户 #${task.createdBy}` : '未记录')
|
||||
},
|
||||
// #372: export every page of the filters as they are at click time (params are copied once).
|
||||
async exportExcel() {
|
||||
if (this.exporting || this.orderFilter.error) return
|
||||
|
||||
@@ -449,7 +449,7 @@ export default {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }] },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }, { value: 'pdd_excluded', label: '无需采购' }, { value: 'syb_cancelled', label: '已取消' }] },
|
||||
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.isAIMatchCandidate(row)) },
|
||||
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||
@@ -572,11 +572,11 @@ export default {
|
||||
if (this.query.page !== this.loadedPage || this.query.pageSize !== this.loadedPageSize) this.load()
|
||||
}, 0)
|
||||
},
|
||||
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
|
||||
// #340/#381: 选择「退货待确认/已用退货/无需采购/已取消」这些不属于「需 PDD 采购」默认范围
|
||||
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
|
||||
// 的 AND 组合出空列表;用户仍可再手动切回。
|
||||
onProcessStageChange(value) {
|
||||
if (['return_pending', 'return_used', 'pdd_excluded'].includes(value) && this.query.purchaseType !== 'all') {
|
||||
if (['return_pending', 'return_used', 'pdd_excluded', 'syb_cancelled'].includes(value) && this.query.purchaseType !== 'all') {
|
||||
this.query.purchaseType = 'all'
|
||||
}
|
||||
},
|
||||
@@ -614,10 +614,12 @@ export default {
|
||||
// 图搜采集的用途就是给未关联 PDD 的虾皮商品找到 PDD 商品,所以它的可勾选条件
|
||||
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
|
||||
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
isImageSearchCandidate(row) { return !this.isActivelyReturnMatched(row) && !this.isPDDExcluded(row) && !this.isSYBCancelled(row) && Boolean(row.shopeeProductId && row.imageUrl) },
|
||||
// #340: pdd_purchase_excluded 商品仍可参与退货匹配(服务端 returnmatch 把
|
||||
// pdd_excluded 加入了 participatingStages),但不参与其他任何 PDD 相关操作。
|
||||
isPDDExcluded(row) { return this.purchaseReady(row).processStage === 'pdd_excluded' },
|
||||
// #381: SYB 订单已取消的明细不进入任何 PDD 流程(也不参与退货匹配)。
|
||||
isSYBCancelled(row) { return this.purchaseReady(row).processStage === 'syb_cancelled' },
|
||||
// #338/#340: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
|
||||
isReturnMatchCandidate(row) {
|
||||
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready', 'pdd_excluded'])
|
||||
@@ -635,7 +637,7 @@ export default {
|
||||
return `${item.excludedRuleKind === 'char' ? '结构' : '关键词'} ${item.excludedRuleKeyword}`
|
||||
},
|
||||
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }, pdd_excluded: { label: '无需采购', type: 'info' }, syb_cancelled: { label: '已取消', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
|
||||
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
|
||||
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||
|
||||
@@ -59,3 +59,8 @@ test('late creation response after navigation does not retain a secret', async()
|
||||
resolve({ data: { secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }}); await pending
|
||||
expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
|
||||
})
|
||||
test('agent release actions have distinct labels', () => {
|
||||
const p = vm()
|
||||
expect(p.actionName('release_upload')).toBe('上传 Agent 版本')
|
||||
expect(p.actionName('release_activate')).toBe('设置当前 Agent 版本')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user