Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Sonnet 5.5 0d21ca9881 fix(android): accept evidence-complete quick confirmation panel after address save (#388)
Purchase now accepts a QUICK_CONFIRMATION panel (the order sheet with the
"已选" summary PDD re-renders after returning from the address list) only
when it has the same #331 required evidence as REQUIRED_EVIDENCE (address
entry, payment entry, single quantity input in one container). Applies to
isPurchaseConfirmationPanel and finalSubmitTargets; address suffix,
bottom-row target, blocked words and spec/price/quantity checks unchanged.
Adds a structured, text-free diagnostic to PURCHASE_ADDRESS_SAVE_TIMEOUT
after Back. Agent 0.9.71 (84) -> 0.9.72 (85). Regression tests added.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 16:13:20 +08:00
QiuSWandClaude Opus 5.5 00a0c8386d docs: mirror wiki for #387 release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 11:52:23 +08:00
QiuSWandClaude Opus 5.5 5183df306f docs: mirror wiki for collection task cancel by device (#387)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 11:23:00 +08:00
QiuSWandClaude Opus 5.5 df6b4a570c merge: 采集任务按设备批量取消,采购员仅可取消本人设备任务 (#387)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 11:16:14 +08:00
QiuSWandClaude Sonnet 5.5 fc5d9edfa6 feat(task): 批量取消按设备限定,采购员只能取消本人设备任务 (#387)
- 批量取消新增可选 deviceId;非管理员必填且须为本人设备,否则 INVALID_REQUEST/FORBIDDEN
- 单条取消对非管理员强制设备归属(未指定设备、他人设备一律拒绝),管理员不变
- 新增 FORBIDDEN 错误码(HTTP 403)
- Web:去掉选设备禁用按钮;采购员未选设备点击只提示;数量/预览/提交带 deviceId;弹窗显示设备范围

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:14:40 +08:00
QiuSWandClaude Opus 5.5 59a483f53a docs: mirror wiki for Agent APK signing per device (#385 #386)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:38:38 +08:00
QiuSWandClaude Opus 5.5 328afde919 chore(android): bump Agent to 0.9.71 (84) (#385 #386)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:22:22 +08:00
QiuSWandClaude Opus 5.5 afa03ef3e4 merge: drop diagnostic record lock to avoid deadlock (#386)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:22:16 +08:00
QiuSWandClaude Opus 5.5 71165b1fe7 fix(android): drop object lock on diagnostic record to avoid deadlock (#386)
AgentDiagnosticStore.record() held the helper monitor (@Synchronized) while
waiting for the SQLite write connection, while failure-snapshot and sync
diagnostic transactions held the connection and re-entered
getWritableDatabase() (same monitor). The inverted lock order deadlocked and
blocked the sync/heartbeat thread forever (seen on purchase device 1).
record() only touches locals inside its own transaction and is called from a
single-thread executor, so the monitor is unnecessary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:22:04 +08:00
QiuSWandClaude Opus 5.5 7c0d37b512 merge: pause task claiming when accessibility is not ready (#385)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:21:50 +08:00
QiuSWandClaude Opus 5.5 68eccade03 fix(android): pause task claiming when accessibility is not ready (#385)
Agent no longer claims purchase/collection tasks while the accessibility
service is DISABLED or ENABLED_WAITING; tasks stay pending on the server.
Status card, manual-check text, persistent notification, foreground dialog
and a separate high-priority background notification explain the recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:10:09 +08:00
QiuSWandClaude Opus 5.5 d4cf2bbb3e docs: mirror wiki for client key agent release (#384)
Business-Rules-and-Glossary@808db5f, Architecture-and-Code-Map@21d6cb0,
Deployment-and-Operations@7e91eea.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 23:17:47 +08:00
QiuSWandClaude Opus 5.5 07800cf41e merge: client API key agent release upload and activate (#384)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 18:08:56 +08:00
QiuSWandClaude Sonnet 5.5 6107c235cf feat(web): 客户端密钥页新增 Agent 版本上传与设为当前动作 (#384)
actionLabels 增加 release_upload、release_activate;勾选设为当前时提示
「设为当前后,所有设备会提示更新」。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 18:07:57 +08:00
QiuSWandClaude Sonnet 5.5 19484f0a68 feat(clientapi): 客户端密钥可上传 Agent APK 并单独授权设为当前 (#384)
devices 模块新增 release_upload(列表/上传)与 release_activate(设为当前)两个显式动作,
不挂在 read/write 上;不开放下载。Upload 创建人统一取操作人(客户端取 AuthorizedBy,
Admin 仍取 JWT 用户)。网关请求体上限仅对 APK 上传端点放宽到 MaxAPKBytes+1MB,其余仍为 16MB。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 18:06:37 +08:00
QiuSWandClaude Opus 5.5 3495de96a2 chore(android): bump Agent to 0.9.70 (83) and drop debug suffix (#370 #379 #375 #380)
The merged #370/#379/#375/#380 builds still reported 0.9.69-373-fix1 (82),
so devices and the in-app updater could not tell them from the old build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:56:17 +08:00
QiuSWandClaude Opus 5.5 ac112b59b5 docs: mirror wiki for #381 #382 #383 and #378 release
Business-Rules-and-Glossary@c8aa5a4, Architecture-and-Code-Map@72fa776,
Deployment-and-Operations@4103d9b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:41:01 +08:00
QiuSWandClaude Opus 5.5 15bab9c989 merge: collection task device filter (#383)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:23:15 +08:00
QiuSWandClaude Opus 5.5 0893b55efc merge: purchase creator and device filters (#382)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:23:15 +08:00
QiuSWandClaude Opus 5.5 c5709a0f3a merge: block cancelled SYB orders from PDD flows (#381)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:23:15 +08:00
QiuSWandClaude Sonnet 5.5 0745b2d894 feat(web): 采集任务页新增设备筛选 (#383)
筛选区在「来源」后新增「设备」下拉(可清空、可搜索,含已停用设备),
查询传 deviceId,重置清空;设备列表加载失败时下拉为空且不影响列表。
批量取消接口不支持设备维度,选择设备筛选时禁用该按钮,避免取消范围超出当前列表。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:20:34 +08:00
QiuSWandClaude Sonnet 5.5 9e54263ee6 feat(web): 采购管理增加设备/创建人筛选与创建人展示 (#382)
- 筛选区新增「设备」「创建人」可清空可搜索下拉,选项来自 filter-options,含未分派/Agent 发起/未记录;参与查询、重置与导出
- 表格在设备列后新增「创建人」列,详情新增创建人与创建来源

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:17:08 +08:00
QiuSWandClaude Sonnet 5.5 ac7f81141c feat(purchase): 记录采购任务创建人与来源,列表增加设备/创建人筛选 (#382)
- 新建时写入一次创建人与来源:Admin/备货/批量创建=admin,客户端密钥=client_api(授权人),Agent 重试=agent(无创建人);幂等重放不改写
- AdminList 新增 deviceId(含 unassigned)与 createdBy(含 agent/unrecorded)筛选,非法值返回 CodeInvalidRequest
- 列表与详情返回 createdBy/createdByName/createdSource
- 新增 GET /purchase-tasks/filter-options 返回实际出现过的设备与创建人

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:14:51 +08:00
QiuSWandClaude Sonnet 5.5 6cc3bb98d0 feat(db): purchase_task 新增 created_by/created_source 迁移 (#382)
只新增可空 created_by(索引)与非空默认空串的 created_source,不回填历史行;
迁移按列/索引存在性守卫,可重复执行。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 17:14:50 +08:00
41 changed files with 2217 additions and 73 deletions
+2 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 82
versionName = "0.9.69"
versionCode = 85
versionName = "0.9.72"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -30,9 +30,6 @@ android {
}
buildTypes {
debug {
versionNameSuffix = "-373-fix1"
}
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@@ -12,11 +12,15 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.ui.AccessibilityPauseText
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
import cn.ilapage.goauto.agent.ui.AgentSettingsFragment
import cn.ilapage.goauto.agent.ui.AgentUiVisibility
import cn.ilapage.goauto.agent.ui.AgentStatusFragment
import cn.ilapage.goauto.agent.ui.TaskHistoryFragment
import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class MainActivity : AppCompatActivity() {
private var selectedTab = TAB_STATUS
@@ -30,6 +34,18 @@ class MainActivity : AppCompatActivity() {
}
}
private var accessibilityDialog: androidx.appcompat.app.AlertDialog? = null
private val accessibilityReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
if (intent?.action != AgentForegroundService.ACTION_ACCESSIBILITY_ALERT) return
val readiness = runCatching {
AccessibilityReadiness.valueOf(intent.getStringExtra(AgentForegroundService.EXTRA_ACCESSIBILITY_READINESS).orEmpty())
}.getOrNull() ?: return
showAccessibilityDialog(readiness)
}
}
private var accessibilityReceiverRegistered = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
selectedTab = AgentTabSelectionPolicy.initialTab(
@@ -53,9 +69,15 @@ class MainActivity : AppCompatActivity() {
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
screenPolicyHandler.post(screenPolicyRefresh)
AgentUiVisibility.resumed = true
registerAccessibilityReceiver()
}
override fun onPause() {
AgentUiVisibility.resumed = false
unregisterAccessibilityReceiver()
accessibilityDialog?.dismiss()
accessibilityDialog = null
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
super.onPause()
}
@@ -65,6 +87,38 @@ class MainActivity : AppCompatActivity() {
super.onSaveInstanceState(outState)
}
private fun showAccessibilityDialog(readiness: AccessibilityReadiness) {
accessibilityDialog?.dismiss()
accessibilityDialog = null
val detail = AccessibilityPauseText.recovery(readiness) ?: return // READY:恢复,仅关闭弹窗
accessibilityDialog = MaterialAlertDialogBuilder(this)
.setTitle(AccessibilityPauseText.TITLE)
.setMessage(detail)
.setNegativeButton("稍后", null)
.setPositiveButton("去设置") { _, _ ->
startActivity(android.content.Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
.show()
}
private fun registerAccessibilityReceiver() {
if (accessibilityReceiverRegistered) return
val filter = android.content.IntentFilter(AgentForegroundService.ACTION_ACCESSIBILITY_ALERT)
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(accessibilityReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(accessibilityReceiver, filter)
}
accessibilityReceiverRegistered = true
}
private fun unregisterAccessibilityReceiver() {
if (!accessibilityReceiverRegistered) return
runCatching { unregisterReceiver(accessibilityReceiver) }
accessibilityReceiverRegistered = false
}
private fun buildContent(): LinearLayout {
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
@@ -560,7 +560,11 @@ class PurchaseLiveAutomation(
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
waitFor(
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
"地址保存后无法返回订单页面,未创建订单",
diagnostic = { addressSaveBackDiagnostic(it, expected, suffix) },
) {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
}
@@ -612,9 +616,21 @@ class PurchaseLiveAutomation(
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
return screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES
return acceptsPurchasePanel(screen)
}
/**
* #388: after an address save PDD may re-render the sheet with the "已选" summary, which the parser
* classifies as QUICK_CONFIRMATION. Accept that type only with the same evidence bar as
* REQUIRED_EVIDENCE (address, payment entry and the single quantity input in one container).
* This only affects panel recognition; address suffix, bottom-row submit target, blocked words
* and spec/price/quantity checks are unchanged.
*/
private fun acceptsPurchasePanel(screen: ParsedPddScreen): Boolean =
screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES ||
(screen.specPanelType == SpecPanelType.QUICK_CONFIRMATION &&
screen.hasRequiredPanelEvidence && screen.requiredEvidenceSameContainer)
private fun restoreFinalEvidenceInCurrentPanel(
initial: UiSnapshot,
expected: String,
@@ -749,16 +765,33 @@ class PurchaseLiveAutomation(
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
Regex("${Regex.escape(suffix)}(?![0-9])").containsMatchIn(normalizeAddressText(value))
private fun waitFor(code: String, message: String, predicate: (UiSnapshot) -> Boolean): UiSnapshot {
private fun waitFor(
code: String,
message: String,
diagnostic: ((UiSnapshot) -> String)? = null,
predicate: (UiSnapshot) -> Boolean,
): UiSnapshot {
var last: UiSnapshot? = null
repeat(50) {
val snapshot = driver.capture()
pageProblem(snapshot)
if (predicate(snapshot)) return snapshot
last = snapshot
pause(200)
}
val sampled = last
if (diagnostic != null && sampled != null) fail(code, "$message [${diagnostic(sampled)}]")
fail(code, message)
}
/** Types, counts and 0/1 flags only; never page text. */
private fun addressSaveBackDiagnostic(snapshot: UiSnapshot, expected: String, suffix: String): String {
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
val saved = if (hasSavedAddressEvidence(snapshot, expected, suffix)) 1 else 0
return "stage=address_save_back;type=${screen.specPanelType};${screen.requiredEvidenceSummary()};" +
"saved=$saved;submitTargets=${finalSubmitTargets(snapshot).size}"
}
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
@@ -888,7 +921,7 @@ class PurchaseLiveAutomation(
return current
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
if (screen.specPanelType !in PURCHASE_CONFIRMATION_PANEL_TYPES) {
if (!acceptsPurchasePanel(screen)) {
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
return emptyList()
}
@@ -166,7 +166,6 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
}
}
@Synchronized
fun record(event: AgentDiagnosticEvent) {
require(event.taskId > 0)
require(event.taskType in setOf("collection", "purchase"))
@@ -79,6 +79,13 @@ import cn.ilapage.goauto.agent.diagnostics.AccessibilitySnapshotSource
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotCapture
import cn.ilapage.goauto.agent.diagnostics.FailureSnapshotPolicy
import cn.ilapage.goauto.agent.diagnostics.SnapshotContext
import cn.ilapage.goauto.agent.ui.AccessibilityAlertAction
import cn.ilapage.goauto.agent.ui.AccessibilityAlertTracker
import cn.ilapage.goauto.agent.ui.AccessibilityDispatchGate
import cn.ilapage.goauto.agent.ui.AccessibilityPauseText
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
import cn.ilapage.goauto.agent.ui.AccessibilityReadinessDetector
import cn.ilapage.goauto.agent.ui.AgentUiVisibility
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import org.json.JSONArray
import org.json.JSONObject
@@ -103,6 +110,7 @@ class AgentForegroundService : Service() {
private var repurchaseSession: RepurchaseSession? = null
private val repurchaseEvidence = java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.ConcurrentHashMap<Long, RepurchaseExecutionEvidence>>()
private val taskMutex = TaskExecutionMutex()
private val accessibilityAlerts = AccessibilityAlertTracker()
private val probeHandoff = PurchaseProbeHandoff()
private val backfillGuard = OrderBackfillGuard(taskMutex)
private val runningTaskId = AtomicReference<Long?>(null)
@@ -443,6 +451,15 @@ class AgentForegroundService : Service() {
private fun scheduleTask(api: AgentApiClient, token: String): String {
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
val readiness = AccessibilityReadinessDetector.current(this)
handleAccessibilityAlert(readiness)
if (!AccessibilityDispatchGate.allowsClaiming(readiness)) {
// 不调用任何领取/查询下一任务接口,任务留在服务端 pending;恢复后下一轮同步自动继续。
cancelIdleReturn("无障碍未就绪")
stateStore.update("ONLINE", AccessibilityPauseText.TITLE, tokenStored = true)
updateNotification(AccessibilityPauseText.TITLE)
return MANUAL_ACCESSIBILITY_NOT_READY
}
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
@@ -489,6 +506,50 @@ class AgentForegroundService : Service() {
return MANUAL_COLLECTION_TASK
}
private fun handleAccessibilityAlert(readiness: AccessibilityReadiness) {
val action = accessibilityAlerts.onReadiness(readiness)
if (action == AccessibilityAlertAction.NONE) return
val manager = getSystemService(NotificationManager::class.java)
if (action == AccessibilityAlertAction.CANCEL) {
manager.cancel(ACCESSIBILITY_ALERT_NOTIFICATION_ID)
} else if (!AgentUiVisibility.resumed) {
postAccessibilityAlertNotification(manager, readiness)
}
// 前台时由 MainActivity 收到广播后弹窗;恢复时同一广播让它关闭弹窗。
sendBroadcast(Intent(ACTION_ACCESSIBILITY_ALERT).apply {
setPackage(packageName)
putExtra(EXTRA_ACCESSIBILITY_READINESS, readiness.name)
})
}
private fun postAccessibilityAlertNotification(manager: NotificationManager, readiness: AccessibilityReadiness) {
// 通知权限被拒时静默跳过,不新增请求流程。
if (!manager.areNotificationsEnabled()) return
runCatching {
val pendingIntent = PendingIntent.getActivity(
this, 1, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, ACCESSIBILITY_ALERT_CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this).setPriority(Notification.PRIORITY_HIGH)
}
val detail = AccessibilityPauseText.recovery(readiness)
manager.notify(
ACCESSIBILITY_ALERT_NOTIFICATION_ID,
builder.setSmallIcon(android.R.drawable.stat_sys_warning)
.setContentTitle(AccessibilityPauseText.TITLE)
.setContentText(detail)
.setStyle(Notification.BigTextStyle().bigText(detail))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build(),
)
}
}
private fun publishManualCheckResult(result: String) {
sendBroadcast(Intent(ACTION_CHECK_RESULT).apply {
setPackage(packageName)
@@ -1760,6 +1821,9 @@ class AgentForegroundService : Service() {
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Agent 连接与心跳", NotificationManager.IMPORTANCE_LOW),
)
manager.createNotificationChannel(
NotificationChannel(ACCESSIBILITY_ALERT_CHANNEL_ID, "无障碍未就绪提醒", NotificationManager.IMPORTANCE_HIGH),
)
}
}
@@ -1877,6 +1941,9 @@ class AgentForegroundService : Service() {
const val EXTRA_CURRENT_PAGE_MESSAGE = "current_page_message"
const val EXTRA_REPLACEMENT_ORIGIN_TYPE = "replacement_origin_type"
const val EXTRA_REPLACEMENT_ORIGIN_TASK_ID = "replacement_origin_task_id"
const val ACTION_ACCESSIBILITY_ALERT = "cn.ilapage.goauto.agent.ACCESSIBILITY_ALERT"
const val EXTRA_ACCESSIBILITY_READINESS = "accessibility_readiness"
const val MANUAL_ACCESSIBILITY_NOT_READY = "accessibility_not_ready"
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
@@ -1889,6 +1956,8 @@ class AgentForegroundService : Service() {
const val MANUAL_COLLECTION_COOLDOWN_PREFIX = "collection_cooldown:"
private const val CHANNEL_ID = "agent_connection"
private const val NOTIFICATION_ID = 1001
private const val ACCESSIBILITY_ALERT_CHANNEL_ID = "agent_accessibility_alert"
private const val ACCESSIBILITY_ALERT_NOTIFICATION_ID = 1002
private const val HEARTBEAT_SECONDS = 15L
private const val TASK_WAKE_LOCK_TIMEOUT_MILLIS = 5 * 60 * 1000L
private const val IDLE_RETURN_WAKE_LOCK_TIMEOUT_MILLIS = 30_000L
@@ -0,0 +1,45 @@
package cn.ilapage.goauto.agent.ui
/** 无障碍未就绪时暂停领取任务(#385)。纯策略,便于单元测试。 */
object AccessibilityDispatchGate {
fun allowsClaiming(readiness: AccessibilityReadiness): Boolean = readiness == AccessibilityReadiness.READY
}
/** 暂停提示的文案,状态 Tab、手动检查、弹窗和通知共用。 */
object AccessibilityPauseText {
const val TITLE = "无障碍未就绪,已暂停领取任务"
fun recovery(readiness: AccessibilityReadiness): String? = when (readiness) {
AccessibilityReadiness.READY -> null
AccessibilityReadiness.ENABLED_WAITING -> "请到 设置 → 无障碍,把 GoAuto 关闭后重新打开"
AccessibilityReadiness.DISABLED -> "请到 设置 → 无障碍 开启 GoAuto"
}
}
enum class AccessibilityAlertAction { NONE, PROMPT, CANCEL }
/**
* 只在「就绪 → 未就绪」时提示一次;持续未就绪(含 DISABLED 与 ENABLED_WAITING 互转)不重复;
* 恢复就绪时撤销提示,之后再次失效重新提示。进程内状态,不持久化。
* 进程启动后的第一次观察视为转变:首次未就绪会提示,首次就绪会撤销可能残留的旧通知。
*/
class AccessibilityAlertTracker {
private var previous: AccessibilityReadiness? = null
@Synchronized
fun onReadiness(current: AccessibilityReadiness): AccessibilityAlertAction {
val last = previous
previous = current
val ready = current == AccessibilityReadiness.READY
return when {
ready && last != AccessibilityReadiness.READY -> AccessibilityAlertAction.CANCEL
!ready && (last == null || last == AccessibilityReadiness.READY) -> AccessibilityAlertAction.PROMPT
else -> AccessibilityAlertAction.NONE
}
}
}
/** Agent 界面是否在前台(MainActivity onResume/onPause 维护),服务据此选择弹窗或通知。 */
object AgentUiVisibility {
@Volatile var resumed: Boolean = false
}
@@ -23,7 +23,7 @@ import java.util.Date
internal object ManualTaskCheckPolicy {
fun blockedMessage(stateCode: String, currentTaskId: Long?, readiness: AccessibilityReadiness): String? = when {
currentTaskId != null || stateCode == "BUSY" -> "当前任务执行中,无需重复检查"
readiness != AccessibilityReadiness.READY -> "请先到“设置”开启采集采购助手"
readiness != AccessibilityReadiness.READY -> AccessibilityPauseText.recovery(readiness)
stateCode == "CONFIG_REQUIRED" -> "请先到“设置”配置服务地址"
stateCode == "AUTH_ERROR" -> "设备身份校验失败,请先检查设置"
else -> null
@@ -38,6 +38,7 @@ internal object ManualTaskCheckPolicy {
result == AgentForegroundService.MANUAL_EMPTY -> "暂无新任务,Agent 会继续自动检查"
result == AgentForegroundService.MANUAL_COLLECTION_TASK -> "已领取采集任务"
result == AgentForegroundService.MANUAL_PURCHASE_TASK -> "已领取采购任务"
result == AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY -> AccessibilityPauseText.TITLE
result == AgentForegroundService.MANUAL_BUSY -> "当前任务执行中,无需重复检查"
result == AgentForegroundService.MANUAL_CONFIG_REQUIRED -> "请先到“设置”配置服务地址"
result == AgentForegroundService.MANUAL_AUTH_ERROR -> "设备身份校验失败,请先检查设置"
@@ -52,10 +53,17 @@ internal object AgentStatusTextPolicy {
"最近心跳:$heartbeat",
).joinToString("\n")
fun accessibilityDetail(readiness: AccessibilityReadiness): String? = when (readiness) {
AccessibilityReadiness.READY -> null
AccessibilityReadiness.ENABLED_WAITING -> "请到“设置”检查无障碍服务"
AccessibilityReadiness.DISABLED -> "请到“设置”开启"
fun accessibilityDetail(readiness: AccessibilityReadiness): String? = AccessibilityPauseText.recovery(readiness)
fun accessibilityTitle(readiness: AccessibilityReadiness): String = when (readiness) {
AccessibilityReadiness.READY -> "已开启"
AccessibilityReadiness.ENABLED_WAITING -> "未连接,任务已暂停"
AccessibilityReadiness.DISABLED -> "未开启,任务已暂停"
}
fun accessibilityColor(readiness: AccessibilityReadiness): Int = when (readiness) {
AccessibilityReadiness.READY -> R.color.agent_primary_light
else -> R.color.agent_error
}
}
@@ -109,6 +117,7 @@ class AgentStatusFragment : Fragment() {
AgentForegroundService.MANUAL_CONFIG_REQUIRED,
AgentForegroundService.MANUAL_NETWORK_ERROR,
AgentForegroundService.MANUAL_ERROR,
AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY,
),
)
}
@@ -286,20 +295,8 @@ class AgentStatusFragment : Fragment() {
"${if (purchase) "采购" else "采集"}任务 ${if (purchase) "CG-" else "#"}$id"
} ?: "等待任务"
val readiness = AccessibilityReadinessDetector.current(context)
when (readiness) {
AccessibilityReadiness.READY -> {
accessibilityTitle.text = "已开启"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_primary_light))
}
AccessibilityReadiness.ENABLED_WAITING -> {
accessibilityTitle.text = "等待连接"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_warning))
}
AccessibilityReadiness.DISABLED -> {
accessibilityTitle.text = "未开启"
accessibilityTitle.setTextColor(context.getColor(R.color.agent_error))
}
}
accessibilityTitle.text = AgentStatusTextPolicy.accessibilityTitle(readiness)
accessibilityTitle.setTextColor(context.getColor(AgentStatusTextPolicy.accessibilityColor(readiness)))
accessibilityDetail.text = AgentStatusTextPolicy.accessibilityDetail(readiness).orEmpty()
accessibilityDetail.visibility = if (accessibilityDetail.text.isEmpty()) View.GONE else View.VISIBLE
val mediaReadiness = MediaPermissionPolicy.current(context)
@@ -0,0 +1,53 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.AccessibilityAlertAction
import cn.ilapage.goauto.agent.ui.AccessibilityAlertTracker
import cn.ilapage.goauto.agent.ui.AccessibilityDispatchGate
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AccessibilityDispatchGateTest {
@Test fun pausesClaimingWhenDisabledOrWaiting() {
assertFalse(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.DISABLED))
assertFalse(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.ENABLED_WAITING))
}
@Test fun allowsClaimingWhenReady() {
assertTrue(AccessibilityDispatchGate.allowsClaiming(AccessibilityReadiness.READY))
}
@Test fun promptsOnceWhenReadyBecomesNotReady() {
val tracker = AccessibilityAlertTracker()
assertEquals(AccessibilityAlertAction.CANCEL, tracker.onReadiness(AccessibilityReadiness.READY))
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.READY))
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
}
@Test fun switchingBetweenNotReadyStatesDoesNotPromptAgain() {
val tracker = AccessibilityAlertTracker()
tracker.onReadiness(AccessibilityReadiness.READY)
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.DISABLED))
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
assertEquals(AccessibilityAlertAction.NONE, tracker.onReadiness(AccessibilityReadiness.DISABLED))
}
@Test fun recoveryCancelsAndNextFailurePromptsAgain() {
val tracker = AccessibilityAlertTracker()
tracker.onReadiness(AccessibilityReadiness.READY)
tracker.onReadiness(AccessibilityReadiness.DISABLED)
assertEquals(AccessibilityAlertAction.CANCEL, tracker.onReadiness(AccessibilityReadiness.READY))
assertEquals(AccessibilityAlertAction.PROMPT, tracker.onReadiness(AccessibilityReadiness.ENABLED_WAITING))
}
@Test fun firstObservationAfterProcessStartPromptsWhenNotReady() {
assertEquals(
AccessibilityAlertAction.PROMPT,
AccessibilityAlertTracker().onReadiness(AccessibilityReadiness.ENABLED_WAITING),
)
}
}
@@ -1,5 +1,6 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.ui.AccessibilityReadiness
import cn.ilapage.goauto.agent.ui.AccessibilityReadinessResolver
import cn.ilapage.goauto.agent.ui.ManualTaskCheckPolicy
@@ -46,9 +47,13 @@ class AccessibilityReadinessTest {
ManualTaskCheckPolicy.blockedMessage("BUSY", 35L, AccessibilityReadiness.READY),
)
assertEquals(
"请先到“设置”开启采集采购助手",
"请到 设置 → 无障碍 开启 GoAuto",
ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.DISABLED),
)
assertEquals(
"请到 设置 → 无障碍,把 GoAuto 关闭后重新打开",
ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.ENABLED_WAITING),
)
assertEquals(null, ManualTaskCheckPolicy.blockedMessage("ONLINE", null, AccessibilityReadiness.READY))
}
@@ -59,5 +64,9 @@ class AccessibilityReadinessTest {
assertEquals("已领取采购任务", ManualTaskCheckPolicy.resultMessage("purchase_task"))
assertEquals("采集间隔中,还需 12 秒", ManualTaskCheckPolicy.resultMessage("collection_cooldown:12"))
assertEquals("检查失败,Agent 将继续自动重试", ManualTaskCheckPolicy.resultMessage("network_error"))
assertEquals(
"无障碍未就绪,已暂停领取任务",
ManualTaskCheckPolicy.resultMessage(AgentForegroundService.MANUAL_ACCESSIBILITY_NOT_READY),
)
}
}
@@ -25,12 +25,21 @@ class AgentStatusTextPolicyTest {
@Test fun hidesReadyDetailButKeepsRecoveryMessages() {
assertNull(AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.READY))
assertEquals(
"请到“设置”检查无障碍服务",
"请到 设置 → 无障碍,把 GoAuto 关闭后重新打开",
AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.ENABLED_WAITING),
)
assertEquals(
"请到“设置”开启",
"请到 设置 → 无障碍 开启 GoAuto",
AgentStatusTextPolicy.accessibilityDetail(AccessibilityReadiness.DISABLED),
)
}
@Test fun accessibilityCardTitleAndColorFollowReadiness() {
assertEquals("已开启", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.READY))
assertEquals("未开启,任务已暂停", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.DISABLED))
assertEquals("未连接,任务已暂停", AgentStatusTextPolicy.accessibilityTitle(AccessibilityReadiness.ENABLED_WAITING))
assertEquals(R.color.agent_primary_light, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.READY))
assertEquals(R.color.agent_error, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.DISABLED))
assertEquals(R.color.agent_error, AgentStatusTextPolicy.accessibilityColor(AccessibilityReadiness.ENABLED_WAITING))
}
}
@@ -0,0 +1,260 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
/**
* #388: after an address save PDD can show the order sheet with the "已选" summary, which the parser classifies as
* QUICK_CONFIRMATION. Purchase accepts it only with the #331 required evidence in one container. All nodes are
* synthetic (derived from [SpecPanelFixtures.sheet]); addresses and phones are fake.
*/
class PurchaseQuickConfirmationAddressTest {
// 1. Full call chain: finalConfirmation passes and submitOrderOnce clicks once on the bottom row leaf.
@Test fun `evidence complete quick confirmation passes final confirmation and submits once on the bottom row`() {
val panel = quick()
assertQuick(panel)
val driver = StaticDriver(panel)
val automation = automation(driver)
val evidence = automation.finalConfirmation(input(), ShippingAddressProof(ADDRESS, SUFFIX))
assertEquals("黑色", evidence.mappedColor)
assertEquals(1960L, evidence.unitPriceCent)
assertTrue(driver.clicked.isEmpty())
automation.submitOrderOnce()
assertEquals(listOf("r/sheet/submit/l/t"), driver.clicked)
val error = runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", error.code)
assertEquals(1, driver.clicked.size)
}
// 2. Chain path 1: save -> address list (non panel) -> one Back -> evidence complete quick panel.
@Test fun `save then address list then one back accepts evidence complete quick confirmation`() {
val driver = FlowDriver(afterSave = "list", afterBack = "quickNew")
assertQuick(driver.quickNew)
val proof = automation(driver).updateShippingAddress(SUFFIX)
assertEquals(ADDRESS, proof.expectedAddress)
assertEquals(1, driver.backCount)
assertEquals(1, driver.saveClicks)
}
// 3. Chain path 2: save lands directly on the evidence complete quick panel; no Back, final_evidence.
@Test fun `save landing directly on evidence complete quick confirmation passes without back`() {
val driver = FlowDriver(afterSave = "quickNew", afterBack = "quickNew")
val diagnostics = mutableListOf<String>()
val proof = automation(driver, diagnostics).updateShippingAddress(SUFFIX)
assertEquals(ADDRESS, proof.expectedAddress)
assertEquals(0, driver.backCount)
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("addressSaveWait;outcome=final_evidence") && it.contains("type=QUICK_CONFIRMATION") })
}
// 4a. Rejected: no address entry (no masked phone) -> panel type not accepted, no target, no click.
@Test fun `quick confirmation without address entry has no submit target`() {
val panel = quick(phone = "测试,示例省示例市")
assertQuick(panel)
assertNoSubmitTarget(panel)
}
// 4b. Rejected: required evidence is not in one panel container.
@Test fun `quick confirmation with evidence in different containers has no submit target`() {
val panel = quick(detachAddress = true)
assertQuick(panel)
assertNoSubmitTarget(panel)
}
// 5. Rejected: bottom row mentions payment method or payment wording.
@Test fun `quick confirmation whose bottom row has payment wording has no submit target`() {
listOf("微信支付 现在买,仅 ¥19.6", "现在买,仅 ¥19.6 立即支付", "先用后付 现在买,仅 ¥19.6").forEach { bottom ->
val panel = quick(bottom = bottom)
assertQuick(panel)
assertNoSubmitTarget(panel)
}
// The alias-only variant must be rejected by the bottom row rule itself.
val aliasError = runCatching { automation(StaticDriver(quick(bottom = "微信支付 现在买,仅 ¥19.6"))).submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", aliasError.code)
}
// 6. Rejected: spec on the panel differs from the task.
@Test fun `quick confirmation with another spec fails final confirmation without clicking`() {
val panel = quick()
assertQuick(panel)
val driver = StaticDriver(panel)
val error = runCatching {
automation(driver).finalConfirmation(input(color = "红色"), ShippingAddressProof(ADDRESS, SUFFIX))
}.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_NOT_MATCHED", error.code)
assertTrue(driver.clicked.isEmpty())
}
// 7. Rejected: unit price outside the allowed range.
@Test fun `quick confirmation with price out of range fails final confirmation without clicking`() {
val panel = quick()
assertQuick(panel)
val driver = StaticDriver(panel)
val error = runCatching {
automation(driver).finalConfirmation(input().copy(maxUnitPriceCent = 1000), ShippingAddressProof(ADDRESS, SUFFIX))
}.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", error.code)
assertTrue(driver.clicked.isEmpty())
}
// 8. Rejected: the task address suffix is not unique on the panel.
@Test fun `quick confirmation with duplicated address suffix still fails`() {
val panel = quick(duplicateAddress = true)
assertQuick(panel)
val driver = StaticDriver(panel)
val error = runCatching {
automation(driver).finalConfirmation(input(), ShippingAddressProof(ADDRESS, SUFFIX))
}.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
assertTrue(driver.clicked.isEmpty())
}
// 9. Diagnostic: still failing after Back carries only structural markers, no page text.
@Test fun `address save back timeout carries structured diagnostic without page text`() {
val driver = FlowDriver(afterSave = "list", afterBack = "quickBlocked")
assertQuick(driver.quickBlocked)
val error = runCatching { automation(driver).updateShippingAddress(SUFFIX) }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
val message = error.message.orEmpty()
assertTrue(message, message.startsWith("地址保存后无法返回订单页面,未创建订单 [stage=address_save_back;type=QUICK_CONFIRMATION;"))
assertTrue(message, message.contains("saved=1;submitTargets=0]"))
assertEquals(1, driver.backCount)
listOf("测试路", "139", "示例", ADDRESS, "微信").forEach { assertFalse("$it in $message", message.contains(it)) }
}
private fun automation(driver: PurchaseUiDriver, diagnostics: MutableList<String> = mutableListOf()) =
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add)
private fun assertQuick(snapshot: UiSnapshot) {
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
assertEquals(SpecPanelType.QUICK_CONFIRMATION, screen.specPanelType)
}
private fun assertNoSubmitTarget(panel: UiSnapshot) {
val driver = StaticDriver(panel)
val diagnostics = mutableListOf<String>()
val error = runCatching { automation(driver, diagnostics).submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException
// Payment wording may already stop the page earlier (PAYMENT_FORBIDDEN); either way nothing is clicked.
assertTrue(error.code, error.code in setOf("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "PURCHASE_PAYMENT_FORBIDDEN"))
assertTrue(driver.clicked.isEmpty())
}
private fun input(color: String = "黑色") = PurchaseExecutionInput(
taskId = 101, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=101",
goodsId = "101", mappedColor = color, mappedSize = "XL", quantity = 1,
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = SUFFIX,
)
/** Quick confirmation panel: summary, close, +/- and input, payment row, address row and a full-width bottom buy row. */
private fun quick(
address: String = ADDRESS,
bottom: String = "现在买,仅 ¥19.6",
phone: String = "测试,${SpecPanelFixtures.FAKE_PHONE},示例省示例市",
detachAddress: Boolean = false,
duplicateAddress: Boolean = false,
): UiSnapshot {
val base = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色"), sizeLabels = listOf("XL")))
val addrPrefix = "r/sheet/body/addr"
val nodes = base.nodes.map { node ->
var n = when (node.path) {
"r/sheet/body/summary" -> node.copy(text = "已选 黑色 XL")
"r/sheet/body/addr/a/detail" -> node.copy(text = address)
"r/sheet/body/addr/a/b/c/phone" -> node.copy(text = phone)
"r/sheet/submit/l/t" -> node.copy(text = bottom)
else -> node
}
if (detachAddress && (n.path == addrPrefix || n.path.startsWith("$addrPrefix/"))) {
n = n.copy(
path = n.path.replaceFirst(addrPrefix, "r/addrwrap/addr"),
parentPath = n.parentPath?.let { if (it == "r/sheet/body") "r/addrwrap" else it.replaceFirst(addrPrefix, "r/addrwrap/addr") },
)
}
n
}.toMutableList()
if (detachAddress) {
nodes += SnapshotNode("r/addrwrap", "r", "", null, null, "android.widget.FrameLayout", NodeBounds(0, 330, 1080, 484), false, false, false, false, true, true)
}
if (duplicateAddress) {
nodes += SnapshotNode("r/sheet/body/dup", "r/sheet/body", address, null, null, "android.widget.TextView", NodeBounds(36, 1000, 1000, 1060), false, false, false, false, true, true)
}
return base.copy(nodes = nodes)
}
private class StaticDriver(private val snapshot: UiSnapshot) : PurchaseUiDriver {
val clicked = mutableListOf<String>()
override fun capture() = snapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult { clicked += target.path; return FreshActionResult.SUCCESS }
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
override fun backPurchase(): Boolean = error("unexpected back")
}
/** confirmation (old address) -> list -> edit -> save -> [afterSave] -> Back -> [afterBack]. */
private class FlowDriver(private val afterSave: String, private val afterBack: String) : PurchaseUiDriver {
private var page = "quickOld"
private var address = "测试路1号-old"
var backCount = 0
var saveClicks = 0
val quickNew: UiSnapshot get() = quickFor(ADDRESS)
val quickBlocked: UiSnapshot get() = quickFor(ADDRESS, bottom = "微信支付 现在买,仅 ¥19.6")
private fun quickFor(addr: String, bottom: String = "现在买,仅 ¥19.6") =
PurchaseQuickConfirmationAddressTest().quick(address = addr, bottom = bottom)
override fun capture(): UiSnapshot = when (page) {
"quickOld" -> quickFor(address)
"quickNew" -> quickFor(address)
"quickBlocked" -> quickFor(address, bottom = "微信支付 现在买,仅 ¥19.6")
"list" -> snapshot(listOf(
node("title", "收货地址"),
node("modify-parent", "", clickable = true),
node("modify", "修改", parentPath = "modify-parent"),
node("address", address),
))
else -> snapshot(listOf(
node("title", "修改收货地址"),
node("recipient-label", "收货人", bounds = NodeBounds(20, 200, 160, 260)),
node("editor-recipient", "测试收货人", className = "android.widget.EditText", bounds = NodeBounds(180, 190, 900, 270)),
node("phone-label", "手机号", bounds = NodeBounds(20, 300, 160, 360)),
node("editor-phone", "13800000000", className = "android.widget.EditText", bounds = NodeBounds(180, 290, 900, 370)),
node("detail", "详细地址", bounds = NodeBounds(20, 400, 160, 460)),
node("editor-address", address, className = "android.widget.EditText", bounds = NodeBounds(180, 390, 900, 480)),
node("save-parent", "", clickable = true), node("save", "保存", parentPath = "save-parent"),
))
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
when {
target.label.contains(SpecPanelFixtures.FAKE_PHONE) -> page = "list"
target.label == "修改" -> page = "edit"
target.label == "保存" -> { saveClicks++; page = afterSave }
else -> error("unexpected click ${target.path}")
}
return FreshActionResult.SUCCESS
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS }
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
override fun backPurchase(): Boolean { backCount++; page = afterBack; return true }
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(SpecPanelFixtures.PDD, SpecPanelFixtures.ACTIVITY, nodes)
private fun node(
path: String, text: String, clickable: Boolean = false, className: String = "android.widget.TextView",
bounds: NodeBounds = NodeBounds(0, 0, 500, 100), parentPath: String? = null,
) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, false, false, false, true, true)
}
private companion object {
const val SUFFIX = "_cg101"
const val ADDRESS = "测试路1号_cg101"
}
}
+5 -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: 5508fe99e1040dff61d3bc6dbdf08ce0a1fd02ad
synchronized_at: 2026-10-10T03:10:31Z
wiki_revision: 21d6cb0a4d8829ac2dbc4947e7ef7718f237fd2d
synchronized_at: 2026-10-12T15:15:52Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -150,6 +150,8 @@ Android Portal/Agent
| 管理端闭环页面 | `web/src/views/goauto/` |
| 备货采购服务端路径 | `purchase_task.task_type` 与迁移 `1787885300000_stock_purchase.go`;`POST /api/admin/v1/purchase-tasks/stock` 由 `server/app/goauto/purchase/service.go` 校验 PDD 当前可选规格并固化 `direct_select`,复用既有任务状态机和设备/账号互斥;重试、替换和 SYB 回填显式排除 `stock` |
| Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 |
| 采购任务创建人与列表筛选(#382) | 迁移 `1791700000000_purchase_task_creator.go` 新增 `purchase_task.created_by`(索引)、`created_source`;`purchase/handler.go` `creatorFromContext` 在 Admin 创建/备货/批量创建与客户端密钥请求时写入,Agent 重试路径 `retry.go` `batchRetryCreate` 写 `agent`;`GET /api/admin/v1/purchase-tasks` 新增 `deviceId`(数字或 `unassigned`)、`createdBy`(数字、`agent`、`unrecorded`),列表/详情返回 `createdBy`、`createdByName`、`createdSource`;`GET /api/admin/v1/purchase-tasks/filter-options`(`admin_query.go`)返回任务中出现过的设备与创建人 |
| SYB 已取消订单拦截(#381) | `purchase/return_match_block.go` `sybCancelled`/`rejectIfSYBCancelled`,错误码 `PURCHASE_SYB_CANCELLED`;拦截点:`service.go` 创建、`batch.go` 预览、`ai_match_eligibility.go`、`collection_eligibility.go`、`task/image_search.go`、`process_stage.go`(阶段 `syb_cancelled`);`returnmatch.participatingStages` 不含该阶段 |
| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key);采购员菜单硬排除,仍只可通过受控 API 查看启用状态 |
| GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 12 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。`web/src/router/index.js` 只保留公共路由,`web/src/store/modules/permission.js` 在登录时根据 `GET /api/v1/menurole` 返回结果注册业务路由与侧栏;退出、凭据异常和切换角色会清除旧动态路由,未授权深链接回退工作台。菜单可见性与 API 授权彼此独立 |
| Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent |
@@ -359,6 +361,7 @@ PddProductDetailCollector
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
- #384:`Inventory` 新增 `GET/POST /api/client/v1/agent-app-releases`(devices 模块 `release_upload`)与 `PUT /api/client/v1/agent-app-releases/current`(`release_activate`),复用 `apprelease.Handler`;不注册下载。`gateway.go` `bodyLimit` 只对 APK 上传放宽到 `apprelease.MaxAPKBytes`+1MB,其余端点仍为 16MB;`apprelease/handler.go` `operatorID` 对客户端请求取 `AuthorizedBy`。Web `views/goauto/client-keys` 动作标签与 `release_activate` 风险提示。
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
+47 -4
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: 01132608beefbecb40b62284482e3b73822fcc87
synchronized_at: 2026-10-12T08:31:15Z
wiki_revision: 5299f7358c54dfcd20ba2a1389946c11e0d8eedb
synchronized_at: 2026-10-13T03:19:48Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -428,6 +428,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
- Agent 采购历史必须分开显示任务原始目标规格与服务端最终映射的执行规格;映射缺失不等于目标规格缺失。本地历史缓存同时保存两组字段,自动化始终只消费执行规格。
- “采购规则”是“采采管理”下与“采集规则”同级的管理员菜单,采购员角色没有菜单和 API 权限。
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
- #384 起,客户端 API Key 也可经显式授权上传与设为当前(见「客户端密钥上传 Agent 版本(#384)」);Admin 流程不变。
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
## Android 采购规格动作安全执行(#214)
@@ -804,7 +805,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- “SYB状态”仅显示来源取消标记:`isCancel=1` 为“已取消”,`0` 为“未取消”,没有有效证据为“未获取”。“未取消”不等于可采购,不表达其他 SYB 业务状态,不由 status/orderStatus、规格解析或 PDD 排除标记推断。
- 正常同步成功取得有效值后覆盖原值,允许由取消变回未取消;失败、字段缺失/null、未知值或空页不改变既有取消状态与取得时间。历史记录不默认未取消。
- 仅更新同步范围内实际导入的明细;超出同步创建日期范围的旧订单保留上次状态,未经同步的显示未获取。此列不是实时核验,列表加载不会请求 SYB。
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截另行评估。
- 本单严格只做展示:不修改创建、批量、重试、Claim/Start、下单边界、采购资格或任务状态,不自动拦截已取消订单,不影响已存在 PDD 订单或回填事实。后续采购拦截已由 #381 实现(见「SYB 已取消订单不进入 PDD 流程(#381)」)。
- “匹配退货”仅是原“匹配到的退货商品”的表头缩短和列宽调整,内容、权限、匹配/使用/取消规则不变。允许少量表格横向滚动。
## SYB 字母尺码与体重连写识别(#358)
@@ -961,7 +962,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## 设备离线诊断的事务边界(#378,实现基线)
Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;未发布或装机;本节不代表线上/手机行为已经更新。
Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378。2026-10-12 Server 随 main `15bab9c` 发布到 122.228.200.167 并执行迁移 `1791600000000`;Android 部分尚未装采购机。
默认心跳 15 秒、离线阈值 45 秒、离线扫描 15 秒不变。扫描逐设备独立事务:锁定最新设备/任务状态后,将设备离线、相关任务和 attempt 收敛、对应状态事件一起提交。锁争用时本设备不作任何部分修改,跳过后继续其他设备,下轮再检查;不自动重试采购或换机。
@@ -992,3 +993,45 @@ Android同步诊断不改变#377的心跳→恢复→补传→必要的一次任
- 打开由 Agent 服务执行:点击时再判断设备是否忙,忙则不打开;不忙时先作废 #373 一次性交接凭证再打开,下一次采购必须重新打开本任务链接。
- 设备忙:重购一轮进行中(含两个任务之间)、订单回填中、当前页采集进行中、设备正在执行任务(含图搜)、有未上传或待恢复的采购结果;任务结束后的冷却期不算忙。忙时按钮置灰并显示原因,状态约每 2 秒发布,与 `repurchaseLocalIdle` 共用同一判断。
- 不暂停领任务,不改调度、心跳和租约;查看期间来了新任务由 Agent 接管屏幕,打开后提示「打开期间如有新任务,Agent 会接管屏幕」。不上传打开结果,不写日志或诊断。
## SYB 已取消订单不进入 PDD 流程(#381)
合并 main `c5709a0`,2026-10-12 随 `15bab9c` 发布到线上(Server + Web),待业务验收。
- `syb_product.syb_is_cancelled = true`(同步取得 `isCancel=1`)的明细:不能创建采购任务(`PURCHASE_SYB_CANCELLED`「SYB 订单已取消,无需采购」),批量采购预览、AI 规格匹配资格、批量采集资格给出同一原因,图搜采集批量逐项拒绝,不参与退货匹配。NULL(未获取)与 false 不拦截,不推断。
- 处理阶段新增 `syb_cancelled`「已取消」,优先级:已有任务或订单结果 → 退货匹配 → 已取消 → 无需采购(#340)→ 其余;同时被过滤规则标记的行显示「已取消」,创建接口仍拒绝(返回码可能是 `PURCHASE_PDD_EXCLUDED`)。
- 同步照常导入并更新取消状态;订单恢复为未取消后阶段与资格自动恢复。
- 不拦截已存在的采购任务(Claim/Start、下单边界与状态机不变);已下单的已取消明细需人工售后。
- 取消状态只随同步日期范围(当天与前一天创建的货运单)刷新,更早订单在 SYB 被取消后仍按上次状态处理。
## 采购任务创建人与采购管理筛选(#382)
合并 main `0893b55`,2026-10-12 发布并执行迁移 `1791700000000`,待业务验收。
- `purchase_task.created_by`(可空)与 `created_source` 只在新建任务时写入一次:Admin 单个创建、备货、批量创建为 `admin` + 登录账号;客户端密钥接口为 `client_api` + 授权该密钥的账号;Agent 重试/重购为 `agent`、创建人为空。幂等重放不改写;Admin 批量重试是原地重置原任务,不新建任务、不改创建人。
- 迁移前的历史任务 `created_source` 为空,显示「未记录」,不回填、不从操作日志反推。
- Admin 采购管理可按设备(含「未分派」)和创建人(含「Agent 发起」「未记录」)筛选,导出沿用当前筛选;列表与详情显示创建人(Agent 显示「Agent」)。不改变可见范围:采购员仍可看到全部任务。
## Admin 采集任务按设备筛选(#383)
合并 main `15bab9c`,2026-10-12 随发布上线,待业务验收。采集任务页新增「设备」筛选(含已停用设备),使用服务端既有 `deviceId` 参数;选择设备时「取消未开始任务」按钮禁用,因为批量取消接口不支持设备维度,避免取消范围超出当前列表。
## 采集任务按设备批量取消与采购员取消范围(#387)
合并 main `df6b4a5`,待发布和业务验收。取代 #383 中「选择设备时按钮禁用」的规则。
- 「取消未开始的任务」按当前筛选(来源、状态、Goods ID、设备)批量取消待执行任务;选了设备时只取消该设备的任务。
- 采购员必须先在「设备」筛选中选择设备(下拉只含归属本人的设备),未选择时提示并停止,不发请求;服务端同样要求非管理员传 `deviceId`,且设备必须归属当前账号,否则返回 `FORBIDDEN`「只能取消归属自己设备的任务」,不取消任何任务。
- 采购员单条取消也只能取消归属本人设备的任务;未指定设备或他人设备的任务一律拒绝(不区分任务当前状态)。
- 管理员不受归属限制:不选设备时取消筛选范围内全部待执行任务(含未指定设备的任务),也可单条取消任意任务;暂不支持「只取消未指定设备的任务」。
- 「设备归属」指 `agent_device.owner_user_id`,以执行取消时的归属为准。
## 客户端密钥上传 Agent 版本(#384)
合并 main `07800cf`,2026-10-12 发布上线,待业务验收。
- `devices` 模块新增两个独立动作,必须在密钥上显式勾选,默认不授予;只读或读写授权不会获得:
- `release_upload`「上传 Agent 版本」:查看版本列表、上传 APK;
- `release_activate`「设置当前 Agent 版本」:把已上传版本设为当前。设为当前后所有设备启动或手动检查时会提示更新,授权页对此显示风险提示。
- 不开放 APK 下载给客户端 API。上传沿用 Admin 的 Manifest 解析、SHA-256、大小与 versionCode 唯一校验;创建人记为授权该密钥的账号。每次调用写客户端审计,不记录文件内容。
- 设备端安装仍由 Android 系统人工确认;签名与已安装版本不一致的 APK 无法覆盖安装,设为当前前须核对目标设备签名。
+10 -2
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: c05446aecd49fb7234f79f5d5e2df68f244166e9
synchronized_at: 2026-10-12T08:31:57Z
wiki_revision: eb957f366d0561df9af8c89b63c6a2406b3acf6c
synchronized_at: 2026-10-13T03:20:31Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -250,6 +250,8 @@ POST /api/admin/v1/collection-tasks/batch
GET /api/admin/v1/collection-tasks
GET /api/admin/v1/collection-tasks/{taskId}
POST /api/admin/v1/collection-tasks/{taskId}/reset
POST /api/admin/v1/collection-tasks/{taskId}/cancel
POST /api/admin/v1/collection-tasks/batch-cancel
DELETE /api/admin/v1/collection-tasks/{taskId}
```
@@ -292,6 +294,12 @@ HTTP 200 响应返回 `successCount`、`failureCount` 和按请求顺序排列
`reset` 请求体和失败任务 `DELETE` 请求体均为 `{"requestId":"uuid"}`。只允许删除 `failed`;重置允许 `completed`、`completed_partial`、`failed`。二者均按 `requestId` 幂等。
取消(#297、#298、#387):
- 单条取消 `POST /collection-tasks/{taskId}/cancel`,请求体 `{"requestId":"uuid"}`。只允许取消 `pending` 且未被设备领取(租约未生效)的任务;已取消返回幂等成功;`running` 或其他状态返回 `TASK_STATE_CONFLICT`。
- 批量取消 `POST /collection-tasks/batch-cancel`,请求体字段均可选:`source`、`status`、`goodsId`(与列表筛选同一匹配方式)、`deviceId`。只取消范围内 `pending` 且未被领取的任务,单次最多 500 条;响应 `cancelledCount`、`cancelledIds`、`skipped[{taskId,status}]`、`hasMore`。
- 权限:管理员不受设备归属限制,`deviceId` 可省略(范围含未指定设备的任务)。非管理员(采购员)批量取消必须传 `deviceId`,缺少时 HTTP 422 `INVALID_REQUEST`「请先选择设备」;设备不存在、未归属或归属他人时 HTTP 403 `FORBIDDEN`「只能取消归属自己设备的任务」,不取消任何任务。非管理员单条取消未指定设备或他人设备的任务同样返回 403 `FORBIDDEN`,不区分任务状态。
## Android:注册与心跳
```http
+52 -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: d08a0dfbb8356f9cd7212d495e4f11beca59e293
synchronized_at: 2026-10-10T03:56:35Z
wiki_revision: 19eaaa2853544e73f4abc5b788b571377e87296b
synchronized_at: 2026-10-13T03:49:33Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -347,6 +347,25 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
### Agent 装机签名对照(2026-10-13 核对)
采购机和测试机上已装的 Agent 签名不同,装包前必须按目标设备选对签名:
| 设备 | 序列号 | 已装证书 SHA-256 | 签名文件 |
|---|---|---|---|
| 采购1(PKG110) | `3B65BD02H7F00000` | `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec` | `D:/OPC/goauto/debug.keystore`(私有,不提交) |
| 采购2(PLY110) | `3B663A00JW500000` | 同采购1 | 同采购1 |
| 测试机(PLY110) | `3B668T01GF400000` | `3eab2527dc3ce592fe22f2f8cc31e4d0fa07dd1fa54b6209948ea6ce9cd7c4f6` | `C:/Users/Qiu/.android/debug.keystore` |
- 本机 Gradle 调试构建产物已经是采购机签名(由上面的 init 脚本强制),可以直接装采购机;装测试机前需用 apksigner 以测试机签名改签。
- 装包前逐台核对:
- `ro.serialno` 确认目标设备(USB 连接的手机不一定是测试机);
- 用 `pm path` 拉取已装 `base.apk`,比较它与待装 APK 的 `apksigner verify --print-certs`;
- 设备空闲,没有待执行或执行中的任务。
- 签名不一致会报 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`,**不得卸载重装**:卸载会清掉设备凭据和本地采购数据。
- 装完后手动打开 Agent,等待心跳恢复;无障碍显示未连接时,在系统设置中把 GoAuto 关闭后重新打开(#385)。
- 2026-10-13 记录:0.9.71(84,main `328afde`)测试机包 SHA256 `10ad5a2b898230897b15a54f73212ae56a11fcf2f73beffc0392321699d7cf00`,采购1 包 SHA256 `3004cda3784182381e98ce9953cabcdba4017eb535c8578a2b317b3a7303e6ab`,均以 `install -r` 覆盖安装,未卸载。
## Android 商品导航诊断(#373,诊断阶段)
@@ -409,6 +428,7 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
- 本地Android验证(cwd仓库根,JDK17及现有Android SDK):`./android/gradlew.bat -p android :app:testDebugUnitTest --tests '*HeartbeatReport*Test' --tests '*AgentTransportTimeoutTest' --tests '*SyncDiagnostic*Test' --tests '*HandledRecoveryDiagnosticTest' --tests '*AgentDiagnosticStoreMigrationTest' --tests '*AgentSyncCycleTest' :app:assembleDebug :app:compileReleaseUnitTestKotlin`。发布前还应执行#377受影响的outbox、运行占位、互斥、重购等回归;SQLite JDBC验证不等同于真机升级。
- Android兼容性测试在`android/build/diagnostics378-client-reports.json`生成纯合成HTTP捕获报告;在server目录将该绝对路径注入`GOAUTO_378_ANDROID_REPORTS`,执行`go test ./app/goauto/device -run TestAndroidGeneratedHeartbeatReports -count=1 -v`,使用真实validator/handler验证三类报告与重放。未设置路径时该项明确skip,不得据此宣称跨端通过;产物不提交Git。
- 线上迁移、发布、重启、装机仍需单独授权;本地构建/夹具不代表真机或线上30分钟心跳验收。
- 2026-10-12 Server 已随 main `15bab9c` 发布并执行迁移 `1791600000000`(新建 `agent_heartbeat_log`、`agent_device_status_event`),重启后心跳历史开始写入;Android 部分未装采购机。
## #372 v3 导出店铺列的纯 Web 发布(2026-10-10)
@@ -419,3 +439,33 @@ Server实现 `d84728d`、Android实现 `dbe7e98`,main 合并记录见 #378;
- 只原子切换 current,不重启 GoAuto/Nginx;切换前后 PID 4786 不变,服务 active。
- 按内容验收:`/` 与 `/login` 返回新 index,10 项 JS/CSS 200,线上导出模块的 SYB 表头含「店铺」,health 200,验证码 code=200,未登录业务接口 401。登录后的实际导出待业务验收。
- 回滚:current 原子切回 `/home/goauto/releases/20261010-4ad59bd-372v2-web`,不需要重启。
## #381/#382/#383 + #378 Server 发布与迁移(2026-10-12)
- 用户授权按顺序推送、合并、发布、更新 Wiki,并要求「如果线上有采集和采购任务进行着,等待无任务时再重启」;发布前用户选择按 main 发布(含此前未发布的 #378 Server 与迁移)。
- main `15bab9c`(#381 `c5709a0`、#382 `0893b55`、#383 `15bab9c`,基于 `16e3d35`)。合并后 `go test ./...`:除既有失败外通过(`sybimport` 基线已有失败;`apprelease` 的 `TestParseBuiltAgentAPKWhenAvailable` 仅在本地存在 Debug APK 时运行,断言旧版本号 0.9.41,属既有过时用例);Linux amd64 `CGO_ENABLED=0` 构建、Web `build:prod` 通过。
- 发布目录 `/home/goauto/releases/20261012-15bab9c-381-382-383`:goauto-server SHA256 `f34864441203e4dfba51c06d0c9db6c6648c7ef34c7c2eb78a61b2d869ff3695`(运行进程一致),index.html SHA256 `a4c85aa8c91bdae2d11eda0756ee7482cafbb83b8e1782d61a2f99df0ff5d853`;config 与上一发布 diff 一致,static/temp/var 链接沿用,旧 js/css 哈希资源保留(`cp -rn`)。
- 备份:`/home/goauto/backups/20261012-381-382-383/database.sql`(0600,single-transaction,`/opt/mysql84-root/usr/bin/mysqldump`;账号缺 PROCESS 权限只导致表空间信息未导出,可加 `--no-tablespaces` 消除提示)。
- 迁移:待执行为 `1791600000000`、`1791700000000`,执行成功(退出码 0),新会话回读 `sys_migration`、新列与索引 `idx_purchase_task_created_by`、两张新表;采购任务 2623 行完好。
- `[必须]` 本机 systemd 为 219,`systemd-run` 不支持 `--wait/--pipe`;`/etc/goauto/goauto.env` 含括号与引号,不能用 shell `source`。迁移使用 root 私有脚本 `/root/goauto-migrate-20261012.sh` 逐行读取 `KEY=VALUE`(去掉成对引号,不经 shell 求值)后执行 `goauto-server migrate -c config/settings.yml`;日志存备份目录,不输出凭据。
- 迁移前与切换前确认无执行中的采集/采购/SYB 同步/AI 解析/自动匹配;1 个 pending 采购任务保持原状。current 原子切换后重启 `goauto.service`,active;Nginx 未改。
- 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 200,验证码 code=200,未登录访问 `yeeke-returns`、`purchase-tasks/filter-options`、带 `deviceId=unassigned` 的采购列表均为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线,`agent_heartbeat_log` 开始写入。
- 回滚:current 原子切回 `/home/goauto/releases/20261010-3d5e964-372v3-web` 并重启;新增表与列保留不影响旧代码,不回退数据库。
## #384 客户端密钥上传 Agent 版本发布(2026-10-12)
- 用户授权建单、实施并部署上线。main `07800cf`(`19484f0` 服务端、`6107c23` Web),无迁移。clientapi/apprelease/clientkey/access 测试、jest client-keys、Linux 构建与 `build:prod` 通过。
- 发布目录 `/home/goauto/releases/20261012-07800cf-384`:goauto-server SHA256 `58f4a0a49f203f46a948262e771841993442bc66999126d5e82ed98d56b62131`(运行进程一致),index.html SHA256 `67c5fcee7063c2d43b61d7ca301390437c16b5f0c34ecaf1072f77fba350098a`;config 一致,static/temp/var 沿用,旧哈希资源保留。无迁移,未做数据库备份。
- 发布时有 1 个采购执行中、32 个排队。按用户要求用服务器脚本 `/root/goauto-switch-384.sh` 等待:采集与采购执行中数量为 0,10 秒后复查仍为 0,再立即确认一次后原子切换 current 并重启(最长等待 3 小时,超时不切换)。18:34:38 切换,`goauto.service` active,日志 `/home/goauto/backups/20261012-384-switch.log`。
- 按内容验收:`/`、`/login` 新 index,10 项 JS/CSS 200,验证码 200,未登录 Admin 接口 401;`/api/client/v1/agent-app-releases` 的 GET/PUT 无密钥 401;下载路径 404(未开放);日志无 panic/fatal/1146/1054。
- Nginx 9527 vhost `client_max_body_size 100m`,超过 100MB 的 APK 会在到达服务前被拒。
- 回滚:current 切回 `/home/goauto/releases/20261012-15bab9c-381-382-383` 并重启。
## #387 采集任务按设备批量取消发布(2026-10-13)
- 用户授权「先把#387发布到线上」。main `5183df3`(实现 `fc5d9ed`,合并 `df6b4a5`);相对上一线上源码 `07800cf`,Server/Web 只增加 #387 的 6 个文件,无迁移、无配置变化,未做数据库备份。`go test ./app/goauto/task/` 通过;Linux amd64 `CGO_ENABLED=0` 构建、Web `build:prod` 通过。
- 发布目录 `/home/goauto/releases/20261013-5183df3-387`:goauto-server SHA256 `85c307cf2393eaf0d6188217ddf0560d3fead3313b72ce98c1e6bf2e6ea44b23`(运行进程一致),index.html SHA256 `dfd9dadfe28fbdb500aa91ce98611850215ad8b53b908b79200f23b205960ba9`;config 与上一发布 diff 一致,static/temp/var 链接沿用,旧 js/css 哈希资源保留(`cp -rn`);目录 0755,已用 Nginx 运行账号 `www` 验证可读。
- 切换沿用等待脚本(`/root/goauto-switch-387.sh`,复制自 #384 脚本):采集与采购执行中数量连续为 0 后原子切换并重启。11:47:24 开始等待,11:47:41 切换,`goauto.service` active;日志 `/home/goauto/backups/20261013-387-switch.log`。当时有 1 个待执行采购任务,保持原状。
- 按内容验收:`/`、`/login` 返回新 index(含 `id="app"`、无欢迎页),10 项 JS/CSS 与新采集任务页资源均 200,验证码 code=200,未登录访问 `collection-tasks/batch-cancel`、`yeeke-returns` 为业务码 401;重启后日志无 panic/fatal/1146/1054;采购1、采购2 心跳恢复在线(Agent 0.9.71)。
- 未做:线上管理员与采购员账号的功能检查(待用户验收)。
- 回滚:current 原子切回 `/home/goauto/releases/20261012-07800cf-384` 并重启(先确认无执行中任务);无数据库变化。
+10 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"go-admin/app/goauto/device"
"go-admin/common/clientprincipal"
"gorm.io/gorm"
)
@@ -72,13 +73,21 @@ func (h Handler) Upload(c *gin.Context) {
if !ok {
return
}
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), uint64(user.GetUserId(c)), stream)
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), operatorID(c), stream)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"code": http.StatusOK, "data": result})
}
// operatorID returns the authorizing user for client API keys and the JWT user otherwise.
func operatorID(c *gin.Context) uint64 {
if id, ok := clientprincipal.Get(c); ok {
return id.AuthorizedBy
}
return uint64(user.GetUserId(c))
}
func (h Handler) SetCurrent(c *gin.Context) {
var req SetCurrentRequest
if err := decode(c, &req); err != nil {
@@ -0,0 +1,152 @@
package apprelease
import (
"archive/zip"
"bytes"
"encoding/binary"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/models"
"go-admin/common/clientprincipal"
)
// buildTestAPK returns a minimal zip whose binary AndroidManifest.xml carries
// the manifest versionCode/versionName attributes that parseAPK reads.
func buildTestAPK(t *testing.T, versionCode uint32, versionName string) []byte {
t.Helper()
pool := []string{"manifest", "versionCode", "versionName", versionName}
var strs bytes.Buffer
offsets := make([]uint32, len(pool))
for i, s := range pool {
offsets[i] = uint32(strs.Len())
strs.WriteByte(byte(len(s)))
strs.WriteByte(byte(len(s)))
strs.WriteString(s)
strs.WriteByte(0)
}
for strs.Len()%4 != 0 {
strs.WriteByte(0)
}
headerSize := 28
poolSize := headerSize + len(pool)*4 + strs.Len()
var m bytes.Buffer
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
le(uint16(0x0003))
le(uint16(8))
le(uint32(0)) // total size, patched below
le(uint16(0x0001))
le(uint16(headerSize))
le(uint32(poolSize))
le(uint32(len(pool)))
le(uint32(0))
le(uint32(0x100))
le(uint32(headerSize + len(pool)*4))
le(uint32(0))
for _, o := range offsets {
le(o)
}
m.Write(strs.Bytes())
attrs := 2
le(uint16(0x0102))
le(uint16(16))
le(uint32(36 + attrs*20))
le(uint32(1)) // line
le(uint32(0xffffffff)) // comment
le(uint32(0xffffffff)) // ns
le(uint32(0)) // name -> manifest
le(uint16(20)) // attributeStart
le(uint16(20)) // attributeSize
le(uint16(attrs))
le(uint16(0))
le(uint16(0))
le(uint16(0))
attr := func(name uint32, raw uint32, typ byte, data uint32) {
le(uint32(0xffffffff))
le(name)
le(raw)
le(uint16(8))
le(byte(0))
le(typ)
le(data)
}
attr(1, 0xffffffff, 0x10, versionCode)
attr(2, 3, 0x03, 3)
raw := m.Bytes()
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
var out bytes.Buffer
zw := zip.NewWriter(&out)
w, err := zw.Create("AndroidManifest.xml")
if err != nil {
t.Fatal(err)
}
_, _ = w.Write(raw)
if err = zw.Close(); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
func uploadRequest(t *testing.T, filename string, content []byte) *http.Request {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
part, _ := mw.CreateFormFile("file", filename)
_, _ = part.Write(content)
_ = mw.WriteField("releaseNotes", "test")
_ = mw.Close()
req := httptest.NewRequest("POST", "/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
return req
}
func TestUploadCreatorUsesClientAuthorizerOrJWTUser(t *testing.T) {
gin.SetMode(gin.TestMode)
db := releaseTestDB(t)
h := Handler{DB: db, StorageDir: t.TempDir()}
router := gin.New()
router.POST("/upload", func(c *gin.Context) {
if c.GetHeader("X-Test-Client") != "" {
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 5, RequestID: "r", AuthorizedBy: 42})
} else {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"identity": float64(7)})
}
h.Upload(c)
})
send := func(client bool, filename string, content []byte) *httptest.ResponseRecorder {
req := uploadRequest(t, filename, content)
if client {
req.Header.Set("X-Test-Client", "1")
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
if rr := send(true, "agent.apk", buildTestAPK(t, 101, "1.0.1")); rr.Code != 201 {
t.Fatalf("client upload %d %s", rr.Code, rr.Body.String())
}
if rr := send(false, "agent.apk", buildTestAPK(t, 102, "1.0.2")); rr.Code != 201 {
t.Fatalf("admin upload %d %s", rr.Code, rr.Body.String())
}
var rows []models.AgentAppRelease
if err := db.Order("version_code").Find(&rows).Error; err != nil || len(rows) != 2 {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
if rows[0].CreatedBy != 42 || rows[1].CreatedBy != 7 {
t.Fatalf("createdBy client=%d admin=%d", rows[0].CreatedBy, rows[1].CreatedBy)
}
if rr := send(true, "agent.zip", buildTestAPK(t, 103, "1.0.3")); rr.Code != 422 {
t.Fatalf("non-apk accepted %d", rr.Code)
}
rr := send(true, "again.apk", buildTestAPK(t, 101, "1.0.1"))
var res struct{ Code string }
_ = json.Unmarshal(rr.Body.Bytes(), &res)
if rr.Code != 409 || res.Code != "AGENT_RELEASE_CONFLICT" {
t.Fatalf("duplicate versionCode %d %s", rr.Code, rr.Body.String())
}
}
+11 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"go-admin/app/goauto/apprelease"
"go-admin/app/goauto/clientkey"
"go-admin/common/clientprincipal"
"gorm.io/gorm"
@@ -80,7 +81,7 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
return
}
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit(e))
original := c.Writer
buffer := &responseBuffer{ResponseWriter: original, status: 200}
c.Writer = buffer
@@ -104,6 +105,15 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
}
}
// bodyLimit keeps 16MB for every endpoint except the Agent APK upload, which
// needs room for the APK itself plus multipart overhead (same bound as Admin).
func bodyLimit(e Endpoint) int64 {
if e.Method == "POST" && e.Path == "/agent-app-releases" {
return apprelease.MaxAPKBytes + (1 << 20)
}
return 16 << 20
}
type responseBuffer struct {
gin.ResponseWriter
body bytes.Buffer
+250
View File
@@ -0,0 +1,250 @@
package clientapi
import (
"archive/zip"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go-admin/app/goauto/apprelease"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/models"
)
// releaseAPK builds a minimal APK whose binary manifest carries the version fields.
func releaseAPK(t *testing.T, code uint32, name string) []byte {
t.Helper()
pool := []string{"manifest", "versionCode", "versionName", name}
var strs bytes.Buffer
offsets := make([]uint32, len(pool))
for i, s := range pool {
offsets[i] = uint32(strs.Len())
strs.WriteByte(byte(len(s)))
strs.WriteByte(byte(len(s)))
strs.WriteString(s)
strs.WriteByte(0)
}
for strs.Len()%4 != 0 {
strs.WriteByte(0)
}
var m bytes.Buffer
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
le(uint16(3))
le(uint16(8))
le(uint32(0))
le(uint16(1))
le(uint16(28))
le(uint32(28 + len(pool)*4 + strs.Len()))
le(uint32(len(pool)))
le(uint32(0))
le(uint32(0x100))
le(uint32(28 + len(pool)*4))
le(uint32(0))
for _, o := range offsets {
le(o)
}
m.Write(strs.Bytes())
le(uint16(0x0102))
le(uint16(16))
le(uint32(36 + 40))
le(uint32(1))
le(uint32(0xffffffff))
le(uint32(0xffffffff))
le(uint32(0))
le(uint16(20))
le(uint16(20))
le(uint16(2))
le(uint16(0))
le(uint16(0))
le(uint16(0))
attr := func(n, raw uint32, typ byte, data uint32) {
le(uint32(0xffffffff))
le(n)
le(raw)
le(uint16(8))
le(byte(0))
le(typ)
le(data)
}
attr(1, 0xffffffff, 0x10, code)
attr(2, 3, 0x03, 3)
raw := m.Bytes()
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
var out bytes.Buffer
zw := zip.NewWriter(&out)
w, _ := zw.Create("AndroidManifest.xml")
_, _ = w.Write(raw)
_ = zw.Close()
return out.Bytes()
}
func multipartUpload(content []byte, filename string) (*bytes.Buffer, string) {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
part, _ := mw.CreateFormFile("file", filename)
_, _ = part.Write(content)
_ = mw.Close()
return &body, mw.FormDataContentType()
}
func TestAgentReleaseActionsAreSeparateAndExplicit(t *testing.T) {
var devices clientkey.Module
for _, m := range Catalog(Inventory()) {
if m.Key == "devices" {
devices = m
}
}
if strings.Join(devices.Actions, ",") != "release_activate,release_upload" || devices.Writable {
t.Fatalf("devices catalog=%+v", devices)
}
db, s := fixture(t)
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
t.Fatal(err)
}
h := apprelease.Handler{DB: db, StorageDir: t.TempDir()}
router := gin.New()
found := 0
for _, e := range Inventory() {
if !strings.HasPrefix(e.Path, "/agent-app-releases") {
continue
}
found++
switch e.Method + e.Path {
case "GET/agent-app-releases":
e.Handle = h.List
case "POST/agent-app-releases":
e.Handle = h.Upload
case "PUT/agent-app-releases/current":
e.Handle = h.SetCurrent
default:
t.Fatalf("unexpected release route %s", keyFor(e))
}
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
}
if found != 3 {
t.Fatalf("release routes=%d", found)
}
newKey := func(grant clientkey.Grant) string {
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{grant}, 42)
if err != nil {
t.Fatal(err)
}
return token
}
do := func(token, method, path, contentType string, body *bytes.Buffer) *httptest.ResponseRecorder {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, "https://example.test/api/client/v1"+path, body)
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
upload := func(token string, code uint32) *httptest.ResponseRecorder {
body, ct := multipartUpload(releaseAPK(t, code, "9.9.9"), "agent.apk")
return do(token, "POST", "/agent-app-releases", ct, body)
}
setCurrent := func(token string, id uint64) *httptest.ResponseRecorder {
raw, _ := json.Marshal(map[string]any{"requestId": uuid.NewString(), "releaseId": id})
return do(token, "PUT", "/agent-app-releases/current", "application/json", bytes.NewBuffer(raw))
}
// A key with only devices read must be rejected everywhere (devices has no
// write capability, so a write grant cannot even be created).
if _, _, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Write: true}}, 42); err == nil {
t.Fatal("devices write grant must be invalid")
}
for name, token := range map[string]string{
"devices read": newKey(clientkey.Grant{Module: "devices"}),
} {
if rr := do(token, "GET", "/agent-app-releases", "", nil); rr.Code != 403 {
t.Fatalf("%s list %d", name, rr.Code)
}
if rr := upload(token, 1); rr.Code != 403 {
t.Fatalf("%s upload %d", name, rr.Code)
}
if rr := setCurrent(token, 1); rr.Code != 403 {
t.Fatalf("%s activate %d", name, rr.Code)
}
}
uploader := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_upload"}})
if rr := do(uploader, "GET", "/agent-app-releases", "", nil); rr.Code != 200 {
t.Fatalf("uploader list %d %s", rr.Code, rr.Body.String())
}
if rr := upload(uploader, 201); rr.Code != 201 {
t.Fatalf("uploader upload %d %s", rr.Code, rr.Body.String())
}
var row models.AgentAppRelease
if err := db.First(&row).Error; err != nil || row.CreatedBy != 42 {
t.Fatalf("createdBy=%d err=%v", row.CreatedBy, err)
}
if rr := setCurrent(uploader, row.ID); rr.Code != 403 {
t.Fatalf("uploader activated %d", rr.Code)
}
activator := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_activate"}})
if rr := upload(activator, 202); rr.Code != 403 {
t.Fatalf("activator uploaded %d", rr.Code)
}
if rr := setCurrent(activator, row.ID); rr.Code != 200 {
t.Fatalf("activator set current %d %s", rr.Code, rr.Body.String())
}
for _, e := range Inventory() {
if strings.Contains(e.Path, "download") {
t.Fatal("download must not be a client route")
}
}
}
func TestAgentReleaseUploadBodyLimitIsScopedToUploadEndpoint(t *testing.T) {
if got := bodyLimit(Endpoint{Method: "POST", Path: "/agent-app-releases"}); got != apprelease.MaxAPKBytes+(1<<20) {
t.Fatalf("upload limit %d", got)
}
for _, e := range Inventory() {
if e.Method == "POST" && e.Path == "/agent-app-releases" {
continue
}
if got := bodyLimit(e); got != 16<<20 {
t.Fatalf("%s limit %d", keyFor(e), got)
}
}
}
func TestAgentReleaseUploadAcceptsBodyAbove16MB(t *testing.T) {
db, s := fixture(t)
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
t.Fatal(err)
}
e := Endpoint{Method: "POST", Path: "/agent-app-releases", Module: "devices", Capability: "release_upload", Handle: apprelease.Handler{DB: db, StorageDir: t.TempDir()}.Upload}
router := gin.New()
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Actions: []string{"release_upload"}}}, 42)
if err != nil {
t.Fatal(err)
}
// 17MB of non-APK data passes the transport limit and fails only at APK parsing.
body, ct := multipartUpload(bytes.Repeat([]byte{'x'}, 17<<20), "big.apk")
req := httptest.NewRequest(http.MethodPost, "https://example.test/api/client/v1"+e.Path, body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", ct)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 422 || !strings.Contains(rr.Body.String(), "文件不是有效 APK") {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var audits []clientkey.Audit
if err = db.Where("event = ?", "request").Find(&audits).Error; err != nil || len(audits) != 1 || audits[0].Status != 422 {
t.Fatalf("audit=%+v err=%v", audits, err)
}
}
+5
View File
@@ -5,6 +5,7 @@ package clientapi
import (
"go-admin/app/goauto/access"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/apprelease"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/device"
"go-admin/app/goauto/product"
@@ -39,9 +40,13 @@ func Inventory() []Endpoint {
pr := purchaserule.Handler{}
t := task.Handler{}
buy := purchase.Handler{}
release := apprelease.Handler{}
return []Endpoint{
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
{"GET", "/agent-app-releases", access.ModuleDevices, "release_upload", release.List},
{"POST", "/agent-app-releases", access.ModuleDevices, "release_upload", release.Upload},
{"PUT", "/agent-app-releases/current", access.ModuleDevices, "release_activate", release.SetCurrent},
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
+7 -2
View File
@@ -170,8 +170,13 @@ type PurchaseTask struct {
CancelReason *string `json:"cancelReason" gorm:"size:500"`
ErrorCode *string `json:"errorCode" gorm:"size:64;index"`
ErrorMessage *string `json:"errorMessage" gorm:"size:1000"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// CreatedBy/CreatedSource 只在新建任务时写入一次(#382):admin=管理端账号,
// client_api=客户端密钥的授权人,agent=设备重试(CreatedBy 为空);空串表示
// 迁移前的历史任务,不回填。
CreatedBy *uint64 `json:"createdBy" gorm:"index"`
CreatedSource string `json:"createdSource" gorm:"size:16;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PurchaseTask) TableName() string { return "purchase_task" }
+161 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"time"
"unicode"
@@ -29,6 +31,35 @@ type AdminListRequest struct {
// SYB products createdFrom/createdTo filter) over order_submitted_at.
OrderSubmittedFrom string
OrderSubmittedTo string
// DeviceID is a numeric device id or "unassigned" (#382).
DeviceID string
// CreatedBy is a numeric user id, "agent" or "unrecorded" (#382).
CreatedBy string
}
const (
adminFilterUnassigned = "unassigned"
adminFilterAgent = "agent"
adminFilterUnrecorded = "unrecorded"
)
// parseAdminIDFilter returns (id, keyword, error); at most one of id/keyword is
// set. Empty input means the filter is absent.
func parseAdminIDFilter(raw, label string, keywords ...string) (uint64, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, "", nil
}
for _, keyword := range keywords {
if raw == keyword {
return 0, keyword, nil
}
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
return 0, "", fail(CodeInvalidRequest, label+" 无效")
}
return id, "", nil
}
// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
@@ -78,6 +109,9 @@ type AdminTaskItem struct {
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
CreatedBy *uint64 `json:"createdBy,omitempty"`
CreatedByName string `json:"createdByName,omitempty"`
CreatedSource string `json:"createdSource"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
@@ -219,6 +253,14 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if err != nil {
return AdminListResponse{}, err
}
deviceID, deviceKeyword, err := parseAdminIDFilter(req.DeviceID, "deviceId", adminFilterUnassigned)
if err != nil {
return AdminListResponse{}, err
}
createdByID, createdByKeyword, err := parseAdminIDFilter(req.CreatedBy, "createdBy", adminFilterAgent, adminFilterUnrecorded)
if err != nil {
return AdminListResponse{}, err
}
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
if submittedFrom != nil {
query = query.Where("order_submitted_at >= ?", *submittedFrom)
@@ -229,6 +271,18 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
if deviceID > 0 {
query = query.Where("device_id = ?", deviceID)
} else if deviceKeyword == adminFilterUnassigned {
query = query.Where("device_id IS NULL")
}
if createdByID > 0 {
query = query.Where("created_by = ?", createdByID)
} else if createdByKeyword == adminFilterAgent {
query = query.Where("created_source = ?", CreatedSourceAgent)
} else if createdByKeyword == adminFilterUnrecorded {
query = query.Where("created_source = ?", "")
}
if req.Status == adminStatusSYBWritebackSucceeded {
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
} else if req.Status != "" {
@@ -264,6 +318,10 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if err != nil {
return AdminListResponse{}, err
}
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), tasks)
if err != nil {
return AdminListResponse{}, err
}
matching, err := s.matchingViews(ctx, tasks)
if err != nil {
return AdminListResponse{}, err
@@ -279,6 +337,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
}
for _, task := range tasks {
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
applyCreatorName(&item, creatorNames)
item.OrderWriteback = writebacks[task.ID]
item.Matching = matching[task.ID]
if task.ShopeeProductID != nil {
@@ -327,6 +386,10 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
if err != nil {
return AdminDetailResponse{}, err
}
creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), []models.PurchaseTask{task})
if err != nil {
return AdminDetailResponse{}, err
}
var attempts []models.PurchaseTaskAttempt
if err := s.DB.WithContext(ctx).Where("task_id = ?", task.ID).Order("attempt_number ASC, id ASC").Find(&attempts).Error; err != nil {
return AdminDetailResponse{}, internal(err)
@@ -345,6 +408,7 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
return AdminDetailResponse{}, matchErr
}
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
applyCreatorName(&item, creatorNames)
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
if wbErr != nil {
return AdminDetailResponse{}, wbErr
@@ -384,11 +448,107 @@ func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]strin
return names, nil
}
// userDisplayNames returns sys_user display names (nickname first, then
// username) for the given ids. Ids without a user row are absent from the map.
func userDisplayNames(db *gorm.DB, ids []uint64) (map[uint64]string, error) {
names := make(map[uint64]string, len(ids))
if len(ids) == 0 {
return names, nil
}
var rows []struct {
UserID uint64
Username string
NickName string
}
if err := db.Table("sys_user").Select("user_id, username, nick_name").Where("user_id IN ?", ids).Scan(&rows).Error; err != nil {
return nil, internal(err)
}
for _, row := range rows {
name := strings.TrimSpace(row.NickName)
if name == "" {
name = strings.TrimSpace(row.Username)
}
names[row.UserID] = name
}
return names, nil
}
func loadCreatorNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) {
ids := make([]uint64, 0, len(tasks))
seen := make(map[uint64]bool)
for _, task := range tasks {
if task.CreatedBy != nil && !seen[*task.CreatedBy] {
ids = append(ids, *task.CreatedBy)
seen[*task.CreatedBy] = true
}
}
return userDisplayNames(db, ids)
}
func applyCreatorName(item *AdminTaskItem, names map[uint64]string) {
if item.CreatedBy != nil {
item.CreatedByName = names[*item.CreatedBy]
}
}
// AdminFilterOption lists the devices and creators that actually appear on
// purchase tasks, so purchasers need no access to device or user management.
type AdminFilterOption struct {
ID uint64 `json:"id"`
Name string `json:"name"`
}
type AdminFilterOptionsResponse struct {
Devices []AdminFilterOption `json:"devices"`
Creators []AdminFilterOption `json:"creators"`
}
func (s *Service) AdminFilterOptions(ctx context.Context) (AdminFilterOptionsResponse, error) {
db := s.DB.WithContext(ctx)
var deviceIDs, creatorIDs []uint64
if err := db.Model(&models.PurchaseTask{}).Where("device_id IS NOT NULL").Distinct().Pluck("device_id", &deviceIDs).Error; err != nil {
return AdminFilterOptionsResponse{}, internal(err)
}
if err := db.Model(&models.PurchaseTask{}).Where("created_by IS NOT NULL").Distinct().Pluck("created_by", &creatorIDs).Error; err != nil {
return AdminFilterOptionsResponse{}, internal(err)
}
result := AdminFilterOptionsResponse{Devices: []AdminFilterOption{}, Creators: []AdminFilterOption{}}
if len(deviceIDs) > 0 {
var devices []models.AgentDevice
if err := db.Select("id, name").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil {
return AdminFilterOptionsResponse{}, internal(err)
}
for _, device := range devices {
result.Devices = append(result.Devices, AdminFilterOption{ID: device.ID, Name: device.Name})
}
}
names, err := userDisplayNames(db, creatorIDs)
if err != nil {
return AdminFilterOptionsResponse{}, err
}
for _, id := range creatorIDs {
name := names[id]
if name == "" {
name = "用户#" + strconv.FormatUint(id, 10)
}
result.Creators = append(result.Creators, AdminFilterOption{ID: id, Name: name})
}
for _, options := range [][]AdminFilterOption{result.Devices, result.Creators} {
sort.Slice(options, func(i, j int) bool {
if options[i].Name != options[j].Name {
return options[i].Name < options[j].Name
}
return options[i].ID < options[j].ID
})
}
return result, nil
}
func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem {
item := AdminTaskItem{
ID: task.ID, TaskType: task.TaskType, ExecutionMode: task.ExecutionMode, Status: task.Status,
SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID,
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
DeviceID: task.DeviceID, CreatedBy: task.CreatedBy, CreatedSource: task.CreatedSource, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot,
PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot,
TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot,
+2 -1
View File
@@ -29,6 +29,7 @@ type BatchCreateRequest struct {
RequestID string `json:"requestId"`
SYBProductIDs []uint64 `json:"sybProductIds"`
DeviceID *uint64 `json:"deviceId,omitempty"`
creator creatorStamp
}
type BatchPreviewItem struct {
@@ -313,7 +314,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc
SYBProductID: &sybID, DeviceID: req.DeviceID,
ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent,
MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent,
Currency: preview.Currency, RuleSnapshot: rawRule,
Currency: preview.Currency, RuleSnapshot: rawRule, creator: req.creator,
})
if createErr != nil {
preview.Eligible = false
@@ -0,0 +1,417 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"go-admin/common/clientprincipal"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"gorm.io/gorm"
)
func ensureSysUserTable(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec("CREATE TABLE IF NOT EXISTS sys_user (user_id integer primary key, username text, nick_name text)").Error; err != nil {
t.Fatal(err)
}
}
func addSysUser(t *testing.T, db *gorm.DB, id uint64, username, nick string) {
t.Helper()
ensureSysUserTable(t, db)
if err := db.Exec("INSERT INTO sys_user(user_id, username, nick_name) VALUES(?,?,?)", id, username, nick).Error; err != nil {
t.Fatal(err)
}
}
func stockRequest(f fixture) StockCreateRequest {
return StockCreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
}
}
func stockFixture(t *testing.T, db *gorm.DB, f fixture) {
t.Helper()
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
}
func extraDevice(name string) models.AgentDevice {
return models.AgentDevice{Name: name, InstallID: uuid.NewString(), Manufacturer: "T", Model: "T", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7",
Status: "online", TokenDigest: uuid.NewString(), TokenIssuedAt: time.Now()}
}
func uptr(v uint64) *uint64 { return &v }
func TestCreatorFromContextMapsCallerToSourceAndUser(t *testing.T) {
gin.SetMode(gin.TestMode)
newCtx := func() *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
return c
}
jwtCtx := newCtx()
jwtCtx.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(7), "rolekey": "purchaser"})
if got := creatorFromContext(jwtCtx); got.Source != CreatedSourceAdmin || got.By == nil || *got.By != 7 {
t.Fatalf("jwt creator: %+v", got)
}
clientCtx := newCtx()
clientprincipal.Set(clientCtx, clientprincipal.Identity{KeyID: 3, AuthorizedBy: 9})
if got := creatorFromContext(clientCtx); got.Source != CreatedSourceClientAPI || got.By == nil || *got.By != 9 {
t.Fatalf("client creator: %+v", got)
}
anonymous := newCtx()
if got := creatorFromContext(anonymous); got.Source != CreatedSourceAdmin || got.By != nil {
t.Fatalf("zero operator must be NULL: %+v", got)
}
}
func TestCreateEntriesStampCreatorOnceAndReplayKeepsIt(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
stockFixture(t, db, f)
service := testService(db)
req := stockRequest(f)
req.creator = creatorStamp{By: uptr(11), Source: CreatedSourceAdmin}
first, replayed, err := service.CreateStock(context.Background(), req)
if err != nil || replayed {
t.Fatalf("create stock: %v replayed=%v", err, replayed)
}
if first.CreatedBy == nil || *first.CreatedBy != 11 || first.CreatedSource != CreatedSourceAdmin {
t.Fatalf("stock creator not stamped: %+v %v", first.CreatedBy, first.CreatedSource)
}
req.creator = creatorStamp{By: uptr(99), Source: CreatedSourceClientAPI}
replay, replayed, err := service.CreateStock(context.Background(), req)
if err != nil || !replayed || replay.ID != first.ID {
t.Fatalf("stock replay: %v", err)
}
var stored models.PurchaseTask
if err = db.First(&stored, first.ID).Error; err != nil {
t.Fatal(err)
}
if stored.CreatedBy == nil || *stored.CreatedBy != 11 || stored.CreatedSource != CreatedSourceAdmin {
t.Fatalf("replay rewrote creator: %+v %v", stored.CreatedBy, stored.CreatedSource)
}
single, err := createLive(t, service, f)
if err != nil {
t.Fatal(err)
}
if single.CreatedBy != nil || single.CreatedSource != "" {
t.Fatalf("unstamped create must stay unrecorded: %+v %q", single.CreatedBy, single.CreatedSource)
}
}
func TestBatchCreateStampsCreatorAndReplayDoesNotRewrite(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
request := BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID,
creator: creatorStamp{By: uptr(21), Source: CreatedSourceClientAPI}}
first, err := service.BatchCreate(context.Background(), request)
if err != nil || first.CreatedCount != 1 || first.Items[0].TaskID == nil {
t.Fatalf("batch create: %+v %v", first, err)
}
request.creator = creatorStamp{By: uptr(22), Source: CreatedSourceAdmin}
replay, err := service.BatchCreate(context.Background(), request)
if err != nil || replay.CreatedCount != 1 || !replay.Items[0].Replayed {
t.Fatalf("batch replay: %+v %v", replay, err)
}
var task models.PurchaseTask
if err = db.First(&task, *first.Items[0].TaskID).Error; err != nil {
t.Fatal(err)
}
if task.CreatedBy == nil || *task.CreatedBy != 21 || task.CreatedSource != CreatedSourceClientAPI {
t.Fatalf("batch creator: %+v %q", task.CreatedBy, task.CreatedSource)
}
}
func TestAgentRetryTaskIsAgentSourceWithoutCreator(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "当前黑色", "target_size": "均碼"}).Error; err != nil {
t.Fatal(err)
}
result, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
var created, source models.PurchaseTask
if err = db.First(&created, result.TaskID).Error; err != nil {
t.Fatal(err)
}
if created.CreatedBy != nil || created.CreatedSource != CreatedSourceAgent {
t.Fatalf("agent retry creator: %+v %q", created.CreatedBy, created.CreatedSource)
}
if err = db.First(&source, failed.ID).Error; err != nil || source.CreatedSource != "" {
t.Fatalf("source task must be untouched: %q %v", source.CreatedSource, err)
}
}
func callAdminHandler(t *testing.T, db *gorm.DB, handler func(Handler, *gin.Context), method, target, body string, setup func(*gin.Context)) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, target, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
setup(c)
handler(Handler{DB: db}, c)
return w
}
func TestAdminCreateStockHandlerStampsJWTAndClientCreators(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
stockFixture(t, db, f)
body := func() string {
raw, _ := json.Marshal(stockRequest(f))
return string(raw)
}
for _, tc := range []struct {
name string
setup func(*gin.Context)
by uint64
source string
}{
{"admin", func(c *gin.Context) {
c.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(5), "rolekey": "purchaser"})
}, 5, CreatedSourceAdmin},
{"client", func(c *gin.Context) {
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 1, AuthorizedBy: 8})
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "admin"})
}, 8, CreatedSourceClientAPI},
} {
t.Run(tc.name, func(t *testing.T) {
w := callAdminHandler(t, db, Handler.AdminCreateStock, http.MethodPost, "/api/admin/v1/purchase-tasks/stock", body(), tc.setup)
var out struct {
Data models.PurchaseTask `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != http.StatusOK || out.Data.ID == 0 {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
var stored models.PurchaseTask
if err := db.First(&stored, out.Data.ID).Error; err != nil {
t.Fatal(err)
}
if stored.CreatedBy == nil || *stored.CreatedBy != tc.by || stored.CreatedSource != tc.source {
t.Fatalf("stored creator: %+v %q", stored.CreatedBy, stored.CreatedSource)
}
})
}
}
// seedCreatorRows inserts tasks with distinct devices/creators/sources.
func seedCreatorRows(t *testing.T, db *gorm.DB, f fixture) (devB models.AgentDevice, ids map[string]uint64) {
t.Helper()
s := testService(db)
base := createAdminQueryTask(t, s, f)
devB = extraDevice("Pixel")
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&devB).Error; err != nil {
t.Fatal(err)
}
ids = map[string]uint64{}
add := func(key string, device *uint64, by *uint64, source string, status string) {
row := base
row.ID = 0
row.CreateRequestID = uuid.NewString()
row.DeviceID, row.CreatedBy, row.CreatedSource, row.Status = device, by, source, status
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&row).Error; err != nil {
t.Fatal(err)
}
ids[key] = row.ID
}
add("adminA_devA", &f.device.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
add("adminA_devB", &devB.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed)
add("adminB_unassigned", nil, uptr(2), CreatedSourceAdmin, models.PurchaseTaskStatusCancelled)
add("agent_devA", &f.device.ID, nil, CreatedSourceAgent, models.PurchaseTaskStatusFailed)
add("legacy_devB", &devB.ID, nil, "", models.PurchaseTaskStatusFailed)
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", base.ID).Update("created_source", "").Error; err != nil {
t.Fatal(err)
}
ids["base"] = base.ID
return devB, ids
}
func listIDs(t *testing.T, s *Service, req AdminListRequest) map[uint64]bool {
t.Helper()
out, err := s.AdminList(context.Background(), req)
if err != nil {
t.Fatal(err)
}
got := map[uint64]bool{}
for _, item := range out.Items {
got[item.ID] = true
}
return got
}
func sameIDs(t *testing.T, got map[uint64]bool, ids map[string]uint64, keys ...string) {
t.Helper()
if len(got) != len(keys) {
t.Fatalf("got %v want keys %v (%v)", got, keys, ids)
}
for _, key := range keys {
if !got[ids[key]] {
t.Fatalf("missing %s in %v (%v)", key, got, ids)
}
}
}
func TestAdminListDeviceAndCreatedByFilters(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
addSysUser(t, db, 1, "alice", "爱丽丝")
addSysUser(t, db, 2, "bob", "")
devB, ids := seedCreatorRows(t, db, f)
s := testService(db)
devA := f.device.ID
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA)}), ids, "base", "adminA_devA", "agent_devA")
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID)}), ids, "adminA_devB", "legacy_devB")
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: "unassigned"}), ids, "adminB_unassigned")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1"}), ids, "adminA_devA", "adminA_devB")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "agent"}), ids, "agent_devA")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "unrecorded"}), ids, "base", "legacy_devB")
// Combined with each other and with existing filters.
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID), CreatedBy: "1"}), ids, "adminA_devB")
sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA), CreatedBy: "unrecorded"}), ids, "base")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "2", Status: models.PurchaseTaskStatusCancelled, DeviceID: "unassigned"}), ids, "adminB_unassigned")
sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1", Status: models.PurchaseTaskStatusCancelled}), ids)
for _, bad := range []AdminListRequest{
{DeviceID: "abc"}, {DeviceID: "0"}, {DeviceID: "-1"}, {DeviceID: "agent"},
{CreatedBy: "abc"}, {CreatedBy: "0"}, {CreatedBy: "unassigned"},
} {
if _, err := s.AdminList(context.Background(), bad); code(err) != CodeInvalidRequest {
t.Fatalf("%+v accepted: %v", bad, err)
}
}
out, err := s.AdminList(context.Background(), AdminListRequest{PageSize: 100})
if err != nil {
t.Fatal(err)
}
for _, item := range out.Items {
switch item.ID {
case ids["adminA_devA"]:
if item.CreatedBy == nil || *item.CreatedBy != 1 || item.CreatedByName != "爱丽丝" || item.CreatedSource != CreatedSourceAdmin {
t.Fatalf("nick name item: %+v", item)
}
case ids["adminB_unassigned"]:
if item.CreatedByName != "bob" {
t.Fatalf("username fallback: %q", item.CreatedByName)
}
case ids["agent_devA"]:
if item.CreatedBy != nil || item.CreatedSource != CreatedSourceAgent || item.CreatedByName != "" {
t.Fatalf("agent item: %+v", item)
}
case ids["legacy_devB"]:
if item.CreatedSource != "" {
t.Fatalf("legacy item: %+v", item)
}
}
}
detail, err := s.AdminDetail(context.Background(), ids["adminA_devA"])
if err != nil || detail.Task.CreatedByName != "爱丽丝" || detail.Task.CreatedSource != CreatedSourceAdmin {
t.Fatalf("detail creator: %+v %v", detail.Task, err)
}
}
func itoa(v uint64) string {
raw, _ := json.Marshal(v)
return string(raw)
}
func TestAdminListHandlerRejectsInvalidDeviceAndCreatedBy(t *testing.T) {
db := testDB(t)
for _, query := range []string{"deviceId=abc", "createdBy=abc", "deviceId=0"} {
w := callAdminHandler(t, db, Handler.AdminList, http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, "", func(c *gin.Context) {
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
})
if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), CodeInvalidRequest) {
t.Fatalf("%s: %d %s", query, w.Code, w.Body.String())
}
}
}
func TestAdminFilterOptionsListsOnlyUsedDevicesAndCreatorsSorted(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
addSysUser(t, db, 1, "zed", "")
addSysUser(t, db, 2, "amy", "Amy")
addSysUser(t, db, 3, "unused", "Unused")
devB, ids := seedCreatorRows(t, db, f)
_ = ids
unused := extraDevice("Unused")
if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&unused).Error; err != nil {
t.Fatal(err)
}
out, err := testService(db).AdminFilterOptions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(out.Devices) != 2 || out.Devices[0].Name != "Pixel" || out.Devices[0].ID != devB.ID || out.Devices[1].ID != f.device.ID {
t.Fatalf("devices: %+v", out.Devices)
}
if len(out.Creators) != 2 || out.Creators[0].Name != "Amy" || out.Creators[0].ID != 2 || out.Creators[1].Name != "zed" {
t.Fatalf("creators: %+v", out.Creators)
}
}
func TestAdminFilterOptionsEmptyReturnsEmptyArrays(t *testing.T) {
empty, err := testService(testDB(t)).AdminFilterOptions(context.Background())
if err != nil || empty.Devices == nil || empty.Creators == nil || len(empty.Devices)+len(empty.Creators) != 0 {
t.Fatalf("empty options must be empty arrays: %+v %v", empty, err)
}
}
func TestAdminFilterOptionsHandlerAccess(t *testing.T) {
db := testDB(t)
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
w := callAdminHandler(t, db, Handler.AdminFilterOptions, http.MethodGet, "/api/admin/v1/purchase-tasks/filter-options", "", func(c *gin.Context) {
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if want && w.Code != http.StatusOK {
t.Fatalf("role %q denied: %d %s", role, w.Code, w.Body.String())
}
if !want && w.Code != http.StatusForbidden {
t.Fatalf("role %q allowed: %d", role, w.Code)
}
}
}
func TestFilterOptionsRouteDoesNotCollideWithTaskID(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
group := engine.Group("/api/admin/v1/purchase-tasks")
group.GET("", func(c *gin.Context) { c.String(200, "list") })
group.GET("/filter-options", func(c *gin.Context) { c.String(200, "options") })
group.GET("/:taskId", func(c *gin.Context) { c.String(200, "detail") })
for path, want := range map[string]string{"/api/admin/v1/purchase-tasks/filter-options": "options", "/api/admin/v1/purchase-tasks/12": "detail"} {
w := httptest.NewRecorder()
engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Body.String() != want {
t.Fatalf("%s routed to %q", path, w.Body.String())
}
}
}
+35
View File
@@ -58,6 +58,8 @@ func (h Handler) AdminList(c *gin.Context) {
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
OrderSubmittedTo: c.Query("orderSubmittedTo"),
DeviceID: c.Query("deviceId"),
CreatedBy: c.Query("createdBy"),
})
if err != nil {
writeError(c, err)
@@ -66,6 +68,22 @@ func (h Handler) AdminList(c *gin.Context) {
writeAdminData(c, response)
}
func (h Handler) AdminFilterOptions(c *gin.Context) {
if !allowedOperator(c) {
return
}
service, ok := h.service(c)
if !ok {
return
}
response, err := service.AdminFilterOptions(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
writeAdminData(c, response)
}
func (h Handler) AdminCreateStock(c *gin.Context) {
if !allowedOperator(c) {
return
@@ -78,6 +96,7 @@ func (h Handler) AdminCreateStock(c *gin.Context) {
if !ok {
return
}
req.creator = creatorFromContext(c)
record, replayed, err := service.CreateStock(c.Request.Context(), req)
if err != nil {
writeError(c, err)
@@ -118,6 +137,7 @@ func (h Handler) AdminCreate(c *gin.Context) {
if !ok {
return
}
req.creator = creatorFromContext(c)
record, replayed, err := service.Create(c.Request.Context(), req)
if err != nil {
writeError(c, err)
@@ -178,6 +198,7 @@ func (h Handler) AdminBatchCreate(c *gin.Context) {
if !ok {
return
}
req.creator = creatorFromContext(c)
response, err := service.BatchCreate(c.Request.Context(), req)
if err != nil {
writeError(c, err)
@@ -550,6 +571,20 @@ func writeError(c *gin.Context, err error) {
}
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
}
// creatorFromContext maps the authenticated caller to the #382 creator stamp.
// A zero operator id is stored as NULL rather than a fake user.
func creatorFromContext(c *gin.Context) creatorStamp {
stamp := creatorStamp{Source: CreatedSourceAdmin}
if _, ok := clientprincipal.Get(c); ok {
stamp.Source = CreatedSourceClientAPI
}
if id := operatorID(c); id > 0 {
stamp.By = &id
}
return stamp
}
func operatorID(c *gin.Context) uint64 {
if id, ok := clientprincipal.Get(c); ok {
return id.AuthorizedBy
+1
View File
@@ -196,6 +196,7 @@ func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
Currency: candidate.preview.Currency,
RuleSnapshot: rawRule,
creator: creatorStamp{Source: CreatedSourceAgent},
})
item := response.Items[candidate.resultIndex]
if createErr != nil {
+1
View File
@@ -29,6 +29,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
agent.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot)
admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("", h.AdminList)
admin.GET("/filter-options", h.AdminFilterOptions)
admin.POST("/batch-preview", h.AdminBatchPreview)
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
admin.POST("/batch", h.AdminBatchCreate)
+2 -1
View File
@@ -349,6 +349,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName,
PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot,
Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID,
CreatedBy: req.creator.By, CreatedSource: req.creator.Source,
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected}
if err := tx.Create(&out).Error; err != nil {
if isDuplicate(err) {
@@ -377,7 +378,7 @@ func (s *Service) CreateStock(ctx context.Context, req StockCreateRequest) (mode
DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID,
TargetColor: req.Color, TargetSize: req.Size, Quantity: req.Quantity,
MinUnitPriceCent: req.MinUnitPriceCent, MaxUnitPriceCent: req.MaxUnitPriceCent,
Currency: "CNY", RuleSnapshot: rule,
Currency: "CNY", RuleSnapshot: rule, creator: req.creator,
})
}
+16
View File
@@ -96,8 +96,23 @@ type CreateRequest struct {
// immutable source-task value instead of accepting a client-supplied
// identity snapshot.
shopeeOrderNoSnapshotOverride string
// creator is server-only (#382): written once when a task row is created.
creator creatorStamp
}
// creatorStamp records who created a purchase task. It is never decoded from
// request bodies; the handler or the internal caller fills it.
type creatorStamp struct {
By *uint64
Source string
}
const (
CreatedSourceAdmin = "admin"
CreatedSourceClientAPI = "client_api"
CreatedSourceAgent = "agent"
)
// StockCreateRequest is intentionally detached from SYB/Shopee identities.
// The server supplies the reviewed purchase rule and derives the immutable
// reference price from the selected color in the collected PDD archive.
@@ -112,6 +127,7 @@ type StockCreateRequest struct {
Quantity int64 `json:"quantity"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
creator creatorStamp
}
type MatchingView struct {
+34 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func (handler Handler) AdminList(c *gin.Context) {
@@ -176,7 +177,7 @@ func (handler Handler) AdminCancel(c *gin.Context) {
if !ok {
return
}
response, err := service.Cancel(c.Request.Context(), id, request)
response, err := service.CancelOwnedBy(c.Request.Context(), id, request, ownerScope(c))
if err != nil {
writeError(c, err)
return
@@ -194,7 +195,7 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
if !ok {
return
}
response, err := service.BatchCancel(c.Request.Context(), request)
response, err := service.BatchCancelOwnedBy(c.Request.Context(), request, ownerScope(c))
if err != nil {
writeError(c, err)
return
@@ -202,6 +203,37 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
// ownerScope 返回取消操作的归属限制:管理员返回 nil(不限制),其他角色返回
// 当前用户 id(取不到时为 0,此时任何设备都不算本人设备)。读取方式与
// device/handler.go 一致(JWT rolekey / identity,#333)。
func ownerScope(c *gin.Context) *uint64 {
if role, _ := jwt.ExtractClaims(c)["rolekey"].(string); role == "admin" {
return nil
}
id := currentUserID(c)
return &id
}
func currentUserID(c *gin.Context) uint64 {
switch id := jwt.ExtractClaims(c)["identity"].(type) {
case float64:
if id > 0 {
return uint64(id)
}
case int:
if id > 0 {
return uint64(id)
}
case int64:
if id > 0 {
return uint64(id)
}
case uint64:
return id
}
return 0
}
func decodeStrict(c *gin.Context, destination any) error {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
decoder := json.NewDecoder(c.Request.Body)
+64 -5
View File
@@ -13,6 +13,20 @@ import (
const maxBatchCancelItems = 500
const messageNotOwnDevice = "只能取消归属自己设备的任务"
// deviceOwnedBy 判断设备是否归属指定用户;设备为空、不存在或未归属都视为否。
func deviceOwnedBy(db *gorm.DB, deviceID *uint64, ownerUserID uint64) (bool, error) {
if deviceID == nil || *deviceID == 0 || ownerUserID == 0 {
return false, nil
}
var count int64
if err := db.Model(&models.AgentDevice{}).Where("id = ? AND owner_user_id = ?", *deviceID, ownerUserID).Count(&count).Error; err != nil {
return false, internalError(err)
}
return count > 0, nil
}
// CancelResponse describes the outcome of cancelling one pending collection task.
type CancelResponse struct {
TaskID uint64 `json:"taskId"`
@@ -31,6 +45,9 @@ type BatchCancelRequest struct {
// 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用
// 「筛选 + 一个按钮」想避免的事(#298)。
GoodsID string `json:"goodsId,omitempty"`
// DeviceID 按设备限定取消范围(#387)。管理员可不传(范围与之前一致,含
// 未指定设备的任务);非管理员必须传,且必须是归属自己的设备。
DeviceID uint64 `json:"deviceId,omitempty"`
}
type BatchCancelSkippedItem struct {
@@ -57,6 +74,13 @@ type BatchCancelResponse struct {
// failure #292 already paid for once. The semantics are "stop what has not
// started, let what is running finish".
func (service *Service) Cancel(ctx context.Context, taskID uint64, request ActionRequest) (CancelResponse, error) {
return service.CancelOwnedBy(ctx, taskID, request, nil)
}
// CancelOwnedBy 与 Cancel 相同,但 ownerUserID 非空时只允许取消归属该用户设备
// 的任务(#387)。未指定设备、设备不存在或归属他人的任务一律返回同一个拒绝,
// 不区分任务当前状态,避免向采购员泄露不属于他的任务信息。
func (service *Service) CancelOwnedBy(ctx context.Context, taskID uint64, request ActionRequest, ownerUserID *uint64) (CancelResponse, error) {
if err := validateAction(taskID, request); err != nil {
return CancelResponse{}, err
}
@@ -69,6 +93,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
}
return internalError(err)
}
if ownerUserID != nil {
owned, err := deviceOwnedBy(tx, record.DeviceID, *ownerUserID)
if err != nil {
return err
}
if !owned {
return serviceError(CodeForbidden, messageNotOwnDevice)
}
}
if record.Status == models.TaskStatusCancelled {
response = CancelResponse{TaskID: taskID, Cancelled: true}
return nil
@@ -88,11 +121,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
// 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。
// RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。
now := service.Now()
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now).
Updates(map[string]any{
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
})
update := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now)
if ownerUserID != nil {
// 上面已校验归属;这里在写入条件里再兜一层,防止校验与更新之间归属变化。
update = update.Where("device_id IN (SELECT id FROM agent_device WHERE owner_user_id = ?)", *ownerUserID)
}
result := update.Updates(map[string]any{
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
})
if result.Error != nil {
return internalError(result.Error)
}
@@ -115,6 +152,25 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
// 回滚已经成功取消的其它任务。采购员要的是「能停多少停多少」,而不是全有或
// 全无——批量越大,中途有一条被领走的概率越高。
func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequest) (BatchCancelResponse, error) {
return service.BatchCancelOwnedBy(ctx, request, nil)
}
// BatchCancelOwnedBy 与 BatchCancel 相同,但 ownerUserID 非空(非管理员)时
// 必须指定 DeviceID,且该设备必须归属 ownerUserID,否则不取消任何任务(#387)。
func (service *Service) BatchCancelOwnedBy(ctx context.Context, request BatchCancelRequest, ownerUserID *uint64) (BatchCancelResponse, error) {
if ownerUserID != nil {
if request.DeviceID == 0 {
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "请先选择设备")
}
deviceID := request.DeviceID
owned, err := deviceOwnedBy(service.DB.WithContext(ctx), &deviceID, *ownerUserID)
if err != nil {
return BatchCancelResponse{}, err
}
if !owned {
return BatchCancelResponse{}, serviceError(CodeForbidden, messageNotOwnDevice)
}
}
source := strings.TrimSpace(request.Source)
if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch {
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "source 无效")
@@ -135,6 +191,9 @@ func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequ
if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" {
query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%")
}
if request.DeviceID > 0 {
query = query.Where("device_id = ?", request.DeviceID)
}
var candidates []models.CollectionTask
// 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。
if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil {
+224
View File
@@ -0,0 +1,224 @@
package task
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"gorm.io/gorm"
)
// #387:批量取消按设备限定,单条/批量取消对非管理员强制设备归属。
func ownDevice(t *testing.T, db *gorm.DB, name string, owner uint64) models.AgentDevice {
t.Helper()
record, _ := registerTaskDevice(t, db, name)
if owner > 0 {
if err := db.Model(&models.AgentDevice{}).Where("id = ?", record.ID).Update("owner_user_id", owner).Error; err != nil {
t.Fatalf("set owner: %v", err)
}
}
return record
}
func taskStatus(t *testing.T, db *gorm.DB, id uint64) string {
t.Helper()
var stored models.CollectionTask
if err := db.First(&stored, id).Error; err != nil {
t.Fatalf("load task: %v", err)
}
return stored.Status
}
func callCancel(t *testing.T, db *gorm.DB, claims jwt.MapClaims, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
handler := Handler{DB: db}
setClaims := func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, claims)
c.Set("userId", 0)
c.Next()
}
engine.POST("/batch-cancel", setClaims, handler.AdminBatchCancel)
engine.POST("/:taskId/cancel", setClaims, handler.AdminCancel)
payload, _ := json.Marshal(body)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload)))
var decoded map[string]any
_ = json.Unmarshal(recorder.Body.Bytes(), &decoded)
return recorder, decoded
}
func purchaser(id float64) jwt.MapClaims {
return jwt.MapClaims{"rolekey": "purchaser", "identity": id}
}
func adminClaims() jwt.MapClaims { return jwt.MapClaims{"rolekey": "admin", "identity": float64(1)} }
func TestBatchCancelWithDeviceIDOnlyCancelsThatDevice(t *testing.T) {
db := openTaskDatabase(t)
service := newTaskService(db)
one := ownDevice(t, db, "one", 3)
two := ownDevice(t, db, "two", 3)
onOne := createTask(t, db, &one.ID)
onTwo := createTask(t, db, &two.ID)
unassigned := createTask(t, db, nil)
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{DeviceID: one.ID})
if err != nil {
t.Fatalf("batch cancel: %v", err)
}
if response.CancelledCount != 1 || response.CancelledIDs[0] != onOne.ID {
t.Fatalf("expected only device one task cancelled, got %+v", response)
}
if taskStatus(t, db, onTwo.ID) != models.TaskStatusPending || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
t.Fatalf("other device and unassigned tasks must stay pending")
}
}
func TestBatchCancelWithoutDeviceIDKeepsFullScope(t *testing.T) {
db := openTaskDatabase(t)
service := newTaskService(db)
one := ownDevice(t, db, "one", 3)
createTask(t, db, &one.ID)
createTask(t, db, nil)
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{})
if err != nil {
t.Fatalf("batch cancel: %v", err)
}
if response.CancelledCount != 2 {
t.Fatalf("expected both tasks cancelled including unassigned, got %+v", response)
}
}
func TestBatchCancelPurchaserRequiresDeviceID(t *testing.T) {
db := openTaskDatabase(t)
one := ownDevice(t, db, "one", 3)
record := createTask(t, db, &one.ID)
for _, body := range []map[string]any{{}, {"deviceId": 0}} {
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", body)
if recorder.Code != http.StatusUnprocessableEntity || decoded["code"] != "INVALID_REQUEST" || decoded["message"] != "请先选择设备" {
t.Fatalf("expected INVALID_REQUEST, got %d %v", recorder.Code, decoded)
}
}
if taskStatus(t, db, record.ID) != models.TaskStatusPending {
t.Fatalf("no task may be cancelled")
}
}
func TestBatchCancelPurchaserOwnDeviceSucceeds(t *testing.T) {
db := openTaskDatabase(t)
own := ownDevice(t, db, "own", 3)
other := ownDevice(t, db, "other", 4)
mine := createTask(t, db, &own.ID)
theirs := createTask(t, db, &other.ID)
recorder, _ := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": own.ID})
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if taskStatus(t, db, mine.ID) != models.TaskStatusCancelled || taskStatus(t, db, theirs.ID) != models.TaskStatusPending {
t.Fatalf("expected only own device task cancelled")
}
}
func TestBatchCancelPurchaserRejectsForeignUnownedAndMissingDevices(t *testing.T) {
db := openTaskDatabase(t)
foreign := ownDevice(t, db, "foreign", 4)
unowned := ownDevice(t, db, "unowned", 0)
foreignTask := createTask(t, db, &foreign.ID)
unownedTask := createTask(t, db, &unowned.ID)
for name, id := range map[string]uint64{"foreign": foreign.ID, "unowned": unowned.ID, "missing": 999999} {
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": id})
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
t.Fatalf("%s: expected 403 FORBIDDEN, got %d %v", name, recorder.Code, decoded)
}
}
if taskStatus(t, db, foreignTask.ID) != models.TaskStatusPending || taskStatus(t, db, unownedTask.ID) != models.TaskStatusPending {
t.Fatalf("rejected requests must not cancel anything")
}
}
func TestBatchCancelMissingIdentityIsRejected(t *testing.T) {
db := openTaskDatabase(t)
unowned := ownDevice(t, db, "unowned", 0)
record := createTask(t, db, &unowned.ID)
recorder, _ := callCancel(t, db, jwt.MapClaims{"rolekey": "purchaser"}, "/batch-cancel", map[string]any{"deviceId": unowned.ID})
if recorder.Code != http.StatusForbidden || taskStatus(t, db, record.ID) != models.TaskStatusPending {
t.Fatalf("expected rejection, got %d", recorder.Code)
}
}
func TestBatchCancelAdminAnyDeviceAndUnassigned(t *testing.T) {
db := openTaskDatabase(t)
foreign := ownDevice(t, db, "foreign", 4)
onForeign := createTask(t, db, &foreign.ID)
unassigned := createTask(t, db, nil)
recorder, _ := callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{"deviceId": foreign.ID})
if recorder.Code != http.StatusOK || taskStatus(t, db, onForeign.ID) != models.TaskStatusCancelled || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
t.Fatalf("admin with deviceId should cancel only that device, code=%d", recorder.Code)
}
recorder, _ = callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{})
if recorder.Code != http.StatusOK || taskStatus(t, db, unassigned.ID) != models.TaskStatusCancelled {
t.Fatalf("admin without deviceId should cancel unassigned tasks, code=%d", recorder.Code)
}
}
func TestSingleCancelPurchaserOwnership(t *testing.T) {
db := openTaskDatabase(t)
own := ownDevice(t, db, "own", 3)
foreign := ownDevice(t, db, "foreign", 4)
mine := createTask(t, db, &own.ID)
theirs := createTask(t, db, &foreign.ID)
unassigned := createTask(t, db, nil)
body := map[string]any{"requestId": uuid.NewString()}
for name, id := range map[string]uint64{"foreign": theirs.ID, "unassigned": unassigned.ID} {
recorder, decoded := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", id), body)
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
t.Fatalf("%s: expected 403, got %d %v", name, recorder.Code, decoded)
}
if taskStatus(t, db, id) != models.TaskStatusPending {
t.Fatalf("%s: status must be unchanged", name)
}
}
recorder, _ := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", mine.ID), body)
if recorder.Code != http.StatusOK || taskStatus(t, db, mine.ID) != models.TaskStatusCancelled {
t.Fatalf("own device task should cancel, code=%d body=%s", recorder.Code, recorder.Body.String())
}
// 他人设备上已取消的任务也不能通过幂等分支探测。
if _, err := newTaskService(db).Cancel(context.Background(), theirs.ID, ActionRequest{RequestID: uuid.NewString()}); err != nil {
t.Fatalf("seed: %v", err)
}
recorder, _ = callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", theirs.ID), body)
if recorder.Code != http.StatusForbidden {
t.Fatalf("foreign cancelled task must still be forbidden, got %d", recorder.Code)
}
}
func TestSingleCancelAdminUnaffected(t *testing.T) {
db := openTaskDatabase(t)
foreign := ownDevice(t, db, "foreign", 4)
theirs := createTask(t, db, &foreign.ID)
unassigned := createTask(t, db, nil)
for _, id := range []uint64{theirs.ID, unassigned.ID} {
recorder, _ := callCancel(t, db, adminClaims(), fmt.Sprintf("/%d/cancel", id), map[string]any{"requestId": uuid.NewString()})
if recorder.Code != http.StatusOK || taskStatus(t, db, id) != models.TaskStatusCancelled {
t.Fatalf("admin cancel of %d failed: %d", id, recorder.Code)
}
}
}
+1 -1
View File
@@ -317,7 +317,7 @@ func writeError(context *gin.Context, err error) {
status = http.StatusUnprocessableEntity
case device.CodeTokenInvalid:
status = http.StatusUnauthorized
case device.CodeDeviceDisabled:
case device.CodeDeviceDisabled, CodeForbidden:
status = http.StatusForbidden
case CodeTaskNotFound:
status = http.StatusNotFound
+1
View File
@@ -20,6 +20,7 @@ const (
DefaultLeaseDuration = 2 * time.Minute
DefaultTaskTimeout = 120
CodeForbidden = "FORBIDDEN"
CodeTaskNotFound = "TASK_NOT_FOUND"
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
@@ -0,0 +1,37 @@
package version_local
import (
"runtime"
"go-admin/app/goauto/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), MigratePurchaseTaskCreator)
}
// MigratePurchaseTaskCreator only adds the #382 creator columns to
// purchase_task: nullable created_by (indexed) and created_source defaulting
// to the empty string. Existing rows are not rewritten or backfilled. Each step is guarded so
// a partial run (MySQL DDL commits implicitly) can be re-run safely.
func MigratePurchaseTaskCreator(db *gorm.DB, version string) error {
m := db.Migrator()
for _, column := range []string{"CreatedBy", "CreatedSource"} {
if !m.HasColumn(&models.PurchaseTask{}, column) {
if err := m.AddColumn(&models.PurchaseTask{}, column); err != nil {
return err
}
}
}
if !m.HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") {
if err := m.CreateIndex(&models.PurchaseTask{}, "CreatedBy"); err != nil {
return err
}
}
return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error
}
@@ -0,0 +1,59 @@
package version_local
import (
"testing"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigratePurchaseTaskCreatorAddsColumnsKeepsLegacyRowsAndIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
conn, _ := db.DB()
defer conn.Close()
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text NOT NULL)").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("INSERT INTO purchase_task(id,status) VALUES(1,'failed')").Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err = MigratePurchaseTaskCreator(db, "test_382"); err != nil {
t.Fatal(err)
}
}
for _, column := range []string{"CreatedBy", "CreatedSource"} {
if !db.Migrator().HasColumn(&models.PurchaseTask{}, column) {
t.Fatalf("missing column %s", column)
}
}
if !db.Migrator().HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") {
t.Fatal("missing created_by index")
}
var row struct {
Status string
CreatedBy *uint64
CreatedSource string
}
if err = db.Table("purchase_task").Select("status, created_by, created_source").Where("id = 1").Scan(&row).Error; err != nil {
t.Fatal(err)
}
if row.Status != "failed" || row.CreatedBy != nil || row.CreatedSource != "" {
t.Fatalf("legacy row changed: %+v", row)
}
var count int64
db.Model(&common.Migration{}).Where("version = ?", "test_382").Count(&count)
if count != 1 {
t.Fatal("version not idempotent")
}
}
+1
View File
@@ -19,6 +19,7 @@ export function listPurchaseTasks(params) {
}
})
}
export function getPurchaseTaskFilterOptions() { return request({ url: '/api/admin/v1/purchase-tasks/filter-options', method: 'get' }) }
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
+3 -1
View File
@@ -29,6 +29,7 @@
<div v-for="m in group.modules" :key="m.key" class="module-row">
<div class="module-main"><el-checkbox :model-value="!!grants[m.key]" :disabled="readonly || saving" @change="value => selectModule(m.key, value)">{{ m.title }}</el-checkbox><el-radio-group v-if="grants[m.key]" v-model="grants[m.key].write" :disabled="readonly || saving" size="small" :aria-label="`${m.title}访问方式`"><el-radio-button :value="false">只读</el-radio-button><el-radio-button :value="true" :disabled="!m.writable">读写</el-radio-button></el-radio-group></div>
<div v-if="grants[m.key] && m.actions.length" class="actions"><span class="muted">独立动作:</span><el-checkbox-group v-model="grants[m.key].actions" :disabled="readonly || saving"><el-checkbox v-for="action in m.actions" :key="action" :value="action">{{ actionName(action) }}</el-checkbox></el-checkbox-group></div>
<p v-if="grants[m.key] && grants[m.key].actions.includes('release_activate')" class="risk" role="note">设为当前后,所有设备会提示更新</p>
</div>
</section>
</div>
@@ -51,7 +52,7 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { listClientKeys, clientKeyModules, createClientKey, editClientKey, disableClientKey } from '@/api/goauto/client-keys'
const actionLabels = { sync: '立即同步', import: '导入', match: '匹配 / 确认', collect: '创建 / 重新采集', purchase: '创建 / 重试采购', writeback: '回写', delete: '删除', reparse: '重新解析', activate: '设置当前规则' }
const actionLabels = { sync: '立即同步', import: '导入', match: '匹配 / 确认', collect: '创建 / 重新采集', purchase: '创建 / 重试采购', writeback: '回写', delete: '删除', reparse: '重新解析', activate: '设置当前规则', release_upload: '上传 Agent 版本', release_activate: '设置当前 Agent 版本' }
const snapshot = grants => JSON.stringify(Object.values(grants).map(g => ({ ...g, actions: [...g.actions].sort() })).sort((a, b) => a.module.localeCompare(b.module)))
const errorText = error => error.response?.data?.message || '请求失败,请检查网络后重试'
@@ -114,6 +115,7 @@ h2 { margin: 0 0 12px; }
.module-row { padding: 10px 0; border-top: 1px solid var(--el-border-color-lighter); }
.module-main { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.actions { padding: 4px 0 0 22px; }
.risk { margin: 4px 0 0; padding-left: 22px; color: var(--el-color-warning); font-size: 13px; }
.notice, .el-pagination { margin: 16px 0; }
.validation { color: var(--el-color-danger); }
.secret { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; background: var(--el-fill-color-light); }
+28 -12
View File
@@ -10,10 +10,11 @@
<el-form-item label="Goods ID"><el-input v-model="query.goodsId" clearable placeholder="输入 goods_id" @keyup.enter="handleQuery" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="来源"><el-select v-model="query.source" clearable placeholder="全部来源" style="width:160px"><el-option v-for="item in sources" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="设备"><el-select v-model="query.deviceId" clearable filterable placeholder="全部设备" style="width:160px"><el-option v-for="item in deviceFilterOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span class="action-count">{{ pendingCancelCount }}</span></el-button>
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="!cancelNeedsDevice && pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span v-if="!cancelNeedsDevice" class="action-count">{{ pendingCancelCount }}</span></el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="tasks" border stripe empty-text="暂无采集任务">
@@ -46,7 +47,7 @@
<el-dialog v-model="cancelBatch.open" title="取消未开始的任务" width="680px" :close-on-click-modal="false">
<el-alert
:title="`将取消整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下所有待执行任务,不只是当前页。`"
:title="`将取消${cancelDeviceLabel}上、整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下的所有待执行任务,不只是当前页。`"
type="warning" :closable="false" show-icon class="dialog-alert" />
<div v-loading="cancelBatch.previewLoading">
<p>共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:</p>
@@ -103,47 +104,62 @@ export default {
name: 'GoAutoCollectionTasks', setup() { return { Plus, RefreshLeft, Search } },
data() {
return {
loading: false, tasks: [], total: 0, query: { page: 1, pageSize: 20, goodsId: '', status: '', source: '' },
loading: false, tasks: [], total: 0, query: { page: 1, pageSize: 20, goodsId: '', status: '', source: '', deviceId: null },
statuses: [{ value: 'pending', label: '待执行' }, { value: 'running', label: '执行中' }, { value: 'completed', label: '已完成' }, { value: 'completed_partial', label: '部分完成' }, { value: 'failed', label: '失败' }, { value: 'cancelled', label: '已取消' }],
sources: [{ value: 'admin', label: '手动创建' }, { value: 'agent_current_page', label: '拍照临时采集' }, { value: 'image_search', label: '图搜采集' }],
createDialog: { open: false, saving: false }, form: { pddProductId: null, ruleId: null, deviceId: null },
formRules: { pddProductId: [{ required: true, message: '请选择 PDD 商品', trigger: 'change' }], ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
options: { products: [], rules: [], devices: [] }, detail: { open: false, loading: false, data: null },
pendingCancelCount: 0,
pendingCancelCount: 0, deviceFilterOptions: [],
cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 }
}
},
created() { this.getList(); this.refreshPendingCancelCount() },
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
// #387:采购员未选设备时不能批量取消(服务端同样拒绝);管理员不受限。
cancelNeedsDevice() { return !this.isAdmin && !this.query.deviceId },
cancelDeviceLabel() {
if (!this.query.deviceId) return '全部设备(含未指定设备)'
const found = this.deviceFilterOptions.find(item => item.id === this.query.deviceId)
return `设备「${found ? found.name : this.query.deviceId}」`
}
},
created() { this.getList(); this.refreshPendingCancelCount(); this.loadDeviceFilterOptions() },
watch: {
// `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID
// 那一维,按 goods_id 筛出两条、按钮却显示并取消三十几条——那正是当初
// 放弃「两个固定按钮」、改用「筛选 + 一个按钮」想避免的事(#298)。
'query.source'() { this.refreshPendingCancelCount() },
'query.status'() { this.refreshPendingCancelCount() },
'query.goodsId'() { this.refreshPendingCancelCount() }
'query.goodsId'() { this.refreshPendingCancelCount() },
'query.deviceId'() { this.refreshPendingCancelCount() }
},
methods: {
async getList() { this.loading = true; try { const r = await listCollectionTasks(this.query); this.tasks = r.data.items; this.total = r.data.total } finally { this.loading = false } },
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '', source: '' }; this.getList() },
// 设备下拉含已停用设备;加载失败只留空下拉,不影响列表查询。
async loadDeviceFilterOptions() { try { const r = await listDevices({ page: 1, pageSize: 100 }); this.deviceFilterOptions = r.data.items } catch { this.deviceFilterOptions = [] } },
async getList() { this.loading = true; try { const r = await listCollectionTasks({ ...this.query, deviceId: this.query.deviceId || undefined }); this.tasks = r.data.items; this.total = r.data.total } finally { this.loading = false } },
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '', source: '', deviceId: null }; this.getList() },
statusLabel(value) { return this.statuses.find(item => item.value === value)?.label || value }, statusType(value) { return { pending: 'info', running: 'warning', completed: 'success', completed_partial: 'warning', failed: 'danger', cancelled: 'info' }[value] || 'info' },
sourceLabel(value) { return this.sources.find(item => item.value === value)?.label || value || '' },
isTerminal(value) { return ['completed', 'completed_partial', 'failed'].includes(value) }, specsText(specs) { return Object.entries(specs || {}).map(([key, value]) => `${key}: ${value}`).join(' / ') },
// 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选
// 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个
// 与实际可取消数量无关的数字误导采购员。
// #387:设备维度也参与取消范围;采购员必须先选设备,且服务端只允许取消本人设备的任务。
cancelScopeStatus() { return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
async refreshPendingCancelCount() {
const status = this.cancelScopeStatus()
if (!status) { this.pendingCancelCount = 0; return }
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
if (!status || this.cancelNeedsDevice) { this.pendingCancelCount = 0; return }
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
this.pendingCancelCount = r.data.total
},
async openCancelBatch() {
if (this.cancelNeedsDevice) { ElMessage.warning('请先在「设备」筛选中选择要取消任务的设备'); return }
const status = this.cancelScopeStatus()
if (!status) return
this.cancelBatch = { open: true, previewLoading: true, submitting: false, preview: [], previewTotal: 0 }
try {
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
this.cancelBatch.preview = r.data.items
this.cancelBatch.previewTotal = r.data.total
} finally { this.cancelBatch.previewLoading = false }
@@ -153,7 +169,7 @@ export default {
if (!status) return
this.cancelBatch.submitting = true
try {
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, goodsId: this.query.goodsId || undefined })
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, deviceId: this.query.deviceId || undefined, goodsId: this.query.goodsId || undefined })
const skippedText = response.data.skipped?.length ? `,${response.data.skipped.length} 个因执行中或已被设备领取而跳过(任务号:${response.data.skipped.map(item => item.taskId).join('、')})` : ''
ElMessage.success(`已取消 ${response.data.cancelledCount} 个任务${skippedText}`)
if (response.data.hasMore) {
+19 -3
View File
@@ -14,6 +14,8 @@
</div>
</el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="设备"><el-select v-model="query.deviceId" clearable filterable placeholder="全部设备" style="width:170px"><el-option label="未分派" value="unassigned" /><el-option v-for="item in filterOptions.devices" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="创建人"><el-select v-model="query.createdBy" clearable filterable placeholder="全部创建人" style="width:170px"><el-option label="Agent 发起" value="agent" /><el-option label="未记录" value="unrecorded" /><el-option v-for="item in filterOptions.creators" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></el-form-item>
@@ -30,6 +32,7 @@
<el-table-column label="PDD 商品" min-width="190"><template #default="{ row }"><div class="primary ellipsis">{{ row.pddTitleSnapshot || '标题未记录' }}</div><div class="muted">goods_id {{ row.pddGoodsIdSnapshot || '—' }}</div></template></el-table-column>
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
<el-table-column label="创建人" min-width="110"><template #default="{ row }">{{ creatorLabel(row) }}</template></el-table-column>
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div v-if="row.pddOrderNo" class="muted">{{ orderInformationComplete(row) ? '信息完整' : '订单信息待补全' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
@@ -56,6 +59,7 @@
<el-descriptions-item label="数量">{{ detail.task.quantity }} 件</el-descriptions-item><el-descriptions-item label="映射来源">{{ specSourceLabel(detail.task.specSource) }} <el-button v-if="detail.task.shopeeProductId" type="primary" link @click="openShopeeMapping">查看映射</el-button></el-descriptions-item>
<el-descriptions-item label="允许单价">{{ priceText(detail.task.minUnitPriceCent, detail.task.currency) }} ~ {{ priceText(detail.task.maxUnitPriceCent, detail.task.currency) }}</el-descriptions-item><el-descriptions-item label="参考单价">{{ priceText(detail.task.referenceUnitPriceCent, detail.task.currency) }}</el-descriptions-item>
<el-descriptions-item label="设备">{{ detail.task.deviceName || '尚未分派' }}</el-descriptions-item><el-descriptions-item label="地址后缀">{{ detail.task.addressSuffix || '未生成' }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ creatorLabel(detail.task) }}</el-descriptions-item><el-descriptions-item label="创建来源">{{ createdSourceLabel(detail.task.createdSource) }}</el-descriptions-item>
</el-descriptions></section>
<section v-if="detail.matching"><h3>规格匹配</h3>
@@ -153,7 +157,7 @@
import { parsePurchaseOrderFilter } from '@/utils/purchase-order-filter'
import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, getPurchaseTaskFilterOptions, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id'
import { saveExcel } from '@/utils/save-excel'
import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, PURCHASE_EXPORT_NUMBER_COLS, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export'
@@ -175,7 +179,8 @@ export default {
exporting: false, exportProgress: { done: 0, steps: 0 },
detailSession: 0, snapshots: { items: {}, loading: false, error: false },
orderWriteback: { open: false, saving: false, items: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] },
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' },
filterOptions: { devices: [], creators: [] },
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
retrySelection: [],
@@ -214,6 +219,7 @@ export default {
beforeUnmount() { this.clearSnapshots() },
deactivated() { this.detail.open = false; this.clearSnapshots() },
created() {
this.loadFilterOptions()
const taskId = Number(this.$route.query.taskId)
if (Number.isInteger(taskId) && taskId > 0) {
this.query.taskId = String(taskId)
@@ -241,7 +247,17 @@ export default {
return params
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' }; this.load() },
// #382: options only list devices/creators that appear on purchase tasks; failure leaves the static options usable.
async loadFilterOptions() {
try { const r = await getPurchaseTaskFilterOptions(); this.filterOptions = { devices: r.data.devices || [], creators: r.data.creators || [] } } catch (error) { this.filterOptions = { devices: [], creators: [] } }
},
createdSourceLabel(source) { return { admin: '管理端', client_api: '客户端接口', agent: 'Agent' }[source] || '未记录' },
creatorLabel(task) {
if (task.createdSource === 'agent') return 'Agent'
if (!task.createdSource) return '未记录'
return task.createdByName || (task.createdBy ? `用户 #${task.createdBy}` : '未记录')
},
// #372: export every page of the filters as they are at click time (params are copied once).
async exportExcel() {
if (this.exporting || this.orderFilter.error) return
+5
View File
@@ -59,3 +59,8 @@ test('late creation response after navigation does not retain a secret', async()
resolve({ data: { secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }}); await pending
expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
})
test('agent release actions have distinct labels', () => {
const p = vm()
expect(p.actionName('release_upload')).toBe('上传 Agent 版本')
expect(p.actionName('release_activate')).toBe('设置当前 Agent 版本')
})