Compare commits

..
Author SHA1 Message Date
QiuSW 7ea34e32c1 fix(android): restore address evidence after returning to spec panel #246 2026-09-09 09:48:10 +08:00
QiuSW d1ddb04959 docs: record exact spec availability diagnostics #245 2026-09-09 09:24:48 +08:00
QiuSW ca815c30de fix(android): clarify entry recovery and unavailable exact specs #245 2026-09-09 09:21:27 +08:00
QiuSW 66e0bdfc07 docs: document same-card selection exception (#244) 2026-09-08 18:12:12 +08:00
QiuSW 93aab6a59b fix(android): ignore selected duplicates within exact color card (#244) 2026-09-08 18:07:06 +08:00
QiuSW ec4802dfe7 docs: define scoped purchase entry recovery (#243) 2026-09-08 17:46:31 +08:00
QiuSW ec8b14ae21 fix(android): recover spec entry despite product page animation (#243) 2026-09-08 17:42:05 +08:00
QiuSW 7ac1355a71 docs(agent): document on-demand spec panel scrolling and diagnostics (#238) 2026-09-07 21:36:17 +08:00
QiuSW 58a6c1c86d fix(agent): replace mandatory spec panel preswipe with on-demand search (#238) 2026-09-07 21:33:31 +08:00
QiuSW 9d5242b371 docs(local): record verified client key migration and service startup (#237) 2026-09-07 17:45:38 +08:00
QiuSW 9a10d82cf4 chore(local): run GoAuto from main runtime with explicit migrations (#237) 2026-09-07 17:41:34 +08:00
QiuSW ac3c63ead6 docs(client-api): record approved HTTP compatibility (#237) 2026-09-07 17:29:28 +08:00
QiuSW 71f7751754 fix(client-api): support HTTP and HTTPS by default (#237) 2026-09-07 17:25:55 +08:00
QiuSW 76c94e33e0 docs(client-api): sync key contracts and deployment prerequisites (#237) 2026-09-07 17:15:02 +08:00
QiuSW 57b0f1595f feat(client-api): add scoped editable client keys (#237) 2026-09-07 16:20:46 +08:00
QiuSW 29ba16e4f9 feat(syb): allow purchaser manual sync (#236) 2026-09-07 15:03:31 +08:00
QiuSW ff2d0ca0e5 fix(syb): recover bounded today pagination overlaps (#235) 2026-09-07 14:25:47 +08:00
48 changed files with 2353 additions and 99 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 72
versionName = "0.9.59"
versionCode = 77
versionName = "0.9.64"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -434,8 +434,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} else FreshActionResult.FAILED
}
private var lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = lastPurchaseSwipeFailure
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
val root = rootInActiveWindow ?: return false
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
val root = rootInActiveWindow ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
return false
}
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
@@ -444,8 +452,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
} ?: return false
return swipeNode(target, direction, durationMs, preferScrollAction = false)
} ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
return false
}
return swipeNode(target, direction, durationMs, preferScrollAction = false,
onFailure = { lastPurchaseSwipeFailure = it })
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
@@ -646,16 +658,21 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
preferScrollAction: Boolean = true,
downStartPercent: Int = 25,
downEndPercent: Int = 75,
onFailure: (PurchaseSwipeFailureReason) -> Unit = {},
): Boolean {
fun failed(reason: PurchaseSwipeFailureReason): Boolean {
onFailure(reason)
return false
}
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return false
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
} else {
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
}
if (preferScrollAction && node.performAction(scrollAction)) return true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
@@ -693,8 +710,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
},
null,
)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
if (!queued) return failed(PurchaseSwipeFailureReason.GESTURE_REJECTED)
if (!latch.await(1500, TimeUnit.MILLISECONDS)) return failed(PurchaseSwipeFailureReason.GESTURE_TIMEOUT)
return completed.get() || failed(PurchaseSwipeFailureReason.GESTURE_CANCELLED)
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
@@ -491,8 +491,11 @@ class PurchaseLiveAutomation(
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix) || isPurchaseConfirmationPanel(it)
}
if (!hasFinalSavedAddressEvidence(returned, expected, suffix)) {
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
}
}
}
@@ -3,6 +3,11 @@ package cn.ilapage.goauto.agent.automation
import java.net.URI
import java.net.URLDecoder
enum class PurchaseSwipeFailureReason {
UNKNOWN, ROOT_UNAVAILABLE, NO_SCROLLABLE, INVALID_BOUNDS, GESTURE_UNSUPPORTED,
GESTURE_REJECTED, GESTURE_CANCELLED, GESTURE_TIMEOUT,
}
interface PurchaseUiDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
@@ -32,6 +37,7 @@ interface PurchaseUiDriver {
fun specRowSwipeFailureReason(): String = "gestureFailed"
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
fun backPurchase(): Boolean
@@ -89,6 +95,8 @@ class PurchaseRehearsalExecutor(
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
private var entryRecoveryReason = "not_checked"
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
@@ -370,7 +378,9 @@ class PurchaseRehearsalExecutor(
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val beforeIdentity = pageIdentity
val click = driver.clickFreshDetailed(requireNotNull(target))
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
@@ -385,14 +395,22 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
if (refreshed.specPanelOpen) return null
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
if (recoveryTarget == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
when (gesture) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -406,7 +424,7 @@ class PurchaseRehearsalExecutor(
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -418,6 +436,48 @@ class PurchaseRehearsalExecutor(
)
}
// Whole-page animations are not proof of navigation. Recover only a freshly
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
private fun recoverableSpecEntry(
before: ParsedPddScreen,
after: ParsedPddScreen,
action: PurchaseAction,
identity: Pair<String?, String?>,
): SnapshotNode? {
entryRecoveryReason = when {
identity.first == null || identity.second == null -> "identity_missing"
identity != pageIdentity -> "identity_changed"
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
after.problem != null || after.reviewPageOpen -> "page_problem"
after.specPanelOpen -> "panel_open"
after.hasCloseControl -> "close_control"
after.hasPaymentArea -> "payment_area"
after.hasOrderSubmitAction -> "order_action"
after.hasQuantityControls -> "quantity_controls"
after.hasSelectionSummary -> "selection_summary"
before.summary.title.isNullOrBlank() -> "title_missing"
before.summary.title != after.summary.title -> "title_changed"
after.specEntry == null -> "entry_missing"
before.specEntrySource == null -> "entry_source_missing"
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
else -> "eligible"
}
if (entryRecoveryReason != "eligible") return null
val anchor = after.specEntry ?: return null
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
entryRecoveryReason = "rule_alias_mismatch"
return null
}
val target = after.specEntryClickTarget ?: anchor
if (!target.visible || !target.enabled) {
entryRecoveryReason = "entry_unavailable"
return null
}
return target
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
@@ -543,6 +603,7 @@ class PurchaseRehearsalExecutor(
): PurchaseExecutionOutcome? {
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
unavailableExactSpec(initial, dimension, target)?.let { return it }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
val lookup = locateExactSpec(input, dimension, target)
@@ -617,7 +678,9 @@ class PurchaseRehearsalExecutor(
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes)
}
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
@@ -791,6 +854,18 @@ class PurchaseRehearsalExecutor(
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
if (!screen.specPanelOpen) return null
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
}
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
val role = if (dimension == "color") "颜色" else "尺码"
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
}
/**
* Searches only parsed, selectable values in the already-open spec panel.
* A short downward pass first restores the top when a previous action left
@@ -827,7 +902,7 @@ class PurchaseRehearsalExecutor(
}
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
@@ -892,7 +967,7 @@ class PurchaseRehearsalExecutor(
val lookup = when {
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
else -> null
}
val rows = specValueRows(values)
@@ -1094,16 +1169,17 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// The stock purchase rule asks to reveal additional selector rows after
// opening the sheet. A fully-evidenced non-scrollable selector has no
// scroll target, and treating that absence as an action failure blocks
// an otherwise safe exact-spec flow. Keep all other configured swipes
// mandatory; this exception is limited to that confirmed panel state.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
) return null
// Legacy rules prescribe a blind scroll immediately after opening.
// Opening has already been verified; probe/select owns any necessary
// bounded scrolling. Do not let an unnecessary gesture block either
// phase, or move already-visible exact specs out of the viewport.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL) {
panelDiagnostic("postSwipe=skipped;action=${action.type.wireName};reason=spec_panel_on_demand;panel=${currentScreen(input).specPanelType.name}")
return null
}
repeat(swipe.count) { index ->
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
panelDiagnostic("postSwipe=failed;action=${action.type.wireName};direction=${swipe.direction.name};swipeIndex=${index + 1};reason=${driver.purchaseSwipeFailureReason().name.lowercase()}")
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
}
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
@@ -1127,6 +1203,7 @@ class PurchaseRehearsalExecutor(
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
val snapshot = driver.capture()
pageIdentity = snapshot.packageName to snapshot.activityName
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
screen.specPanelContainer?.let {
@@ -0,0 +1,43 @@
package cn.ilapage.goauto.agent.automation
/** A narrow exception for one selected text option duplicated by its card/image. */
internal object SelectedColorCard {
fun confirms(target: String, values: List<VisibleSpecValue>, source: List<SnapshotNode>): Boolean {
val exact = values.filter { it.text == target }.singleOrNull() ?: return false
if (!exact.node.selected && !exact.node.checked) return false
val byPath = source.associateBy { it.path }
fun contains(outer: NodeBounds, inner: NodeBounds) =
inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top &&
inner.right <= outer.right && inner.bottom <= outer.bottom
fun cardFor(node: SnapshotNode): SnapshotNode? {
var current = node
val seen = mutableSetOf<String>()
while (seen.add(current.path)) {
if (current.scrollable || !current.visible || !current.enabled) return null
val parent = current.parentPath?.let(byPath::get) ?: return null
if (parent.scrollable) {
return current.takeIf {
it.clickable && it.className == "android.view.ViewGroup" &&
contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds)
}
}
current = parent
}
return null
}
val card = cardFor(exact.node) ?: return false
// The parser may have deduplicated identical labels across cards. Check
// the source as well before treating a selected duplicate as harmless.
if (source.any { node ->
node.visible && node.enabled && node.clickable &&
SpecValueNormalizer.normalizeColor(node.label) == target &&
cardFor(node)?.let { it.path != card.path } == true
}) return false
return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other ->
val node = other.node
// Never merge another textual option, adjacent card, or scroll container.
(node.path == card.path || node.className == "android.widget.ImageView") &&
cardFor(node)?.path == card.path && contains(card.bounds, node.bounds)
}
}
}
@@ -478,6 +478,8 @@ class AgentForegroundService : Service() {
if (accessibility == null) {
PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启")
} else {
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
@@ -486,7 +488,9 @@ class AgentForegroundService : Service() {
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
panelDiagnostic = { evidence ->
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
},
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -141,6 +141,34 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into spec panel restores final evidence with one back`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
assertEquals("_cg81", final.addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into stuck spec panel fails without another back or submit`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(1, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
@@ -645,6 +673,7 @@ class PurchaseLiveAutomationTest {
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val backReturnsToSpecPanel: Boolean = false,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
@@ -863,6 +892,7 @@ class PurchaseLiveAutomationTest {
page = when (page) {
"chooser" -> "payment"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
"saved-transition" -> if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
else -> "confirmation"
}
return true
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseSwipeFailureReason
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
@@ -98,7 +99,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
var openCount = 0
@@ -112,7 +113,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(0, openCount)
assertEquals(2, driver.swipeCount)
assertEquals(0, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
@@ -764,7 +765,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
fun `open spec panel still skips legacy follow-up swipe for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -773,6 +774,86 @@ class PurchaseRehearsalExecutorTest {
assertEquals(0, driver.swipeCount)
}
@Test
fun `legacy panel preswipe cannot block visible specs in probe or purchase phase`() {
for (nonScrollable in listOf(false, true)) {
for (phase in listOf("spec_probe", "purchase")) {
val driver = FakePurchaseDriver(nonScrollablePanel = nonScrollable, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
var probeCount = 0
val raw = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":321")
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probeCount++; "{}" }, pause = pauses::add,
panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = phase), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, if (phase == "spec_probe") "spec_probe_completed" else "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
assertTrue(pauses.contains(321L))
assertFalse(pauses.contains(1000L))
assertTrue(diagnostics.any { it.contains("reason=spec_panel_on_demand") })
assertEquals(if (phase == "spec_probe") 1 else 0, probeCount)
assertEquals(if (phase == "spec_probe") 0 else 1, driver.clicked.count { it == "黑色" })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
}
}
}
@Test
fun `non panel mandatory swipes still execute and fail closed`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":2,\"durationMs\":500,\"intervalMs\":1000}")
for (succeeds in listOf(false, true)) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = succeeds)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = pauses::add, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(if (succeeds) "rehearsal_completed" else "failed", outcome.resultType)
assertEquals(if (succeeds) 2 else 1, driver.swipeCount)
assertEquals(succeeds, pauses.contains(1000L))
if (!succeeds) {
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.any { it.contains("action=selectSpec") && it.contains("reason=unknown") })
}
}
}
@Test
fun `quick and order confirmation selectors also skip legacy panel preswipe`() {
for (kind in listOf("QUICK_CONFIRMATION", "ORDER_CONFIRMATION")) {
val driver = FakePurchaseDriver(confirmationPanelKind = kind, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
var probed = false
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probed = true; "{}" }, pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
assertTrue(probed)
assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("postSwipe=skipped") && it.contains("panel=$kind") })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") || it == "现在买" })
}
}
@Test
fun `mandatory swipe diagnostics use fixed reasons without spec text`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500}")
for (reason in PurchaseSwipeFailureReason.values()) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false, purchaseSwipeFailure = reason)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.contains("postSwipe=failed;action=selectSpec;direction=UP;swipeIndex=1;reason=${reason.name.lowercase()}"))
assertFalse(diagnostics.any { it.contains("黑色") || it.contains("XL") })
}
}
@Test(expected = RuleValidationException::class)
fun `ignored legacy panel swipe still rejects invalid rule parameters`() {
PurchaseRuleParser.parse(rule().replace("\"count\":2", "\"count\":0"))
}
@Test
fun `unrecognized opened panel returns only scalar panel evidence`() {
val driver = FakePurchaseDriver(unrecognizedPanel = true)
@@ -802,6 +883,84 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.panel)
}
@Test
fun `selected color card duplicate does not trigger another selection tap`() {
val driver = FakePurchaseDriver(duplicateColorCardEvidence = true)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.specTapCount)
}
@Test
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
}
}
@Test
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
@Test
fun `dynamic product with close control never receives recovery tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
}
@Test
fun `changed entry identity or unsafe page never receives recovery tap`() {
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
val diagnostics = mutableListOf<String>()
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(mode, "failed", outcome.resultType)
assertEquals(mode, 0, driver.specTapCount)
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
"activity" to "identity_changed", "payment" to "payment_area").getValue(mode)
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
}
}
@Test
fun `unavailable exact color stops without selection click or searching even when selected`() {
for (selected in listOf(null, "黑色")) {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
unavailableColors = setOf("黑色")).apply { color = selected }
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
assertFalse(driver.clicked.contains("黑色"))
assertEquals(0, driver.specTapCount)
assertEquals(0, driver.swipeCount)
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
assertFalse(logs.any { it.contains("黑色") })
}
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
@@ -1227,6 +1386,10 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val duplicateColorCardEvidence: Boolean = false,
private val dynamicProduct: Boolean = false,
private val productOverlay: Boolean = false,
private val afterEntryMutation: String = "",
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1244,10 +1407,13 @@ class PurchaseRehearsalExecutorTest {
private val pullDownSucceeds: Boolean = true,
private val loseEvidenceAfterPull: Boolean = false,
private val unavailableSizes: Set<String> = emptySet(),
private val unavailableColors: Set<String> = emptySet(),
allSpecsUnavailable: Boolean = false,
private val nonScrollablePanel: Boolean = false,
private val confirmationPanelKind: String? = null,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
private val purchaseSwipeFailure: PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN,
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
@@ -1278,6 +1444,7 @@ class PurchaseRehearsalExecutorTest {
private var horizontalColorPage = 0
private var horizontalSizePage = 0
private var capturesAfterSizeSelection = 0
private var entryAttempted = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -1340,6 +1507,8 @@ class PurchaseRehearsalExecutorTest {
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
if (bottomPurchaseEntry && specEntryReady) {
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
@@ -1348,6 +1517,12 @@ class PurchaseRehearsalExecutorTest {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
if (entryAttempted) {
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
if (afterEntryMutation == "payment") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
}
return UiSnapshot(PDD, ACTIVITY, nodes)
}
if (unrecognizedPanel) {
@@ -1390,6 +1565,16 @@ class PurchaseRehearsalExecutorTest {
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
}
visibleColors.forEachIndexed { index, value ->
if (duplicateColorCardEvidence && selectedColor == value) {
val cardPath = "scroll/card-$index"
nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550,
clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup")
nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView")
nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView")
return@forEachIndexed
}
nodes += node(
"scroll/color-$index",
value,
@@ -1399,7 +1584,7 @@ class PurchaseRehearsalExecutorTest {
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable,
enabled = !allSpecsUnavailable && value !in unavailableColors,
parentPath = colorParent,
)
}
@@ -1437,7 +1622,16 @@ class PurchaseRehearsalExecutorTest {
nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info")
else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true)
if (confirmationPanelKind != null) {
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
nodes += node("minus", "减少数量", 300, 800, 380, 870, clickable = true)
nodes += node("plus", "增加数量", 620, 800, 700, 870, clickable = true)
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
if (confirmationPanelKind == "QUICK_CONFIRMATION") {
nodes += node("quick", "现在买", 520, 2000, 1020, 2080, clickable = true)
}
}
nodes += node("order", "提交订单", 20, if (singleHeading || confirmationPanelKind != null) 2000 else 1100, 500, if (singleHeading || confirmationPanelKind != null) 2080 else 1180, clickable = true)
nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
@@ -1478,6 +1672,7 @@ class PurchaseRehearsalExecutorTest {
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
if (target.path in setOf("spec", "buy")) entryAttempted = true
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
val result = when (forcedEntryClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
@@ -1531,6 +1726,8 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = purchaseSwipeFailure
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
@@ -0,0 +1,47 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
class SelectedColorCardTest {
private val target = "黑色两件套 19"
private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true,
scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind,
NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true)
private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true)
private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup")
private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView")
private val text = node("s/c/t", "s/c", target, "android.widget.TextView")
private fun check(nodes: List<SnapshotNode>, candidates: List<SnapshotNode> = nodes.filter { it != scroll }) =
SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes)
@Test fun `selected target and own card image duplicates are one selection`() {
assertTrue(check(listOf(scroll, card, image, text)))
}
@Test fun `another selected card remains conflict`() {
val other = card.copy(path = "s/other")
assertFalse(check(listOf(scroll, card, image, text, other)))
}
@Test fun `two exact targets are not merged`() {
val otherCard = card.copy(path = "s/other", selected = false)
val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path)
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText)))
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text)))
}
@Test fun `unselected target cannot borrow selection from its container`() {
assertFalse(check(listOf(scroll, card, image, text.copy(selected = false))))
}
@Test fun `scrollable or missing card boundary cannot merge`() {
assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text)))
assertFalse(check(listOf(card, image, text)))
assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text)))
}
@Test fun `different text sibling is not treated as image duplicate`() {
assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView"))))
}
@Test fun `out of card bounds and parent cycle are rejected`() {
assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text)))
assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text)))
}
}
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
synchronized_at: 2026-09-05T07:16:39Z
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
synchronized_at: 2026-09-07T09:27:40Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -61,6 +61,7 @@ GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布
## 环境与凭据
- 服务端正式环境默认必须使用 HTTPS。仅当管理员接受 Device Token、任务内容和执行结果明文传输风险,并显式设置 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 时,Agent `/api/agent/v1/**` 可通过 HTTP;管理端和第三方服务不因此放宽。
- #237 客户端密钥例外(用户 2026-09-07 明确接受风险):提交 `71f7751` 起,管理员密钥管理及 `/api/client/v1` 默认兼容 HTTP/HTTPS,无开关;HTTP 明文传输密钥及业务数据,建议优先 HTTPS。实际迁移部署仍须单独授权;此例外不改变其他第三方服务的安全边界。
- 每台设备使用独立 Device Token;Token 只存安全配置,不进入仓库。
- PDD 账号密码、Cookie、验证码和用户个人数据不得进入日志。
- 根目录 `gitea.env` 是本机工单访问配置,已被 Git 忽略。
+21 -2
View File
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:50Z
wiki_revision: 20a1ca65dccde9ca1ecd93419cc1b164a5717fdf
synchronized_at: 2026-09-07T13:34:45Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -333,3 +333,22 @@ PddProductDetailCollector
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
## 客户端密钥访问架构(#237)
代码基线 `71f7751`(含用户确认的默认 HTTP/HTTPS 兼容),2026-09-07 完成 Server/Web 代码与隔离测试;本机迁移、管理员菜单写入与 Server/Web 启动已于 2026-09-07 授权完成,真实 HTTP 管理列表和模块目录加载通过;线上仍未部署。
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
- Admin 页面 `web/src/views/goauto/client-keys/index.vue` 复用创建/编辑授权弹窗;菜单位于“采采管理”,仅管理员可见。新追加迁移 `1788798000000_client_api_key.go` 创建两表及管理员菜单,不改 Android。
## 采购规格面板预滑动兼容(#238)
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
+64 -7
View File
@@ -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: 670a5592a6db8301cd115295bf820f7f4b6e06d7
synchronized_at: 2026-09-07T03:21:47Z
wiki_revision: fdcce403423aa799d1517ac75fb38da636ef5217
synchronized_at: 2026-09-09T01:22:58Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-07T03:21:47Z
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
@@ -67,13 +74,13 @@ synchronized_at: 2026-09-07T03:21:47Z
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,已于 2026-09-08 随 d403f3b 部署线上)。
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
## SYB 后台导入记录
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
- 导入允许管理员和采购员(purchaser)创建,创建成功后立即转入后台执行;页面关闭不取消任务。其他已登录角色只能查看同步记录,不能开始导入(#236)。
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
@@ -168,7 +175,7 @@ synchronized_at: 2026-09-07T03:21:47Z
- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。
- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
- AI 规格匹配菜单对采购员硬排除:采购员不显示该菜单,角色配置也不能为采购员选中该模块;既有 API 权限不变,仍只允许读取是否启用,不得读取 Provider 地址、模型、API Key,也不得保存或测试。
- GoAuto 菜单由代码维护的模块定义幂等写入系统菜单和菜单/API 关联;迁移只为采购员追加首次引入且默认开放的模块,不会把采购员人工取消勾选的既有模块重新加回。
- 采购员 Casbin API 白名单由代码权限矩阵全量重建,不从角色菜单勾选反推;减少权限时旧策略必须删除。菜单勾选只控制可见模块,不能扩大采购员 API 权限。
@@ -230,7 +237,7 @@ synchronized_at: 2026-09-07T03:21:47Z
- 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。
- 单次同步内部的 SYB 只读请求(货运单总数、列表和明细)遇到暂时网络故障、5xx 或异常响应时最多执行 3 次,分别退避 1 秒、2 秒,单次 HTTP 总超时 60 秒;这不等于失败任务自动重试。明确未登录、业务失败、数据完整性错误以及任何写操作均不自动重试。
- SYB 商品页不再提供“导入”和“同步记录”快捷按钮,也不查询、展示或轮询同步状态;后台同步成功、失败或中断均不在商品页弹出消息,商品数据由用户主动查询或刷新获取。
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员可在同步记录页人工发起覆盖昨天和今天的同步,采购员只读;人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员和采购员可在同步记录页人工发起覆盖昨天和今天的同步,其他角色只读(#236);人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
## cmautobuy 商品导入
@@ -397,7 +404,7 @@ synchronized_at: 2026-09-07T03:21:47Z
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
## PDD 商品反向关联与继续订单采购(#161)
@@ -444,3 +451,53 @@ synchronized_at: 2026-09-07T03:21:47Z
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
## 客户端密钥与可编辑模块授权(#237)
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,2026-09-08 已随 d403f3b 完成线上迁移与部署,真实客户端密钥执行闭环尚未验证。
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
- 勾选模块默认只读,至少保留一个模块。读写仅开放清单中的普通写操作;同步、采集、采购、删除、导入、重解析、匹配、回写及切换当前采购规则分别独立授权,默认关闭。没有普通写接口的模块不允许勾选读写。
- 编辑既有密钥不会更换密钥,名称和前缀只读;移除模块同时移除其动作。取消保留原授权;失败保留输入;版本冲突要求刷新重开。停用不可编辑或恢复。
- 保存后新请求按最新授权校验,已经通过校验的在途请求可能完成,不追溯回滚。模块授权不是行级、店铺级或租户隔离,授予读取即允许读取该模块明确接口可返回的业务范围。
- 用户于 2026-09-07 明确接受明文风险:密钥管理及客户端接口默认兼容 HTTP/HTTPS,无需开关。HTTP 会明文传输密钥与业务数据,建议优先使用 HTTPS;兼容不改变管理员身份与模块授权边界。
- 客户端与 Admin 登录 JWT、Agent Device Token 独立。响应排除凭据及原始载荷字段;AI 设置只返回 enabled,不开放 Provider 配置读写或连接测试。设备仅开放列表,不开放身份重置、令牌或解锁接口。
- 执行动作复用既有业务门禁、幂等参数及状态机;客户端采购 batch-retry 沿用 Admin 原有语义,不等同于 Agent 就地 reset。授权重采购、支付复核、取消订单等未列入接口不开放;永久禁止付款。
- 创建响应丢失时不可找回完整密钥,应核对列表并停用可能已创建的记录,再明确创建新密钥,不能盲目自动重试。完整密钥只在创建结果弹窗内存中显示,关闭或离开页面清空,不写浏览器持久存储。
## 打开采购规格面板后按需滚动(#238)
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
- 其他动作的后置滑动沿用原行为,必需滑动失败仍返回 RULE_ACTION_FAILED。旧规则 JSON 不回写、不迁移;原任务 ID、历史 attempt、商品与规格快照不变。旧 APK 行为不变,需升级 Agent;本单未改线上规则或执行 CG68 真机采购。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## 商品页动态变化下的采购入口恢复(#243)
实现 ec8b14a,Android 0.9.61/versionCode 74;已构建、未安装或真机验收。规格探测和正式采购共用入口函数,面板已打开时不新增点击;仍在同一商品上下文且安全入口存在时,一次重新定位手势恢复不再依赖整页完全静止。手势后仍未打开则报告入口无效果,不循环点击。恢复不能用于地址、创建订单、支付或未知面板,不修改面板分类器及正式下单边界。CG82/CG85 原首次点击未生效的底层原因尚待新日志和授权真机验证。
## 同一颜色卡片重复选中节点(#244)
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
## 精确规格不可用与入口恢复诊断(#245)
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
+113 -5
View File
@@ -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: f3242938de4ac55c47f5c6eebd69184024e6a0ea
synchronized_at: 2026-09-05T09:04:42Z
wiki_revision: 49dec4c35da793a32822a82e00f3b657757b6d44
synchronized_at: 2026-09-07T13:35:25Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -75,7 +75,7 @@ POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
`import` 仅允许管理员调用。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
@@ -472,7 +472,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|---|---:|---:|---:|---:|
| `openProduct` | 是 | 是 | 是 | 否 |
| `verifyProduct` | 是 | 是 | 否 | 否 |
| `openSpecPanel` | 是 | 是 | 是 | 否 |
| `openSpecPanel` | 是 | 是 | 兼容读取、不执行(0.9.60+,见 #238) | 否 |
| `selectSpec` | 是 | 是 | 是 | 否 |
| `setQuantity` | 是 | 是 | 否 | 否 |
| `verifyUnitPrice` | 是 | 是 | 否 | 否 |
@@ -486,7 +486,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
- 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。
- 动作 `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。只读识别字段使用独立校验:允许订单和支付证据,仍拒绝修改地址、收货地址,并沿用各字段声明的数量、长度、去重和空白限制。
- `waitAfterMs` 表示动作成功后的等待时间,范围为 0~30000 毫秒;省略时为 0。
- `swipeAfter` 表示动作成功后执行一个有限滑动计划。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
- `swipeAfter` 通常表示动作成功后执行一个有限滑动计划;Android 0.9.60+ 的 `openSpecPanel` 例外,只兼容读取而不执行预滑动(见 #238)。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
- 未在矩阵中授权的 action/参数组合、未知字段、空候选和越界值一律拒绝。`updateShippingAddress`、`createOrder`、`readOrderResult` 等正式动作在其独立高风险契约完成前不接受上述参数。
- 旧的仅含 `actions[].type` 的规则继续有效:候选使用 Agent 内置语义,等待为 0,不执行动作后滑动。
- 服务端保存创建任务时收到的完整原始规则快照;规则后来更新为规则 B,不会改变已有任务中的规则 A 快照。
@@ -867,3 +867,111 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
## 客户端 API 与管理员密钥管理(#237)
实现基线 `71f7751`;以下接口已通过隔离测试;2026-09-07 本机 MySQL 迁移及管理员通过 HTTP 读取列表、模块目录已验证,真实密钥创建/编辑/停用及业务访问仍未联调,线上尚未部署。Android 接口不变。
### 管理接口
前缀 `/api/admin/v1/client-keys`,要求 Admin JWT 和 admin 角色,默认接受 HTTP/HTTPS,响应 `Cache-Control: no-store`。
| 方法与相对路径 | 输入 | 成功 data |
|---|---|---|
| GET 空路径 | page,固定每页 20 | items、total、page、pageSize |
| GET /modules | 无 | 模块数组:key、title、group、writable、actions |
| POST 空路径 | name(1~80 字符)、grants | key 元数据与仅本次返回的 secret |
| PATCH /:keyId/grants | version、grants | 更新后的元数据 |
| POST /:keyId/disable | version | 停用后的元数据 |
授权示例:`{"module":"pdd_products","write":false,"actions":[]}`;grants 为非空数组。元数据包括 id、name、prefix、enabled、version、grants、createdBy、updatedBy、createdAt、updatedAt、lastUsedAt,不返回摘要或完整密钥。管理请求只接受单个 JSON 对象、拒绝未知字段、最大 64 KiB。成功 code=200;无效输入 422、不存在 404、授权版本冲突或已停用 409。
### 客户端访问与错误语义
- 前缀 `/api/client/v1`,只接受 `Authorization: Bearer <客户端密钥>`,不接受 Cookie 或 URL 凭据,不与 JWT、Device Token 通用。
- 根据用户 2026-09-07 的明确确认,管理与客户端接口在所有环境默认接受 HTTP/HTTPS,不设置协议开关,也不依赖 X-Forwarded-Proto 或 GOAUTO_TRUST_FORWARDED_PROTO。HTTP 明文传输密钥及业务数据,优先使用 HTTPS;不影响其他接口各自的协议要求。
- 不再因 HTTP 返回 426;401 为缺失、无效或停用密钥;403 为模块/动作未授权;400 为查询参数携带凭据;503 为认证或审计暂不可用。业务错误沿用各既有接口。
- 一般请求体最大 16 MiB;响应只支持有限 JSON(32 MiB),递归过滤凭据与原始载荷字段。不支持直接流式/二进制文件接口。响应不可序列化或超限时返回 502;业务可能已执行,必须先核对结果,不要自动重试。
- 通过密钥认证的请求由服务端生成 `X-Client-Request-Id` 关联审计;它不是业务幂等键,原业务接口要求的 requestId 等字段仍须提供。允许请求必须先落审计意图;完成状态更新失败可留下 status=0。无效密钥没有 key_id 关联审计;拒绝授权的 403 审计为尽力记录。
- GET `/ai-matching-settings` 只返回 `data.enabled`。POST `/ai-matching-settings/resolve` 接受 targetColor、targetSize、colors、sizes;每组最多 200 项,每个值最多 255 字符,总请求最大 64 KiB;确定性优先,必要时使用当前 Provider,返回 mappedColor、mappedSize、source;不保存映射、不创建任务,无法可靠匹配返回 422 安全提示。
### 明确开放的接口清单
下表路径均相对 `/api/client/v1`;普通业务请求字段沿用本文对应 Admin 业务契约。read=选中模块,write=模块读写,其他值均需 actions 逐项授权。没有列出的 Admin 接口不能用客户端密钥调用;新增 Admin 路由不会自动开放。
| 方法 | 路径 | 模块键 | 能力 |
|---|---|---|---|
| POST | `/ai-matching-settings/resolve` | ai_matching | match |
| GET | `/devices` | devices | read |
| GET | `/pdd-products` | pdd_products | read |
| GET | `/pdd-products/:productId` | pdd_products | read |
| POST | `/pdd-products` | pdd_products | write |
| PATCH | `/pdd-products/:productId` | pdd_products | write |
| GET | `/shopee-products` | shopee_products | read |
| GET | `/shopee-products/:productId` | shopee_products | read |
| POST | `/shopee-products` | shopee_products | write |
| PATCH | `/shopee-products/:productId` | shopee_products | write |
| POST | `/shopee-products/:productId/link-pdd` | shopee_products | write |
| POST | `/shopee-products/:productId/specs/values` | shopee_products | write |
| PUT | `/shopee-products/:productId/specs/mapping` | shopee_products | write |
| DELETE | `/shopee-products/:productId/specs/values` | shopee_products | delete |
| DELETE | `/shopee-products/:productId/specs/mapping` | shopee_products | delete |
| POST | `/shopee-products/batch-delete` | shopee_products | delete |
| POST | `/shopee-products/:productId/specs/mapping/auto-match` | shopee_products | match |
| POST | `/shopee-products/:productId/specs/mapping/confirm` | shopee_products | match |
| GET | `/syb-products` | syb_products | read |
| GET | `/syb-products/:productId` | syb_products | read |
| PATCH | `/syb-products/:productId/correction` | syb_products | write |
| POST | `/syb-products/:productId/reparse` | syb_products | reparse |
| POST | `/syb-products/reparse-batch` | syb_products | reparse |
| GET | `/syb-products/sync-runs` | syb_sync_runs | read |
| GET | `/syb-products/sync-runs/:runId` | syb_sync_runs | read |
| POST | `/syb-products/import` | syb_sync_runs | sync |
| GET | `/syb-inner-codes` | syb_inner_codes | read |
| GET | `/syb-inner-codes/:recordId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/match-jobs/:jobId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/apply-batches/:batchId` | syb_inner_codes | read |
| POST | `/syb-inner-codes/rematch` | syb_inner_codes | match |
| POST | `/syb-inner-codes/import` | syb_inner_codes | import |
| POST | `/syb-inner-codes/batch-delete` | syb_inner_codes | delete |
| POST | `/syb-inner-codes/apply-preview` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/apply` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/:recordId/recheck` | syb_inner_codes | match |
| GET | `/syb-shops` | syb_shops | read |
| POST | `/syb-shops` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/name` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/enabled` | syb_shops | write |
| DELETE | `/syb-shops/:shopId` | syb_shops | delete |
| GET | `/collection-rules` | collection_rules | read |
| POST | `/collection-rules` | collection_rules | write |
| PATCH | `/collection-rules/:ruleId` | collection_rules | write |
| DELETE | `/collection-rules/:ruleId` | collection_rules | delete |
| GET | `/purchase-rules` | purchase_rules | read |
| GET | `/purchase-rules/current` | purchase_rules | read |
| POST | `/purchase-rules` | purchase_rules | write |
| PATCH | `/purchase-rules/:ruleId` | purchase_rules | write |
| DELETE | `/purchase-rules/:ruleId` | purchase_rules | delete |
| PUT | `/purchase-rules/current` | purchase_rules | activate |
| GET | `/collection-tasks` | collection_tasks | read |
| GET | `/collection-tasks/:taskId` | collection_tasks | read |
| POST | `/collection-tasks` | collection_tasks | collect |
| POST | `/collection-tasks/batch` | collection_tasks | collect |
| POST | `/collection-tasks/:taskId/reset` | collection_tasks | collect |
| DELETE | `/collection-tasks/:taskId` | collection_tasks | delete |
| GET | `/purchase-tasks` | purchase_tasks | read |
| GET | `/purchase-tasks/:taskId` | purchase_tasks | read |
| POST | `/purchase-tasks/batch-preview` | purchase_tasks | purchase |
| POST | `/purchase-tasks` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch-retry` | purchase_tasks | purchase |
| POST | `/purchase-tasks/stock` | purchase_tasks | purchase |
| GET | `/ai-matching-settings` | ai_matching | read |
### openSpecPanel 后置滑动兼容与诊断(#238)
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。
+13 -2
View File
@@ -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: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
synchronized_at: 2026-09-04T11:30:50Z
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
synchronized_at: 2026-09-07T07:02:57Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -228,9 +228,20 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
### 4.4 统一日期范围同步与覆盖游标
GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)使用(#236),固定覆盖昨天和今天;其他已登录角色仅可查看记录。复用既有同步互斥、日期校验、启用店铺筛选及操作人审计,不授予店铺配置、凭据或定时任务管理权限。权限代码发布并完成启动对账后生效。
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面
固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时
由操作员明确选择日期,单次仍不得超过 31 天。
+25 -2
View File
@@ -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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:08Z
wiki_revision: f951dbc8b6a555cbf8f44cda073910406ba55191
synchronized_at: 2026-09-07T09:43:56Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -70,3 +70,26 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
## 客户端密钥部署与验证(#237)
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。线上仍未部署。
- 发布前须单独授权追加迁移 `server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go`,按既有迁移流程创建 client_api_key、client_api_key_audit 和“采采管理/客户端密钥”管理员菜单。前置父菜单必须存在;不应以赋予普通用户管理员角色代替迁移或权限核验。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。本机已部署验证,不能据此宣称线上已经可用。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
- 隔离验证:Server `go test ./app/goauto/clientkey ./app/goauto/clientapi ./cmd/migrate/migration/version-local`;Web `pnpm exec jest tests/unit/client-keys.spec.js --runInBand` 与 `pnpm run build:prod`。浏览器模拟入口 `/tests/fixtures/client-keys.html` 仅由本地 Vite 开发服务承载,使用内存模拟请求和无效示例密钥,不连接真实数据库,不证明线上鉴权已验收。
- 真实部署验收须另行验证实际 HTTP/HTTPS 入口、管理员创建/编辑/停用、普通用户拒绝、读写/独立动作隔离、停用后的后续请求拒绝及日志无密钥;任何真实业务执行继续按独立授权范围进行。
## Windows 本机运行目录与 #237 迁移验证(2026-09-07)
- Supervisor 实际配置 `D:/supervisor/programs/goauto.conf`;程序仅 `goauto-admin-api` 和 `goauto-admin-ui`。从已有干净工作区 `D:/OPC/goauto-worktrees/main-runtime` 的 main 分支运行;源码运行基线 `ac3c63e`,版本化启动模板更新提交 `9a10d82`。
- 原目录 `D:/OPC/goauto` 的用户改动保持原样;本机敏感配置继续读取 `D:/OPC/goauto/config.yaml`,不复制凭据到运行工作区或版本库。数据库 `127.0.0.1:3307/goauto`,API `http://127.0.0.1:8010`,Web `http://127.0.0.1:9527`。
- API 命令:`pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration`;Web 同目录 `scripts/start-web.ps1` 与相同 ConfigPath。已验证无需 ExecutionPolicy Bypass。
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 API 加 `-SkipMigration`,日常重启不自动执行未来待审核迁移。#237 迁移 `1788798000000_client_api_key.go` 已执行,sys_migration 记录 `1788798000000`、两张密钥表及管理员菜单已回读;其他角色未新增菜单关联。任务启停状态未改。
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。线上 GoAuto/Nginx 未重启或发布。
+4 -4
View File
@@ -1,6 +1,6 @@
[program:goauto-admin-api]
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
directory=D:/OPC/goauto
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-server.ps1" -ConfigPath "D:/OPC/goauto/config.yaml" -SkipMigration
directory=D:/OPC/goauto-worktrees/main-runtime
autostart=true
autorestart=true
startsecs=3
@@ -14,8 +14,8 @@ stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
[program:goauto-admin-ui]
command=pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "D:/OPC/goauto/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
directory=D:/OPC/goauto
command=pwsh.exe -NoLogo -NoProfile -File "D:/OPC/goauto-worktrees/main-runtime/scripts/start-web.ps1" -ConfigPath "D:/OPC/goauto/config.yaml"
directory=D:/OPC/goauto-worktrees/main-runtime
autostart=true
autorestart=true
startsecs=3
+2
View File
@@ -1,6 +1,7 @@
package router
import (
goautoclientapi "go-admin/app/goauto/clientapi"
"os"
"github.com/gin-gonic/gin"
@@ -53,6 +54,7 @@ func InitRouter() {
// 注册 GoAuto Agent 与设备管理路由。
goautodevice.InitRouter(r, authMiddleware)
goautoclientapi.InitRouter(r, authMiddleware)
goautoapprelease.InitRouter(r, authMiddleware)
goautoaimatching.InitRouter(r, authMiddleware)
goautotask.InitRouter(r, authMiddleware)
+1 -1
View File
@@ -56,7 +56,7 @@ var AdminAPIs = []APIPermission{
{"重新解析 SYB 商品", "/api/admin/v1/syb-products/:productId/reparse", "POST", true},
{"批量重新解析 SYB 商品", "/api/admin/v1/syb-products/reparse-batch", "POST", true},
{"人工修正 SYB 商品", "/api/admin/v1/syb-products/:productId/correction", "PATCH", true},
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", false},
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", true},
{"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true},
{"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true},
+1 -1
View File
@@ -16,7 +16,7 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
denied := map[string]bool{
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-products/import": true,
"POST /api/admin/v1/syb-shops/discover": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
+3 -1
View File
@@ -55,7 +55,9 @@ func TestReconcilePurchaserPermissions(t *testing.T) {
}
assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 1)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops/discover", "POST", 0)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops", "POST", 0)
}
func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) {
+158
View File
@@ -0,0 +1,158 @@
package clientapi
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"go-admin/app/goauto/clientkey"
"go-admin/common/clientprincipal"
"gorm.io/gorm"
)
// Both HTTP and HTTPS are supported by explicit operator decision (#237).
// Transport does not grant identity or permissions; secrets remain non-cacheable.
func NoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Next()
}
func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
deny := func(status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
// Never accept a credential supplied through a URL or cookie fallback.
for k := range c.Request.URL.Query() {
if sensitive(k) || strings.EqualFold(k, "token") {
deny(400, "密钥只能通过 Authorization 请求头发送")
return
}
}
header := strings.Fields(c.GetHeader("Authorization"))
if len(header) != 2 || !strings.EqualFold(header[0], "Bearer") {
deny(401, "需要客户端密钥")
return
}
connection := db
var err error
if connection == nil {
connection, err = pkg.GetOrm(c)
}
if err != nil || connection == nil {
deny(503, "客户端认证暂不可用")
return
}
key, err := (clientkey.Service{DB: connection}).Authenticate(c.Request.Context(), header[1])
if err != nil {
if errors.Is(err, clientkey.ErrCredential) {
deny(401, "客户端密钥无效或已停用")
} else {
deny(503, "客户端认证暂不可用")
}
return
}
random := make([]byte, 16)
if _, err = rand.Read(random); err != nil {
deny(503, "审计暂不可用")
return
}
requestID := hex.EncodeToString(random)
c.Header("X-Client-Request-Id", requestID)
record := clientkey.Audit{KeyID: key.ID, Event: "request", RequestID: requestID, Method: e.Method, Route: "/api/client/v1" + e.Path}
if !key.Allows(e.Module, e.Capability) {
record.Status = 403
_ = connection.Create(&record).Error
deny(403, "密钥未授权此模块或执行动作")
return
}
// The intent must be durable before any business handler can run.
if err = connection.WithContext(c.Request.Context()).Create(&record).Error; err != nil {
deny(503, "审计暂不可用,未执行操作")
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)
original := c.Writer
buffer := &responseBuffer{ResponseWriter: original, status: 200}
c.Writer = buffer
defer func() { c.Writer = original }()
c.Next()
c.Writer = original
status := buffer.status
var value any
if buffer.overflow || json.Unmarshal(buffer.body.Bytes(), &value) != nil {
status = 502
value = gin.H{"code": 502, "message": "无法生成客户端响应,请先核对操作结果,不要自动重试"}
} else {
value = redact(value)
}
auditCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = connection.WithContext(auditCtx).Model(&clientkey.Audit{}).Where("id = ?", record.ID).Update("status", status).Error
now := time.Now().UTC()
_ = connection.WithContext(auditCtx).Model(&clientkey.Key{}).Where("id = ?", key.ID).UpdateColumn("last_used_at", now).Error
c.JSON(status, value)
}
}
type responseBuffer struct {
gin.ResponseWriter
body bytes.Buffer
status int
overflow bool
}
func (w *responseBuffer) WriteHeader(status int) { w.status = status }
func (w *responseBuffer) WriteHeaderNow() {}
func (w *responseBuffer) Status() int { return w.status }
func (w *responseBuffer) Size() int { return w.body.Len() }
func (w *responseBuffer) Written() bool { return w.body.Len() > 0 }
func (w *responseBuffer) Write(b []byte) (int, error) {
if w.body.Len()+len(b) > 32<<20 {
w.overflow = true
return len(b), nil
}
return w.body.Write(b)
}
func (w *responseBuffer) WriteString(s string) (int, error) { return w.Write([]byte(s)) }
func (w *responseBuffer) Flush() {}
func sensitive(k string) bool {
key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(k, "_", ""), "-", ""))
for _, part := range []string{"password", "passwd", "secret", "credential", "cookie", "authorization", "apikey", "accesstoken", "devicetoken", "refreshtoken", "recoverycode"} {
if strings.Contains(key, part) {
return true
}
}
switch key {
case "token", "tokenhash", "digest", "rawjson", "rawpayload", "rawresponse", "requestheaders", "responseheaders":
return true
}
return false
}
func redact(v any) any {
switch value := v.(type) {
case map[string]any:
for k, item := range value {
if sensitive(k) {
delete(value, k)
} else {
value[k] = redact(item)
}
}
case []any:
for i, item := range value {
value[i] = redact(item)
}
}
return v
}
+165
View File
@@ -0,0 +1,165 @@
package clientapi
import (
"context"
"crypto/tls"
"encoding/json"
"github.com/gin-gonic/gin"
"go-admin/app/goauto/clientkey"
"go-admin/common/clientprincipal"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"net/http/httptest"
"strings"
"testing"
)
func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
sql.SetMaxOpenConns(1)
t.Cleanup(func() { sql.Close() })
if err = db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
t.Fatal(err)
}
return db, clientkey.Service{DB: db, Modules: Catalog(Inventory())}
}
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
db, s := fixture(t)
routes := Inventory()
if len(s.Modules) != 12 {
t.Fatal("menu groups lost")
}
router := gin.New()
seen := map[string]bool{}
for _, e := range routes {
key := e.Method + e.Path
if seen[key] {
t.Fatal("duplicate route")
}
seen[key] = true
if strings.Contains(e.Path, "payment") || strings.Contains(e.Path, "token") || strings.Contains(e.Path, "client-keys") || e.Handle == nil {
t.Fatal("forbidden route")
}
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { c.JSON(200, gin.H{"data": "ok"}) })
if e.Method != "GET" && e.Capability == "read" {
t.Fatal("mutating read permission")
}
}
for _, e := range routes {
for _, scheme := range []string{"http", "https"} {
grant := clientkey.Grant{Module: e.Module}
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
if err != nil {
t.Fatal(err)
}
path := e.Path
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
path = strings.ReplaceAll(path, param, "1")
}
req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 200
if e.Capability != "read" {
want = 403
}
if rr.Code != want {
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
}
if e.Capability == "write" {
grant.Write = true
} else if e.Capability != "read" {
grant.Actions = []string{e.Capability}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req.Clone(context.Background()))
if rr.Code != 200 {
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
}
}
}
}
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
db, s := fixture(t)
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
if err != nil {
t.Fatal(err)
}
e := Endpoint{Method: "GET", Path: "/pdd-products", Module: "pdd_products", Capability: "read"}
router := gin.New()
calls := 0
router.GET("/api/client/v1/pdd-products", Gate(db, e), func(c *gin.Context) {
calls++
id, ok := clientprincipal.Get(c)
if !ok || id.KeyID != v.ID {
t.Error("client attribution missing")
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"apiKey": "sentinel-secret", "rawJson": "sentinel-raw", "title": "product", "nested": []any{gin.H{"device_token": "sentinel-token"}}}})
})
send := func(tlsOn bool, credential, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "http://example.test"+path, nil)
if tlsOn {
req.TLS = &tls.ConnectionState{}
}
req.Header.Set("Authorization", "Bearer "+credential)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
path := "/api/client/v1/pdd-products"
for _, tlsOn := range []bool{false, true} {
if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
t.Fatal("credential fallback accepted")
}
rr := send(tlsOn, token, path)
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
t.Fatal("redaction failed")
}
if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
t.Fatal("cache or audit headers missing")
}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
t.Fatal(err)
}
if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
t.Fatal("revocation not immediate")
}
var logs []clientkey.Audit
db.Find(&logs)
raw, _ := json.Marshal(logs)
if strings.Contains(string(raw), token) || strings.Contains(string(raw), "sentinel") {
t.Fatal("audit leaked payload")
}
}
func TestAuditUnavailableDoesNotExecute(t *testing.T) {
db, s := fixture(t)
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
if err != nil {
t.Fatal(err)
}
db.Migrator().DropTable(&clientkey.Audit{})
router := gin.New()
called := false
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
router.POST(e.Path, Gate(db, e), func(c *gin.Context) { called = true; c.JSON(200, gin.H{}) })
req := httptest.NewRequest("POST", "https://example.test"+e.Path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 503 || called {
t.Fatal("executed without audit")
}
}
@@ -0,0 +1,81 @@
package clientapi
import (
"context"
"encoding/json"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/models"
"go-admin/app/goauto/product"
"go-admin/common/middleware"
"net/http/httptest"
"strings"
"testing"
)
func TestClientCanCreateProductWithoutLoginAndReplay(t *testing.T) {
db, s := fixture(t)
if err := db.AutoMigrate(&models.PDDProduct{}); err != nil {
t.Fatal(err)
}
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
if err != nil {
t.Fatal(err)
}
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
router := gin.New()
router.Use(func(c *gin.Context) { c.Set("db", db); c.Next() })
router.POST("/api/client/v1/pdd-products", Gate(db, e), product.Handler{}.Create)
for n := 0; n < 2; n++ {
req := httptest.NewRequest("POST", "https://example.test/api/client/v1/pdd-products", strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000237","url":"https://mobile.yangkeduo.com/goods.html?goods_id=100000000001"}`))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("product create failed status %d", rr.Code)
}
var body struct {
Data struct {
Replayed bool `json:"replayed"`
}
}
json.Unmarshal(rr.Body.Bytes(), &body)
if (n == 1) != body.Data.Replayed {
t.Fatal("business idempotency changed")
}
}
var count int64
db.Model(&models.PDDProduct{}).Count(&count)
if count != 1 {
t.Fatal("duplicate business write")
}
}
func TestManagementRequiresAdminNotClientIdentity(t *testing.T) {
db, s := fixture(t)
h := clientkey.Handler{DB: db, Modules: s.Modules}
for _, role := range []string{"admin", "purchaser", "client", ""} {
for _, scheme := range []string{"http", "https"} {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
c.Next()
})
router.POST("/keys", middleware.RequireRoleKey("admin"), NoStore, h.Create)
req := httptest.NewRequest("POST", scheme+"://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 403
if role == "admin" {
want = 200
}
if rr.Code != want {
t.Fatalf("role %q status %d", role, rr.Code)
}
if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
t.Fatal("one-time secret cacheable")
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package clientapi
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"go-admin/app/goauto/aimatching"
"io"
"net/http"
)
// resolveSpecs accepts only specification text, never a provider URL/key,
// prompt, raw order, address or account. It returns a suggestion, not an order.
func resolveSpecs(c *gin.Context) {
var req struct {
TargetColor string `json:"targetColor"`
TargetSize string `json:"targetSize"`
Colors []string `json:"colors"`
Sizes []string `json:"sizes"`
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(&req) != nil || d.Decode(&struct{}{}) != io.EOF || len(req.Colors) > 200 || len(req.Sizes) > 200 {
c.JSON(422, gin.H{"code": 422, "message": "规格请求无效"})
return
}
for _, s := range append(append([]string{req.TargetColor, req.TargetSize}, req.Colors...), req.Sizes...) {
if len([]rune(s)) > 255 {
c.JSON(422, gin.H{"code": 422, "message": "规格值过长"})
return
}
}
db, err := pkg.GetOrm(c)
if err != nil {
c.JSON(503, gin.H{"code": 503})
return
}
v, err := aimatching.NewService(db).Resolve(c.Request.Context(), aimatching.MatchRequest{TargetColor: req.TargetColor, TargetSize: req.TargetSize, Colors: req.Colors, Sizes: req.Sizes})
if err != nil {
c.JSON(422, gin.H{"code": 422, "message": "未获得可靠匹配,请检查候选规格或联系管理员"})
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"mappedColor": v.MappedColor, "mappedSize": v.MappedSize, "source": v.Source}})
}
+170
View File
@@ -0,0 +1,170 @@
// Package clientapi exposes only the reviewed client route inventory. It never
// forwards arbitrary URLs or synthesizes a logged-in administrator identity.
package clientapi
import (
"go-admin/app/goauto/access"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/device"
"go-admin/app/goauto/product"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/purchaserule"
"go-admin/app/goauto/rule"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"go-admin/app/goauto/sybinnercode"
"go-admin/app/goauto/sybshop"
"go-admin/app/goauto/task"
"go-admin/common/middleware"
"sort"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
type Endpoint struct {
Method, Path, Module, Capability string
Handle gin.HandlerFunc
}
func Inventory() []Endpoint {
p := product.Handler{}
s := shopeeproduct.Handler{}
y := sybimport.Handler{}
i := sybinnercode.Handler{}
shop := sybshop.Handler{}
r := rule.Handler{}
pr := purchaserule.Handler{}
t := task.Handler{}
buy := purchase.Handler{}
return []Endpoint{
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
{"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},
{"PATCH", "/pdd-products/:productId", access.ModulePDDProducts, "write", p.Update},
{"GET", "/shopee-products", access.ModuleShopeeProducts, "read", s.List},
{"GET", "/shopee-products/:productId", access.ModuleShopeeProducts, "read", s.Detail},
{"POST", "/shopee-products", access.ModuleShopeeProducts, "write", s.Create},
{"PATCH", "/shopee-products/:productId", access.ModuleShopeeProducts, "write", s.Update},
{"POST", "/shopee-products/:productId/link-pdd", access.ModuleShopeeProducts, "write", s.LinkPDD},
{"POST", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "write", s.AddSpecValue},
{"PUT", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "write", s.SetMapping},
{"DELETE", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "delete", s.RemoveSpecValue},
{"DELETE", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "delete", s.ClearMapping},
{"POST", "/shopee-products/batch-delete", access.ModuleShopeeProducts, "delete", s.BatchDelete},
{"POST", "/shopee-products/:productId/specs/mapping/auto-match", access.ModuleShopeeProducts, "match", s.AutoMatchMappings},
{"POST", "/shopee-products/:productId/specs/mapping/confirm", access.ModuleShopeeProducts, "match", s.ConfirmMapping},
{"GET", "/syb-products", access.ModuleSYBProducts, "read", y.List},
{"GET", "/syb-products/:productId", access.ModuleSYBProducts, "read", y.Detail},
{"PATCH", "/syb-products/:productId/correction", access.ModuleSYBProducts, "write", y.ManualCorrect},
{"POST", "/syb-products/:productId/reparse", access.ModuleSYBProducts, "reparse", y.Reparse},
{"POST", "/syb-products/reparse-batch", access.ModuleSYBProducts, "reparse", y.ReparseBatch},
{"GET", "/syb-products/sync-runs", access.ModuleSYBSyncRuns, "read", y.ListSyncRuns},
{"GET", "/syb-products/sync-runs/:runId", access.ModuleSYBSyncRuns, "read", y.SyncRunDetail},
{"POST", "/syb-products/import", access.ModuleSYBSyncRuns, "sync", y.Import},
{"GET", "/syb-inner-codes", access.ModuleSYBInnerCodes, "read", i.List},
{"GET", "/syb-inner-codes/:recordId", access.ModuleSYBInnerCodes, "read", i.Detail},
{"GET", "/syb-inner-codes/match-jobs/:jobId", access.ModuleSYBInnerCodes, "read", i.MatchJob},
{"GET", "/syb-inner-codes/apply-batches/:batchId", access.ModuleSYBInnerCodes, "read", i.ApplyBatch},
{"POST", "/syb-inner-codes/rematch", access.ModuleSYBInnerCodes, "match", i.Rematch},
{"POST", "/syb-inner-codes/import", access.ModuleSYBInnerCodes, "import", i.Import},
{"POST", "/syb-inner-codes/batch-delete", access.ModuleSYBInnerCodes, "delete", i.Delete},
{"POST", "/syb-inner-codes/apply-preview", access.ModuleSYBInnerCodes, "writeback", i.ApplyPreview},
{"POST", "/syb-inner-codes/apply", access.ModuleSYBInnerCodes, "writeback", i.Apply},
{"POST", "/syb-inner-codes/:recordId/recheck", access.ModuleSYBInnerCodes, "match", i.Recheck},
{"GET", "/syb-shops", access.ModuleSYBShops, "read", shop.List},
{"POST", "/syb-shops", access.ModuleSYBShops, "write", shop.Create},
{"PATCH", "/syb-shops/:shopId/name", access.ModuleSYBShops, "write", shop.Rename},
{"PATCH", "/syb-shops/:shopId/enabled", access.ModuleSYBShops, "write", shop.SetEnabled},
{"DELETE", "/syb-shops/:shopId", access.ModuleSYBShops, "delete", shop.Delete},
{"GET", "/collection-rules", access.ModuleCollectionRules, "read", r.List},
{"POST", "/collection-rules", access.ModuleCollectionRules, "write", r.Create},
{"PATCH", "/collection-rules/:ruleId", access.ModuleCollectionRules, "write", r.Update},
{"DELETE", "/collection-rules/:ruleId", access.ModuleCollectionRules, "delete", r.Delete},
{"GET", "/purchase-rules", access.ModulePurchaseRules, "read", pr.List},
{"GET", "/purchase-rules/current", access.ModulePurchaseRules, "read", pr.Current},
{"POST", "/purchase-rules", access.ModulePurchaseRules, "write", pr.Create},
{"PATCH", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "write", pr.Update},
{"DELETE", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "delete", pr.Delete},
{"PUT", "/purchase-rules/current", access.ModulePurchaseRules, "activate", pr.SetCurrent},
{"GET", "/collection-tasks", access.ModuleCollectionTasks, "read", t.AdminList},
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
{"GET", "/purchase-tasks/:taskId", access.ModulePurchaseTasks, "read", buy.AdminDetail},
{"POST", "/purchase-tasks/batch-preview", access.ModulePurchaseTasks, "purchase", buy.AdminBatchPreview},
{"POST", "/purchase-tasks", access.ModulePurchaseTasks, "purchase", buy.AdminCreate},
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
db, err := pkg.GetOrm(c)
if err != nil {
c.JSON(500, gin.H{"code": 500})
return
}
v, err := aimatching.NewService(db).Settings(c.Request.Context())
if err != nil {
c.JSON(500, gin.H{"code": 500})
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"enabled": v.Enabled}})
}},
}
}
func Catalog(routes []Endpoint) []clientkey.Module {
mods := map[string]clientkey.Module{}
for _, m := range access.GoAutoModules() {
mods[m.Key] = clientkey.Module{Key: m.Key, Title: m.Title, Actions: []string{}}
}
for _, e := range routes {
m := mods[e.Module]
if e.Capability == "write" {
m.Writable = true
} else if e.Capability != "read" {
found := false
for _, a := range m.Actions {
if a == e.Capability {
found = true
}
}
if !found {
m.Actions = append(m.Actions, e.Capability)
}
}
mods[e.Module] = m
}
out := []clientkey.Module{}
for _, g := range access.GoAutoMenuGroups() {
for _, key := range g.ModuleKeys {
m := mods[key]
m.Group = g.Title
sort.Strings(m.Actions)
out = append(out, m)
}
}
return out
}
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
routes := Inventory()
catalog := Catalog(routes)
h := clientkey.Handler{Modules: catalog}
management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), NoStore)
management.GET("", h.List)
management.GET("/modules", h.Catalog)
management.POST("", h.Create)
management.PATCH("/:keyId/grants", h.Edit)
management.POST("/:keyId/disable", h.Disable)
for _, e := range routes {
engine.Handle(e.Method, "/api/client/v1"+e.Path, Gate(nil, e), e.Handle)
}
}
+127
View File
@@ -0,0 +1,127 @@
package clientkey
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"gorm.io/gorm"
)
type Handler struct {
DB *gorm.DB
Modules []Module
}
func (h Handler) service(c *gin.Context) (Service, bool) {
db := h.DB
var err error
if db == nil {
db, err = pkg.GetOrm(c)
}
if err != nil || db == nil {
failure(c, errors.New("database unavailable"))
return Service{}, false
}
c.Header("Cache-Control", "no-store")
return Service{db, h.Modules}, true
}
func failure(c *gin.Context, err error) {
status, message := 500, "服务端处理失败"
switch {
case errors.Is(err, ErrInvalid):
status, message = 422, ErrInvalid.Error()
case errors.Is(err, ErrConflict):
status, message = 409, ErrConflict.Error()
case errors.Is(err, gorm.ErrRecordNotFound):
status, message = 404, "密钥不存在"
}
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
func (h Handler) Catalog(c *gin.Context) { c.JSON(200, gin.H{"code": 200, "data": h.Modules}) }
func (h Handler) List(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := 20
var total int64
var keys []Key
if err := s.DB.WithContext(c.Request.Context()).Model(&Key{}).Count(&total).Error; err != nil {
failure(c, err)
return
}
if err := s.DB.WithContext(c.Request.Context()).Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&keys).Error; err != nil {
failure(c, err)
return
}
items := make([]View, 0, len(keys))
for _, k := range keys {
items = append(items, view(k))
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"items": items, "total": total, "page": page, "pageSize": size}})
}
func decode(c *gin.Context, v any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(v) != nil || d.Decode(&struct{}{}) != io.EOF {
failure(c, ErrInvalid)
return false
}
return true
}
func (h Handler) Create(c *gin.Context) {
var req struct {
Name string `json:"name"`
Grants []Grant `json:"grants"`
}
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
result, secret, err := s.Create(c.Request.Context(), req.Name, req.Grants, uint64(user.GetUserId(c)))
if err != nil {
failure(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"key": result, "secret": secret}})
}
func (h Handler) Edit(c *gin.Context) { h.update(c, false) }
func (h Handler) Disable(c *gin.Context) { h.update(c, true) }
func (h Handler) update(c *gin.Context, disable bool) {
id, err := strconv.ParseUint(c.Param("keyId"), 10, 64)
if err != nil {
failure(c, ErrInvalid)
return
}
var req struct {
Version uint64 `json:"version"`
Grants []Grant `json:"grants"`
}
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
result, err := s.Update(c.Request.Context(), id, req.Version, uint64(user.GetUserId(c)), req.Grants, disable)
if err != nil {
failure(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": result})
}
+222
View File
@@ -0,0 +1,222 @@
// Package clientkey implements independent, revocable client credentials (#237).
package clientkey
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
var ErrInvalid = errors.New("名称或授权无效")
var ErrConflict = errors.New("密钥已停用或授权已被修改,请刷新后重试")
var ErrCredential = errors.New("客户端密钥无效或已停用")
type Grant struct {
Module string `json:"module"`
Write bool `json:"write"`
Actions []string `json:"actions"`
}
type Module struct {
Key string `json:"key"`
Title string `json:"title"`
Group string `json:"group"`
Writable bool `json:"writable"`
Actions []string `json:"actions"`
}
type Key struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:80;not null" json:"name"`
Prefix string `gorm:"size:24;not null" json:"prefix"`
Digest string `gorm:"size:64;uniqueIndex;not null" json:"-"`
GrantsJSON string `gorm:"type:text;not null" json:"-"`
Enabled bool `gorm:"not null" json:"enabled"`
Version uint64 `gorm:"not null" json:"version"`
CreatedBy uint64 `json:"createdBy"`
UpdatedBy uint64 `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
}
func (Key) TableName() string { return "client_api_key" }
type Audit struct {
ID uint64 `gorm:"primaryKey"`
KeyID uint64 `gorm:"index;not null"`
ActorID uint64
Event string `gorm:"size:32;not null"`
RequestID string `gorm:"size:32;index"`
Method string `gorm:"size:10"`
Route string `gorm:"size:180"`
Status int
Changes string `gorm:"type:text"`
CreatedAt time.Time
}
func (Audit) TableName() string { return "client_api_key_audit" }
type View struct {
Key
Grants []Grant `json:"grants"`
}
func view(k Key) View {
var g []Grant
_ = json.Unmarshal([]byte(k.GrantsJSON), &g)
return View{Key: k, Grants: g}
}
type Service struct {
DB *gorm.DB
Modules []Module
}
func (s Service) Normalize(in []Grant) ([]Grant, error) {
if len(in) == 0 || len(in) > len(s.Modules) {
return nil, ErrInvalid
}
catalog := map[string]Module{}
for _, m := range s.Modules {
catalog[m.Key] = m
}
seen := map[string]bool{}
out := make([]Grant, 0, len(in))
for _, g := range in {
m, ok := catalog[g.Module]
if !ok || seen[g.Module] || (g.Write && !m.Writable) {
return nil, ErrInvalid
}
seen[g.Module] = true
allowed := map[string]bool{}
for _, a := range m.Actions {
allowed[a] = true
}
used := map[string]bool{}
actions := []string{}
for _, a := range g.Actions {
if !allowed[a] || used[a] {
return nil, ErrInvalid
}
used[a] = true
actions = append(actions, a)
}
sort.Strings(actions)
out = append(out, Grant{g.Module, g.Write, actions})
}
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
return out, nil
}
func (s Service) Create(ctx context.Context, name string, grants []Grant, actor uint64) (View, string, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 80 || actor == 0 {
return View{}, "", ErrInvalid
}
g, err := s.Normalize(grants)
if err != nil {
return View{}, "", err
}
raw, _ := json.Marshal(g)
secret := make([]byte, 32)
if _, err = rand.Read(secret); err != nil {
return View{}, "", err
}
token := "gak_" + hex.EncodeToString(secret)
digest := sha256.Sum256([]byte(token))
k := Key{Name: name, Prefix: token[:16], Digest: hex.EncodeToString(digest[:]), GrantsJSON: string(raw), Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor}
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&k).Error; err != nil {
return err
}
return tx.Create(&Audit{KeyID: k.ID, ActorID: actor, Event: "create", Changes: string(raw)}).Error
})
if err != nil {
return View{}, "", err
}
return view(k), token, nil
}
func (s Service) Update(ctx context.Context, id, version, actor uint64, grants []Grant, disable bool) (View, error) {
if id == 0 || version == 0 || actor == 0 {
return View{}, ErrInvalid
}
var raw []byte
if !disable {
g, err := s.Normalize(grants)
if err != nil {
return View{}, err
}
raw, _ = json.Marshal(g)
}
var k Key
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&k, id).Error; err != nil {
return err
}
changes := map[string]any{"version": version + 1, "updated_by": actor, "updated_at": time.Now().UTC()}
event := "edit"
if disable {
changes["enabled"] = false
event = "disable"
} else {
changes["grants_json"] = string(raw)
}
result := tx.Model(&Key{}).Where("id = ? AND version = ? AND enabled = ?", id, version, true).Updates(changes)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrConflict
}
diff, _ := json.Marshal(map[string]string{"before": k.GrantsJSON, "after": string(raw)})
if err := tx.Create(&Audit{KeyID: id, ActorID: actor, Event: event, Changes: string(diff)}).Error; err != nil {
return err
}
return tx.First(&k, id).Error
})
return view(k), err
}
func (s Service) Authenticate(ctx context.Context, token string) (Key, error) {
if len(token) != 68 || !strings.HasPrefix(token, "gak_") {
return Key{}, ErrCredential
}
if _, err := hex.DecodeString(token[4:]); err != nil {
return Key{}, ErrCredential
}
digest := sha256.Sum256([]byte(token))
var k Key
err := s.DB.WithContext(ctx).Where("digest = ? AND enabled = ?", hex.EncodeToString(digest[:]), true).First(&k).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Key{}, ErrCredential
}
return k, err
}
func (k Key) Allows(module, capability string) bool {
var grants []Grant
if json.Unmarshal([]byte(k.GrantsJSON), &grants) != nil {
return false
}
for _, g := range grants {
if g.Module != module {
continue
}
if capability == "read" {
return true
}
if capability == "write" {
return g.Write
}
for _, a := range g.Actions {
if a == capability {
return true
}
}
}
return false
}
+115
View File
@@ -0,0 +1,115 @@
package clientkey
import (
"context"
"encoding/json"
"errors"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"strings"
"testing"
)
func testService(t *testing.T) Service {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
sql.SetMaxOpenConns(1)
t.Cleanup(func() { sql.Close() })
if err = db.AutoMigrate(&Key{}, &Audit{}); err != nil {
t.Fatal(err)
}
return Service{db, []Module{{Key: "products", Writable: true, Actions: []string{"delete"}}, {Key: "devices", Actions: []string{}}}}
}
func TestCredentialLifecycle(t *testing.T) {
s := testService(t)
ctx := context.Background()
v, token, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 7)
if err != nil {
t.Fatal(err)
}
if len(token) != 68 || v.Digest == token {
t.Fatal("invalid credential generation")
}
wire, _ := json.Marshal(v)
if strings.Contains(string(wire), token) || strings.Contains(string(wire), v.Digest) {
t.Fatal("secret serialized")
}
k, err := s.Authenticate(ctx, token)
if err != nil || !k.Allows("products", "read") || k.Allows("products", "write") || k.Allows("devices", "read") {
t.Fatal("default grants")
}
updated, err := s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "products", Write: true, Actions: []string{"delete"}}}, false)
if err != nil {
t.Fatal(err)
}
k, err = s.Authenticate(ctx, token)
if err != nil || !k.Allows("products", "write") || !k.Allows("products", "delete") || updated.Version != 2 || k.Digest != v.Digest {
t.Fatal("edit changed credential or failed to apply grants")
}
if _, err = s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "devices"}}, false); !errors.Is(err, ErrConflict) {
t.Fatal("stale edit accepted")
}
if _, err = s.Update(ctx, k.ID, 2, 8, nil, true); err != nil {
t.Fatal(err)
}
if _, err = s.Authenticate(ctx, token); !errors.Is(err, ErrCredential) {
t.Fatal("disabled credential accepted")
}
if _, err = s.Update(ctx, k.ID, 3, 8, []Grant{{Module: "products"}}, false); !errors.Is(err, ErrConflict) {
t.Fatal("disabled key edited")
}
var logs []Audit
s.DB.Find(&logs)
if len(logs) != 3 {
t.Fatalf("audit count %d", len(logs))
}
data, _ := json.Marshal(logs)
if strings.Contains(string(data), token) {
t.Fatal("secret in audit")
}
}
func TestInvalidGrantsAndCredentials(t *testing.T) {
s := testService(t)
for _, g := range [][]Grant{nil, {{Module: "admin"}}, {{Module: "devices", Write: true}}, {{Module: "products", Actions: []string{"payment"}}}, {{Module: "products"}, {Module: "products"}}} {
if _, err := s.Normalize(g); err == nil {
t.Fatal("invalid grant accepted")
}
}
for _, token := range []string{"", "jwt", "gak_" + strings.Repeat("z", 64), strings.Repeat("a", 68)} {
if _, err := s.Authenticate(context.Background(), token); !errors.Is(err, ErrCredential) {
t.Fatal("invalid credential accepted")
}
}
}
func TestAuditFailureRollsBack(t *testing.T) {
s := testService(t)
ctx := context.Background()
v, _, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 1)
if err != nil {
t.Fatal(err)
}
if err = s.DB.Migrator().DropTable(&Audit{}); err != nil {
t.Fatal(err)
}
if _, err = s.Update(ctx, v.ID, 1, 1, nil, true); err == nil {
t.Fatal("audit failure ignored")
}
var k Key
s.DB.First(&k, v.ID)
if !k.Enabled || k.Version != 1 {
t.Fatal("mutation not rolled back")
}
if _, secret, err := s.Create(ctx, "Failed", []Grant{{Module: "products"}}, 1); err == nil || secret != "" {
t.Fatal("creation audit failure ignored")
}
var count int64
s.DB.Model(&Key{}).Count(&count)
if count != 1 {
t.Fatal("key persisted without audit")
}
}
+7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"go-admin/common/clientprincipal"
"io"
"net/http"
"strconv"
@@ -470,6 +471,9 @@ func writeAdminReplay(c *gin.Context, data any, replayed bool) {
}
func allowedOperator(c *gin.Context) bool {
if _, ok := clientprincipal.Get(c); ok {
return true
}
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
if role == "admin" || role == "purchaser" {
return true
@@ -544,6 +548,9 @@ func writeError(c *gin.Context, err error) {
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
}
func operatorID(c *gin.Context) uint64 {
if id, ok := clientprincipal.Get(c); ok {
return id.AuthorizedBy
}
claims := jwt.ExtractClaims(c)
switch v := claims["identity"].(type) {
case float64:
+12 -4
View File
@@ -3,6 +3,8 @@ package sybimport
import (
"context"
"errors"
"fmt"
"go-admin/common/clientprincipal"
"net/http"
"strconv"
"strings"
@@ -113,8 +115,10 @@ func isImportAlreadyRunning(err error) bool {
// `[必须]` This remains the authenticated manual entry point. Both it and the
// scheduler delegate to StartImport, and every downstream SYB call is read-only.
func (handler Handler) Import(c *gin.Context) {
if claimString(jwt.ExtractClaims(c)["rolekey"]) != "admin" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员可以开始导入"})
role := claimString(jwt.ExtractClaims(c)["rolekey"])
client, clientOK := clientprincipal.Get(c)
if role != "admin" && role != "purchaser" && !clientOK {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
return
}
var request ImportRequest
@@ -127,9 +131,13 @@ func (handler Handler) Import(c *gin.Context) {
return
}
claims := jwt.ExtractClaims(c)
result, err := StartImport(c.Request.Context(), service.DB, request, ImportActor{
actor := ImportActor{
ID: claimUint64(claims["identity"]), Name: claimString(claims["nice"]),
}, false)
}
if clientOK {
actor = ImportActor{ID: client.AuthorizedBy, Name: fmt.Sprintf("client-key:%d", client.KeyID)}
}
result, err := StartImport(c.Request.Context(), service.DB, request, actor, false)
if err != nil {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
@@ -53,17 +53,26 @@ func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
}
}
func TestImportRequiresAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": "purchaser"}); c.Next() })
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "只有管理员") {
t.Fatalf("采购员应被拒绝: status=%d body=%s", recorder.Code, recorder.Body.String())
func TestImportRoleBoundary(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "viewer", "", "custom-role"} {
t.Run(role, func(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role}); c.Next() })
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
// Authorized roles reach JSON validation; no DB or external SYB call is made.
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
want := http.StatusForbidden
if role == "admin" || role == "purchaser" {
want = http.StatusUnprocessableEntity
}
if recorder.Code != want {
t.Fatalf("role=%q status=%d want=%d body=%s", role, recorder.Code, want, recorder.Body.String())
}
})
}
}
@@ -0,0 +1,101 @@
package sybimport
import (
"context"
"strings"
"testing"
"time"
)
func freezePaginationToday(t *testing.T) {
t.Helper()
previous := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = previous })
}
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
f.pageIDs = func(_ string, start, call int) []int64 {
if call == 2 {
return []int64{1001, 1002}
}
if call > 2 {
return []int64{int64(2000 + start), int64(2001 + start)}
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
}
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
t.Fatalf("did not restart at first page: %s", got)
}
for index, row := range rows {
if row.ID != int64(2000+index) {
t.Fatal("rows leaked from abandoned attempt")
}
}
}
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
f.pageIDs = func(date string, start, _ int) []int64 {
if date == "2026-08-29" && start == 2 {
return []int64{1001, 1002}
}
return nil
}
db := newSyncTestDB(t)
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
if err == nil {
t.Fatal("overlap was treated as successful sync")
}
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
if !strings.Contains(err.Error(), token) {
t.Fatalf("missing %s in %v", token, err)
}
}
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
}
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
}
var count int64
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
}
}
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
freezePaginationToday(t)
for _, tc := range []struct {
name, date, message string
page int
ids []int64
calls int
}{
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
} {
t.Run(tc.name, func(t *testing.T) {
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
f.pageIDs = func(_ string, start, _ int) []int64 {
if start == tc.page {
return tc.ids
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
}
})
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin.POST("/reparse-batch", handler.ReparseBatch)
admin.POST("/import", handler.Import)
// Run history is intentionally authenticated but not Casbin-gated: #50
// makes it read-only for every role, while Import enforces admin itself.
// makes it readable for every role; Import also requires admin/purchaser.
readonly := engine.Group("/api/admin/v1/syb-products").Use(auth.MiddlewareFunc())
readonly.GET("/sync-runs", handler.ListSyncRuns)
readonly.GET("/sync-runs/:runId", handler.SyncRunDetail)
+20 -6
View File
@@ -358,7 +358,7 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
}
var drift *snapshotDriftError
if !errors.As(err, &drift) {
return nil, err
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
}
last = drift
}
@@ -370,7 +370,8 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
rows := make([]sybclient.StockRow, 0, expectedTotal)
seen := make(map[int64]struct{}, expectedTotal)
type position struct{ page, row int }
seen := make(map[int64]position, expectedTotal)
for start := 0; start < expectedTotal; start += pageSize {
pageIndex := start/pageSize + 1
@@ -386,14 +387,27 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
if pageCount != len(page) {
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
}
for _, row := range page {
// Validate the whole page first: a same-page duplicate must not be
// hidden behind an earlier, recoverable cross-page overlap.
pageSeen := make(map[int64]int, len(page))
for index, row := range page {
if row.ID <= 0 {
return nil, fmt.Errorf("%s 货运单列表包含非法 id=%d", date, row.ID)
}
if _, duplicate := seen[row.ID]; duplicate {
return nil, fmt.Errorf("%s 货运单列表重复返回 id=%d", date, row.ID)
if firstRow, duplicate := pageSeen[row.ID]; duplicate {
return nil, fmt.Errorf("%s 货运单列表同页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
date, pageIndex, firstRow, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))
}
seen[row.ID] = struct{}{}
pageSeen[row.ID] = index + 1
}
for index, row := range page {
if first, duplicate := seen[row.ID]; duplicate {
// No rows/valid flag: overlapping pages are never eligible for
// the existing final-attempt degraded save, even after deduping.
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表跨页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
date, first.page, first.row, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))}
}
seen[row.ID] = position{page: pageIndex, row: index + 1}
rows = append(rows, row)
}
if len(page) != expectedPageCount {
+13
View File
@@ -60,6 +60,9 @@ type fakeSYB struct {
totalOverride map[int]int
shortPageAtIndex int
detailDropID int64
listCalls []string
pageIDs func(date string, start, call int) []int64
detailCalls int
// shopNames 按货运单序号轮换;留空表示全部用「测试店铺」。
shopNames []string
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
@@ -96,6 +99,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
start := int(body["start"].(float64))
pageSize := int(body["length"].(float64))
total := f.perDay[date]
f.listCalls = append(f.listCalls, fmt.Sprintf("%s:%d", date, start))
rows := []map[string]any{}
for i := start; i < total && len(rows) < pageSize; i++ {
@@ -108,9 +112,18 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
rows = rows[:len(rows)-1]
}
if f.pageIDs != nil {
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
rows = nil
for _, id := range ids {
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
}
}
}
writeEnvelope(w, map[string]any{"list": rows, "total": len(rows)})
})
mux.HandleFunc("/am/stock/detail/listByStock", func(w http.ResponseWriter, r *http.Request) {
f.detailCalls++
var body struct {
IDs []int64 `json:"ids"`
}
+10 -3
View File
@@ -3,6 +3,7 @@ package sybinnercode
import (
"encoding/json"
"errors"
"go-admin/common/clientprincipal"
"io"
"net/http"
"strconv"
@@ -81,13 +82,19 @@ func (h Handler) Import(c *gin.Context) {
if !ok {
return
}
result, err := service.Import(c.Request.Context(), src, file.Filename, uint64(user.GetUserId(c)), c.PostForm("requestId"))
result, err := service.Import(c.Request.Context(), src, file.Filename, clientOperator(c), c.PostForm("requestId"))
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": result})
}
func clientOperator(c *gin.Context) uint64 {
if p, ok := clientprincipal.Get(c); ok {
return p.AuthorizedBy
}
return uint64(user.GetUserId(c))
}
func (h Handler) Delete(c *gin.Context) {
var request DeleteRequest
if err := decode(c, &request); err != nil {
@@ -98,7 +105,7 @@ func (h Handler) Delete(c *gin.Context) {
if !ok {
return
}
result, err := service.Delete(c.Request.Context(), uint64(user.GetUserId(c)), request)
result, err := service.Delete(c.Request.Context(), clientOperator(c), request)
if err != nil {
writeError(c, err)
return
@@ -150,7 +157,7 @@ func (h Handler) Apply(c *gin.Context) {
if !ok {
return
}
result, err := service.QueueApply(c.Request.Context(), uint64(user.GetUserId(c)), request)
result, err := service.QueueApply(c.Request.Context(), clientOperator(c), request)
if err != nil {
writeError(c, err)
return
@@ -38,11 +38,11 @@ func TestEnsurePurchaserRoleAndPolicies(t *testing.T) {
if count != int64(len(access.PurchaserAPIs())) {
t.Fatalf("got %d policies, want %d", count, len(access.PurchaserAPIs()))
}
var forbidden int64
var allowed int64
db.Model(&purchaserCasbinRule{}).
Where("v0 = ? AND v1 = ? AND v2 = ?", access.RolePurchaser, "/api/admin/v1/syb-products/import", "POST").
Count(&forbidden)
if forbidden != 0 {
t.Fatal("manual SYB import must remain administrator-only")
Count(&allowed)
if allowed != 1 {
t.Fatal("manual SYB import must follow the current purchaser permission matrix")
}
}
@@ -0,0 +1,42 @@
package version_local
import (
"fmt"
"go-admin/app/goauto/clientkey"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateClientAPIKey)
}
func migrateClientAPIKey(db *gorm.DB, version string) error {
if err := db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
var parent migrationmodels.SysMenu
if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
return err
}
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{MenuName: "GoAutoClientKeys", Title: "客户端密钥", Icon: "lock", Path: "/client-keys/index", MenuType: "C", Action: "无", ParentId: parent.MenuId, Component: "/goauto/client-keys/index", Sort: 6, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
return err
}
var admin migrationmodels.SysRole
if err = tx.Where("role_key = ?", "admin").First(&admin).Error; err != nil {
return err
}
if err = tx.Model(&admin).Association("SysMenu").Append(&child); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,45 @@
package version_local
import (
"go-admin/app/goauto/clientkey"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"testing"
)
func TestClientKeyMigrationOnlyAdminMenu(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
defer sql.Close()
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
admin := migrationmodels.SysRole{RoleKey: "admin"}
purchaser := migrationmodels.SysRole{RoleKey: "purchaser"}
parent := migrationmodels.SysMenu{MenuName: "GoAutoCollectionManagement"}
for _, v := range []any{&admin, &purchaser, &parent} {
if err = db.Create(v).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateClientAPIKey(db, "client-key-test"); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasTable(&clientkey.Key{}) || !db.Migrator().HasTable(&clientkey.Audit{}) {
t.Fatal("missing tables")
}
var child migrationmodels.SysMenu
if err = db.Where("menu_name = ?", "GoAutoClientKeys").First(&child).Error; err != nil {
t.Fatal(err)
}
if child.ParentId != parent.MenuId {
t.Fatal("wrong menu group")
}
assertRoleHasMenu(t, db, admin.RoleId, child.MenuId, true)
assertRoleHasMenu(t, db, purchaser.RoleId, child.MenuId, false)
}
@@ -0,0 +1,20 @@
// Package clientprincipal carries an authenticated client identity, never an
// administrator or purchaser JWT. Only the client gateway sets it.
package clientprincipal
import "github.com/gin-gonic/gin"
const contextKey = "goauto.authenticatedClient"
type Identity struct {
KeyID uint64
RequestID string
AuthorizedBy uint64
}
func Set(c *gin.Context, id Identity) { c.Set(contextKey, id) }
func Get(c *gin.Context) (Identity, bool) {
v, ok := c.Get(contextKey)
id, valid := v.(Identity)
return id, ok && valid && id.KeyID > 0
}
+6
View File
@@ -24,6 +24,12 @@ import (
// LoggerToFile 日志记录到文件
func LoggerToFile() gin.HandlerFunc {
return func(c *gin.Context) {
// #237: client request/response bodies and one-time credentials must never
// enter the legacy operation logger. The client gateway keeps metadata-only audit.
if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") {
c.Next()
return
}
log := api.GetRequestLogger(c)
// 开始时间
startTime := time.Now()
+8
View File
@@ -0,0 +1,8 @@
import request from '@/utils/request'
const base = '/api/admin/v1/client-keys'
export const listClientKeys = page => request({ url: base, method: 'get', params: { page }, suppressErrorMessage: true })
export const clientKeyModules = () => request({ url: `${base}/modules`, method: 'get', suppressErrorMessage: true })
export const createClientKey = data => request({ url: base, method: 'post', data, suppressErrorMessage: true })
export const editClientKey = (id, data) => request({ url: `${base}/${id}/grants`, method: 'patch', data, suppressErrorMessage: true })
export const disableClientKey = (id, version) => request({ url: `${base}/${id}/disable`, method: 'post', data: { version }, suppressErrorMessage: true })
+121
View File
@@ -0,0 +1,121 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card shadow="never">
<el-alert v-if="!isAdmin" title="只有管理员可以管理客户端密钥" type="warning" :closable="false" />
<template v-else>
<div class="heading"><div><h2>客户端密钥</h2><p>按菜单模块开放客户端访问。完整密钥仅在创建成功后显示一次。</p></div><el-button type="primary" :disabled="loading || !!loadError" @click="openEditor()">创建密钥</el-button></div>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false"><el-button @click="load">重新加载</el-button></el-alert>
<el-table v-loading="loading" :data="items" row-key="id" border empty-text="还没有客户端密钥,点击右上角创建">
<el-table-column label="名称 / 标识" min-width="210"><template #default="{ row }"><strong>{{ row.name }}</strong><p class="muted">{{ row.prefix }}••••</p></template></el-table-column>
<el-table-column label="授权模块" min-width="250"><template #default="{ row }"><div v-for="g in row.grants" :key="g.module">{{ moduleName(g.module) }} · {{ g.write ? '读写' : '只读' }}<span v-if="g.actions.length"> / {{ g.actions.map(actionName).join('、') }}</span></div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用中' : '已停用' }}</el-tag></template></el-table-column>
<el-table-column label="最后使用" min-width="170"><template #default="{ row }">{{ row.lastUsedAt ? formatTime(row.lastUsedAt) : '尚未使用' }}</template></el-table-column>
<el-table-column label="操作" width="255"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row, true)">查看授权</el-button><el-button link type="primary" :disabled="!row.enabled || saving" @click="openEditor(row)">编辑授权</el-button><el-button link type="danger" :disabled="!row.enabled || saving" @click="disable(row)">停用</el-button></template></el-table-column>
</el-table>
<el-pagination v-if="total" v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next, total" @current-change="load" />
<p class="muted">客户端地址:/api/client/v1。支持 HTTP / HTTPS;HTTP 明文传输密钥和数据。不开放支付、账号权限管理及敏感密钥读取。</p>
</template>
</el-card>
<el-dialog v-model="editorOpen" :title="readonly ? '查看授权' : editing ? '编辑授权' : '创建客户端密钥'" width="min(1080px, 95vw)" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving" :before-close="closeEditor">
<el-alert v-if="editError" :title="editError" type="error" :closable="false" class="notice" />
<el-form label-position="top" @submit.prevent="save">
<el-form-item label="名称" required><el-input v-model.trim="name" maxlength="80" :disabled="readonly || editing || saving" placeholder="例如:商品同步工具" /></el-form-item>
<p v-if="editing" class="muted">标识:{{ selected.prefix }}•••• · 保存不会更换 API Key,完整密钥不可再次查看。</p>
<el-alert title="新选模块默认只读;同步、采集、采购、回写、删除等动作需单独勾选。新增菜单不会自动授权。" type="info" :closable="false" class="notice" />
<div class="groups">
<section v-for="group in groups" :key="group.title" class="module-group">
<el-checkbox :model-value="group.modules.every(m => !!grants[m.key])" :indeterminate="group.modules.some(m => !!grants[m.key]) && !group.modules.every(m => !!grants[m.key])" :disabled="readonly || saving" @change="value => selectGroup(group, value)">{{ group.title }}</el-checkbox>
<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>
</div>
</section>
</div>
<p v-if="editing" class="muted">{{ selected.enabled ? '保存成功后,后续请求按新授权校验;已执行操作不回滚。' : '密钥已停用,只允许查看授权。' }} 最近修改:管理员 #{{ selected.updatedBy }} · {{ formatTime(selected.updatedAt) }}</p>
<p v-if="!Object.keys(grants).length" class="validation" role="alert">至少选择一个模块;要禁止全部访问,请使用停用。</p>
</el-form>
<template #footer><el-button :disabled="saving" @click="closeEditor()">{{ readonly ? '关闭' : '取消' }}</el-button><el-button v-if="!readonly" type="primary" :loading="saving" :disabled="!valid || !changed || conflicted" @click="save">{{ editing ? '保存授权' : '创建密钥' }}</el-button></template>
</el-dialog>
<el-dialog v-model="secretOpen" title="密钥已创建,请立即保存" width="min(650px, 95vw)" :close-on-click-modal="false" @closed="clearSecret">
<el-alert title="完整密钥只显示这一次;关闭后不可再次查看。请勿放入 URL、日志或前端代码。" type="warning" :closable="false" />
<pre class="secret">{{ secret }}</pre><el-button type="primary" @click="copySecret">复制密钥</el-button>
<p>请求头:Authorization: Bearer &lt;客户端密钥&gt;</p>
<template #footer><el-button type="primary" @click="secretOpen = false">已保存,返回列表</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
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 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 || '请求失败,请检查网络后重试'
export default {
name: 'GoAutoClientKeys',
data() { return { active: true, items: [], modules: [], total: 0, page: 1, loading: false, loadError: '', saving: false, editorOpen: false, editing: false, readonly: false, selected: null, name: '', grants: {}, initial: '', editError: '', conflicted: false, secret: '', secretOpen: false } },
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
groups() { return [...new Set(this.modules.map(m => m.group))].map(title => ({ title, modules: this.modules.filter(m => m.group === title) })) },
valid() { return !!this.name.trim() && Object.keys(this.grants).length > 0 },
changed() { return !this.editing || snapshot(this.grants) !== this.initial }
},
mounted() { this.load() },
activated() { this.active = true },
deactivated() { this.active = false; this.clearSecret(); this.secretOpen = false },
beforeUnmount() { this.active = false; this.clearSecret() },
methods: {
moduleName(key) { return this.modules.find(m => m.key === key)?.title || key },
actionName(action) { return actionLabels[action] || action },
formatTime(value) { return value ? new Date(value).toLocaleString() : '—' },
async load() {
if (!this.isAdmin || this.loading) return
this.loading = true; this.loadError = ''
try { const [rows, catalog] = await Promise.all([listClientKeys(this.page), clientKeyModules()]); this.items = rows.data.items; this.total = rows.data.total; this.modules = catalog.data } catch (error) { this.loadError = errorText(error) } finally { this.loading = false }
},
openEditor(row = null, readonly = false) {
if (!this.isAdmin || (row && !row.enabled && !readonly)) return
this.selected = row; this.editing = !!row; this.readonly = readonly; this.name = row?.name || ''; this.grants = {}
for (const g of row?.grants || []) this.grants[g.module] = { ...g, actions: [...g.actions] }
this.initial = snapshot(this.grants); this.editError = ''; this.conflicted = false; this.editorOpen = true
},
selectModule(key, value) { if (value) { if (!this.grants[key]) this.grants[key] = { module: key, write: false, actions: [] } } else delete this.grants[key] },
selectGroup(group, value) { for (const m of group.modules) this.selectModule(m.key, value) },
closeEditor(done) { if (this.saving) return; this.editorOpen = false; this.grants = {}; if (typeof done === 'function') done() },
async save() {
if (!this.valid || !this.changed || this.saving || this.readonly || this.conflicted) return
this.saving = true; this.editError = ''
try {
if (this.editing) { await editClientKey(this.selected.id, { version: this.selected.version, grants: Object.values(this.grants) }); ElMessage.success('授权已更新,API Key 保持不变') } else { const result = await createClientKey({ name: this.name, grants: Object.values(this.grants) }); if (this.active) { this.secret = result.data.secret; this.secretOpen = true } }
this.editorOpen = false; await this.load()
} catch (error) { this.editError = errorText(error); if (!this.editing) this.editError += '。响应丢失时可能已创建,请先检查列表;丢失密钥需停用后重建。'; this.conflicted = error.response?.status === 409; if (this.conflicted) { this.editError += '。请取消并刷新列表后重新打开。'; await this.load() } } finally { this.saving = false }
},
async disable(row) {
try { await ElMessageBox.confirm(`停用“${row.name}”后,新请求将被拒绝,已执行操作不回滚。首版不支持恢复。`, '停用客户端密钥', { confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning' }) } catch { return }
this.saving = true
try { await disableClientKey(row.id, row.version); ElMessage.success('密钥已停用'); await this.load() } catch (error) { ElMessage.error(errorText(error)); await this.load() } finally { this.saving = false }
},
async copySecret() { try { await navigator.clipboard.writeText(this.secret); ElMessage.success('已复制,请妥善保存') } catch { ElMessage.warning('复制失败,请手动选择并复制密钥') } },
clearSecret() { this.secret = '' }
}
}
</script>
<style scoped>
.heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
h2 { margin: 0 0 12px; }
.muted, .heading p { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
.groups { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.module-group { padding: 16px; border: 1px solid var(--el-border-color); border-radius: 6px; min-width: 0; }
.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; }
.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); }
@media (max-width: 760px) { .groups { grid-template-columns: 1fr; } .heading { align-items: flex-start; } }
</style>
+4 -4
View File
@@ -3,9 +3,9 @@
<template #wrapper>
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员可以立即同步今天和昨天的数据。</p></div>
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员和采购员可以立即同步今天和昨天的数据。</p></div>
<el-button
v-if="isAdmin"
v-if="canStartSync"
type="primary"
:loading="manualSyncStarting"
:disabled="manualSyncStarting || hasRunningSync"
@@ -86,7 +86,7 @@ export default {
}
},
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
hasRunningSync() { return this.items.some(item => item.status === 'running') }
},
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
@@ -109,7 +109,7 @@ export default {
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
},
async startManualSync() {
if (this.manualSyncStarting || this.hasRunningSync) return
if (!this.canStartSync || this.manualSyncStarting || this.hasRunningSync) return
const today = new Date()
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
this.manualSyncStarting = true
+42 -8
View File
@@ -4,6 +4,8 @@ async function authenticate(context: any) {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
}
const syncMenu = [{ path: '/syb-sync-runs', component: 'Layout', visible: '0', menuName: 'SybSync', title: 'SYB 同步', children: [{ path: 'index', component: '/goauto/syb-sync-runs/index', visible: '0', menuName: 'GoAutoSybSyncRuns', title: 'SYB 同步记录' }] }];
test('同步记录展示失败原因和按店铺统计', async ({ page, context }) => {
await authenticate(context);
await page.route('**/api/**', async route => {
@@ -12,12 +14,12 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs/51')) return route.fulfill({ json: { code: 200, data: { item: { id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, progressPercent: 66, orderCount: 12, detailCount: 8, acceptedCount: 10, shopSkipped: 2, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z', finishedAt: '2026-08-20T01:05:00Z', errorMessage: '第 3 天读取失败,等待下个调度周期重试', shopFilterHash: 'abc123', shopBreakdown: [{ shopName: '大碼臻選', accepted: 10, skipped: 0 }, { shopName: '未知店铺', accepted: 0, skipped: 2 }] } } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, orderCount: 12, detailCount: 8, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z' }], total: 1, page: 1, pageSize: 20 } } });
return route.fulfill({ json: { code: 200, data: [] } });
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
});
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
await page.goto('/#/syb-sync-runs/index');
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
await expect(page.getByRole('button', { name: '立即同步' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即同步' })).toBeVisible();
await expect(page.getByText('2026-08-18 至 2026-08-20')).toBeVisible();
await page.getByRole('button', { name: '详情' }).click();
await expect(page.getByText('第 3 天读取失败,等待下个调度周期重试')).toBeVisible();
@@ -26,14 +28,15 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
await expect(page.getByText('系统定时同步', { exact: true }).first()).toBeVisible();
});
test('管理员可立即同步今天和昨天,受理后按钮进入运行中状态', async ({ page, context }) => {
for (const role of ['admin', 'purchaser']) {
test(`${role} 可立即同步今天和昨天,受理后按钮进入运行中状态`, async ({ page, context }) => {
await authenticate(context);
let importBody: any = null;
let importAccepted = false;
await page.route('**/api/**', async route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/src/api/')) return route.continue();
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/import') && route.request().method() === 'POST') {
importBody = route.request().postDataJSON();
await new Promise(resolve => setTimeout(resolve, 200));
@@ -44,10 +47,10 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
const items = importAccepted ? [{ id: 88, dateFrom: importBody.dateFrom, dateTo: importBody.dateTo, status: 'running', progressPercent: 0, daysProcessed: 0, daysTotal: 2, orderCount: 0, detailCount: 0, created: 0, updated: 0, operatorName: '管理员', startedAt: '2026-08-24T08:00:00Z' }] : [];
return route.fulfill({ json: { code: 200, data: { items, total: items.length, page: 1, pageSize: 20 } } });
}
return route.fulfill({ json: { code: 200, data: [] } });
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
});
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
await page.goto('/#/syb-sync-runs/index');
const button = page.getByRole('button', { name: '立即同步' });
await expect(button).toBeVisible();
await button.click();
@@ -60,6 +63,37 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
const formatDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
expect(importBody).toEqual({ dateFrom: formatDate(yesterday), dateTo: formatDate(now) });
});
}
for (const role of ['purchaser', 'viewer']) {
test(`${role} 同步权限及失败恢复`, async ({ page, context }) => {
await authenticate(context);
let requests = 0;
await page.route('**/api/**', async route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/src/api/')) return route.continue();
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', permissions: [] } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/import')) {
requests++;
return route.fulfill({ status: 422, json: { code: 'INVALID_REQUEST', message: '已有同步正在执行,请稍后再试' } });
}
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 } } });
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
});
await page.goto('/#/syb-sync-runs/index');
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
const button = page.getByRole('button', { name: '立即同步' });
if (role === 'viewer') {
await expect(button).toHaveCount(0);
expect(requests).toBe(0);
} else {
await button.click();
await expect(page.getByText('已有同步正在执行,请稍后再试')).toBeVisible();
await expect(button).toBeEnabled();
expect(requests).toBe(1);
}
});
}
test('SYB 定时任务可进入执行记录,其他任务不显示该入口', async ({ page, context }) => {
await authenticate(context);
@@ -73,7 +107,7 @@ test('SYB 定时任务可进入执行记录,其他任务不显示该入口', a
return route.fulfill({ json: { code: 200, data: [] } });
});
await page.goto('http://localhost:9527/#/schedule/index');
await page.goto('/#/schedule/index');
await expect(page.getByText('SYB 每小时自动同步', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '执行记录', exact: true })).toHaveCount(1);
await page.getByRole('button', { name: '执行记录', exact: true }).click();
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>#237 客户端密钥隔离测试</title></head><body><p>仅本地组件测试:全部请求由内存适配器处理,不连接业务服务,不创建真实密钥。</p><div id="app"></div><script type="module" src="./client-keys.js"></script></body></html>
+31
View File
@@ -0,0 +1,31 @@
// Manual browser fixture for the production component, not an offline prototype.
import { createApp, h } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Page from '../../src/views/goauto/client-keys/index.vue'
import request from '../../src/utils/request'
const definitions = [
['syb_products', 'SYB 商品', true, ['reparse']], ['syb_sync_runs', 'SYB 同步记录', false, ['sync']],
['syb_inner_codes', '档口入库码', false, ['import', 'match', 'writeback', 'delete']], ['shopee_products', '虾皮商品', true, ['match', 'delete']],
['pdd_products', 'PDD 商品', true, []], ['collection_tasks', '采集任务', false, ['collect', 'delete']], ['purchase_tasks', '采购管理', false, ['purchase']],
['syb_shops', 'SYB 店铺', true, ['delete']], ['collection_rules', '采集规则', true, ['delete']], ['purchase_rules', '采购规则', true, ['delete', 'activate']], ['devices', '设备列表', false, []], ['ai_matching', 'AI 规格匹配', false, ['match']]
]
const modules = definitions.map(([key, title, writable, actions], index) => ({ key, title, writable, actions, group: index < 7 ? '采集采购' : '采采管理' }))
let items = [{ id: 1, name: '商品同步工具(测试)', prefix: 'gak_DEMO', version: 1, enabled: true, updatedBy: 1, updatedAt: '2026-09-07T00:00:00Z', grants: [{ module: 'pdd_products', write: true, actions: [] }] }]
request.defaults.adapter = async config => {
let data
const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data
if (config.url.endsWith('/modules')) data = modules
else if (config.method === 'get') data = { items: structuredClone(items), total: items.length }
else if (config.url.endsWith('/grants')) { items[0].grants = body.grants; items[0].version++; data = items[0] } else if (config.url.endsWith('/disable')) { items[0].enabled = false; items[0].version++; data = items[0] } else if (config.method === 'post' && config.url === '/api/admin/v1/client-keys') {
const key = { id: 2, name: body.name, prefix: 'gak_DEMO_2', enabled: true, version: 1, grants: body.grants }
items = [key, ...items]; data = { key, secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }
} else throw new Error('Unexpected fixture request')
return { status: 200, statusText: 'OK', headers: {}, config, data: { code: 200, data }}
}
const app = createApp(Page)
app.use(ElementPlus)
app.config.globalProperties.$store = { getters: { roles: ['admin'] }}
app.component('BasicLayout', { setup(_, { slots }) { return () => h('main', { style: 'padding:24px;background:#f3f6fa;min-height:90vh;font-family:Arial,sans-serif' }, slots.wrapper?.()) } })
app.mount('#app')
+61
View File
@@ -0,0 +1,61 @@
import Page from '@/views/goauto/client-keys/index.vue'
import { createClientKey, editClientKey, listClientKeys, clientKeyModules } from '@/api/goauto/client-keys'
jest.mock('@/api/goauto/client-keys', () => ({ createClientKey: jest.fn(), editClientKey: jest.fn(), disableClientKey: jest.fn(), listClientKeys: jest.fn(), clientKeyModules: jest.fn() }))
jest.mock('element-plus', () => ({ ElMessage: { success: jest.fn(), error: jest.fn(), warning: jest.fn() }, ElMessageBox: { confirm: jest.fn() }}))
function vm() {
const value = { ...Page.data(), $store: { getters: { roles: ['admin'] }}}
for (const [name, fn] of Object.entries(Page.methods)) value[name] = fn.bind(value)
for (const [name, fn] of Object.entries(Page.computed)) Object.defineProperty(value, name, { get: fn.bind(value) })
return value
}
const row = () => ({ id: 12, version: 2, name: 'Tool', prefix: 'masked', enabled: true, grants: [{ module: 'pdd_products', write: false, actions: [] }] })
beforeEach(() => { jest.clearAllMocks(); listClientKeys.mockResolvedValue({ data: { items: [], total: 0 }}); clientKeyModules.mockResolvedValue({ data: [] }) })
test('edit prefill and cancel never change the existing row or create a key', () => {
const p = vm(); const original = row(); p.openEditor(original)
expect(p.changed).toBe(false)
p.grants.pdd_products.write = true; expect(p.changed).toBe(true)
p.closeEditor()
expect(original.grants[0].write).toBe(false)
expect(editClientKey).not.toHaveBeenCalled(); expect(createClientKey).not.toHaveBeenCalled()
})
test('module group selection defaults read-only and removing a module removes actions', () => {
const p = vm(); p.selectGroup({ modules: [{ key: 'one' }, { key: 'two' }] }, true)
expect(p.grants.one).toEqual({ module: 'one', write: false, actions: [] })
p.grants.one.actions.push('delete'); p.selectModule('one', false); p.selectModule('one', true)
expect(p.grants.one.actions).toEqual([])
})
test('edit saves same ID with version, does not issue a new key', async() => {
const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true
editClientKey.mockResolvedValue({ data: {}}); await p.save()
expect(editClientKey).toHaveBeenCalledWith(12, { version: 2, grants: [{ module: 'pdd_products', write: true, actions: [] }] })
expect(createClientKey).not.toHaveBeenCalled(); expect(p.secret).toBe(''); expect(p.editorOpen).toBe(false)
})
test('failure preserves changes and conflict prevents blind retry', async() => {
const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true
editClientKey.mockRejectedValue({ response: { status: 409, data: { message: 'conflict' }}})
await p.save(); expect(p.editorOpen).toBe(true); expect(p.grants.pdd_products.write).toBe(true); expect(p.conflicted).toBe(true)
await p.save(); expect(editClientKey).toHaveBeenCalledTimes(1)
})
test('disabled keys are read-only and no modules cannot be saved', async() => {
const p = vm(); p.openEditor({ ...row(), enabled: false }); expect(p.editorOpen).toBe(false)
p.openEditor({ ...row(), enabled: false }, true); expect(p.readonly).toBe(true)
p.openEditor(row()); p.selectModule('pdd_products', false); await p.save(); expect(editClientKey).not.toHaveBeenCalled()
})
test('secret is cleared when dialog or cached view closes', () => {
const p = vm(); p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.clearSecret(); expect(p.secret).toBe('')
p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.secretOpen = true; Page.deactivated.call(p); expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
})
test('ordinary users do not load or open management', async() => {
const p = vm(); p.$store.getters.roles = ['purchaser']; await p.load(); p.openEditor(); expect(listClientKeys).not.toHaveBeenCalled(); expect(p.editorOpen).toBe(false)
})
test('late creation response after navigation does not retain a secret', async() => {
const p = vm(); p.name = 'test'; p.selectModule('pdd_products', true)
let resolve
createClientKey.mockImplementation(() => new Promise(done => { resolve = done }))
const pending = p.save(); Page.deactivated.call(p)
resolve({ data: { secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }}); await pending
expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
})