Compare commits

..
96 changed files with 4770 additions and 510 deletions
@@ -249,16 +249,7 @@ class PurchaseRehearsalExecutor(
pddForegroundObserved = true
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
// A product-page accessibility tree can appear before PDD has
// finished wiring click listeners and bottom-sheet transitions.
// Give the page one short settle window, then require the
// evidence again before moving to the spec entry.
pause(PRODUCT_PAGE_SETTLE_MILLIS)
val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
if (settled.hasPurchaseProductEvidence()) return null
stableEvidenceReads = 0
}
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
pause(OPEN_PRODUCT_POLL_MILLIS)
return@repeat
}
@@ -304,10 +295,7 @@ class PurchaseRehearsalExecutor(
if (screen.hasPurchaseProductEvidence()) {
stableEvidenceReads++
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
pause(PRODUCT_PAGE_SETTLE_MILLIS)
val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
if (settled.hasPurchaseProductEvidence()) return recoverSoldOut(input, settled)
stableEvidenceReads = 0
return recoverSoldOut(input, screen)
}
} else {
stableEvidenceReads = 0
@@ -353,9 +341,6 @@ class PurchaseRehearsalExecutor(
var screen = currentScreen(input)
var entryReadyWaitPolls = 0
var target: SnapshotNode? = null
var stableTargetPath: String? = null
var stableTargetBounds: NodeBounds? = null
var stableTargetReads = 0
while (target == null) {
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
@@ -374,38 +359,8 @@ class PurchaseRehearsalExecutor(
panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls))
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]")
}
val candidate = candidates.singleOrNull()?.second
if (candidate != null) {
val unchanged = candidate.path == stableTargetPath && candidate.bounds == stableTargetBounds
stableTargetReads = if (unchanged) stableTargetReads + 1 else 1
stableTargetPath = candidate.path
stableTargetBounds = candidate.bounds
if (stableTargetReads >= SPEC_ENTRY_STABLE_READS) {
// Reacquire once more immediately before clicking so a node
// rebuilt during the settle window is never reused.
val latest = currentScreen(input)
val latestCandidates = listOfNotNull(
latest.specEntry?.let { anchor -> anchor to (latest.specEntryClickTarget ?: anchor) },
latest.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
.let { candidatesToFilter ->
action.textAliases?.let { aliases ->
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(latest, anchor, aliases) }
} ?: candidatesToFilter
}
if (latestCandidates.size > 1) {
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口点击目标不唯一 [${specEntryEvidence(latest, latestCandidates.size, entryReadyWaitPolls)}]")
}
target = latestCandidates.singleOrNull()?.second
if (target != null) screen = latest
if (target != null) continue
stableTargetReads = 0
}
} else {
stableTargetReads = 0
stableTargetPath = null
stableTargetBounds = null
}
target = candidates.singleOrNull()?.second
if (target != null) continue
if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) {
panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls))
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]")
@@ -430,58 +385,11 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
var gestureBaseline = wait.screen
if (wait.changed) {
// A page transition can be caused by PDD rerendering the entry before
// the bottom sheet becomes observable. Re-locate the fresh entry and
// allow exactly one controlled retry while still on the same product.
if (!wait.screen.isPddPackage || !wait.screen.pageEvidenceMatched) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]")
}
val retryScreen = currentScreen(input)
if (!retryScreen.isPddPackage || !retryScreen.pageEvidenceMatched || retryScreen.specPanelOpen) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]")
}
val retryCandidates = listOfNotNull(
retryScreen.specEntry?.let { anchor -> anchor to (retryScreen.specEntryClickTarget ?: anchor) },
retryScreen.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
.let { candidatesToFilter ->
action.textAliases?.let { aliases ->
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(retryScreen, anchor, aliases) }
} ?: candidatesToFilter
}
if (retryCandidates.size != 1) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]")
}
target = retryCandidates.single().second
screen = retryScreen
gestureBaseline = retryScreen
}
// Give PDD's bottom-sheet re-render a short settle window after an
// accessibility click that produced no observable transition, then
// reacquire the target before the single controlled gesture retry.
if (!wait.changed) {
pause(SPEC_ENTRY_GESTURE_RETRY_SETTLE_MILLIS)
val refreshed = currentScreen(input)
if (!refreshed.isPddPackage || !refreshed.pageEvidenceMatched || refreshed.specPanelOpen) {
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(refreshed)}]")
}
val refreshedCandidates = listOfNotNull(
refreshed.specEntry?.let { anchor -> anchor to (refreshed.specEntryClickTarget ?: anchor) },
refreshed.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
.let { candidatesToFilter ->
action.textAliases?.let { aliases ->
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(refreshed, anchor, aliases) }
} ?: candidatesToFilter
}
if (refreshedCandidates.size != 1) {
return failure(SPEC_ENTRY_CLICK_FAILED, "gesture_failed_after_action_click_no_effect")
}
target = refreshedCandidates.single().second
gestureBaseline = refreshed
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
@@ -495,7 +403,7 @@ class PurchaseRehearsalExecutor(
click.reason.specEntrySubreasonAfterGestureFailure(),
)
}
wait = waitForSpecPanel(input, specActionSignature(gestureBaseline))
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
@@ -569,7 +477,6 @@ class PurchaseRehearsalExecutor(
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
"source=${screen.specEntrySource ?: "none"};" +
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
@@ -1254,14 +1161,11 @@ class PurchaseRehearsalExecutor(
private const val OPEN_PRODUCT_POLL_LIMIT = 150
private const val PRODUCT_PAGE_POLL_LIMIT = 150
private const val PRODUCT_PAGE_STABLE_READS = 2
private const val PRODUCT_PAGE_SETTLE_MILLIS = 1_000L
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_STABLE_READS = 2
private const val SPEC_ENTRY_GESTURE_RETRY_SETTLE_MILLIS = 500L
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
private const val SPEC_POST_CLICK_VERIFY_POLLS = 50
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
private const val SPEC_SELECTION_POLL_MILLIS = 100L
@@ -73,7 +73,6 @@ data class CurrentPageIdentity(
data class PurchaseAgentTask(
val taskId: Long,
val taskAttemptId: String,
val attemptNumber: Int,
val phase: String,
val executionMode: String,
val status: String,
@@ -590,7 +589,6 @@ class AgentApiClient(private val serverUrl: String) {
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
taskId = data.getLong("taskId"),
taskAttemptId = data.optString("taskAttemptId"),
attemptNumber = data.optInt("attemptNumber", 1),
phase = data.optString("phase"),
executionMode = data.getString("executionMode"),
status = data.getString("status"),
@@ -1,7 +1,7 @@
package cn.ilapage.goauto.agent.persistence
internal object AgentDiagnosticSchema {
const val VERSION = 3
const val VERSION = 2
val colorDiagnosticColumns = linkedMapOf(
"color_row_count" to "INTEGER",
@@ -14,15 +14,6 @@ internal object AgentDiagnosticSchema {
"horizontal_swipe_count" to "INTEGER",
)
val purchaseFailureColumns = linkedMapOf(
"attempt_id" to "TEXT",
"device_id" to "INTEGER",
"error_code" to "TEXT",
"failure_message" to "TEXT",
"page_evidence" to "TEXT",
"last_step" to "TEXT",
)
val createTableSql =
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -51,33 +42,14 @@ internal object AgentDiagnosticSchema {
initial_selected_size_count INTEGER,
selected_summary_present INTEGER,
horizontal_swipe_count INTEGER,
attempt_id TEXT,
device_id INTEGER,
error_code TEXT,
failure_message TEXT,
page_evidence TEXT,
last_step TEXT,
agent_version TEXT NOT NULL,
created_at INTEGER NOT NULL
)""".trimIndent()
fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
if (oldVersion >= newVersion) return emptyList()
val statements = mutableListOf<String>()
if (oldVersion < 2 && newVersion >= 2) {
colorDiagnosticColumns.forEach { (name, definition) ->
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
}
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
if (oldVersion >= 2 || newVersion < 2) return emptyList()
return colorDiagnosticColumns.mapNotNull { (name, definition) ->
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
}
if (oldVersion < 3 && newVersion >= 3) {
purchaseFailureColumns.forEach { (name, definition) ->
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
}
}
return statements
}
// Kept for existing migration tests and callers; new code should use migrationStatements.
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> =
migrationStatements(oldVersion, newVersion, existingColumns)
}
@@ -4,8 +4,6 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import org.json.JSONArray
import org.json.JSONObject
import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage {
@@ -73,7 +71,6 @@ enum class AgentDiagnosticReason {
LINK_AMBIGUOUS,
LINK_NOT_FOUND,
UNKNOWN,
PURCHASE_FAILURE,
}
data class AgentDiagnosticEvent(
@@ -102,12 +99,6 @@ data class AgentDiagnosticEvent(
val initialSelectedSizeCount: Int? = null,
val selectedSummaryPresent: Boolean? = null,
val horizontalSwipeCount: Int? = null,
val attemptId: String? = null,
val deviceId: Long? = null,
val errorCode: String? = null,
val failureMessage: String? = null,
val pageEvidence: String? = null,
val lastStep: String? = null,
val createdAt: Long = System.currentTimeMillis(),
)
@@ -127,7 +118,7 @@ internal object AgentDiagnosticRetentionPolicy {
fun cutoff(createdAt: Long): Long = createdAt - RETENTION_MILLIS
}
class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(AgentDiagnosticSchema.createTableSql)
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
@@ -135,7 +126,7 @@ class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(cont
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val existing = columnNames(db)
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
}
private fun columnNames(db: SQLiteDatabase): Set<String> =
@@ -180,12 +171,6 @@ class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(cont
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
event.attemptId?.let { put("attempt_id", it.take(MAX_TEXT_CHARS)) }
putNullableLong("device_id", event.deviceId)
event.errorCode?.let { put("error_code", it.take(MAX_ERROR_CODE_CHARS)) }
event.failureMessage?.let { put("failure_message", sanitizeText(it)) }
event.pageEvidence?.let { put("page_evidence", sanitizeText(it)) }
event.lastStep?.let { put("last_step", it.take(MAX_TEXT_CHARS)) }
put("agent_version", BuildConfig.VERSION_NAME)
put("created_at", event.createdAt)
}
@@ -201,53 +186,6 @@ class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(cont
}
}
/** Returns only the allow-listed, structured fields; raw accessibility data is never exported. */
@Synchronized
fun exportTaskJson(taskId: Long): String {
require(taskId > 0)
val events = JSONArray()
readableDatabase.query(
"agent_diagnostic",
arrayOf(
"task_id", "stage", "reason", "attempt", "elapsed_ms", "attempt_id", "device_id",
"error_code", "failure_message", "page_evidence", "last_step", "agent_version", "created_at",
),
"task_id = ?",
arrayOf(taskId.toString()),
null,
null,
"id ASC",
).use { cursor ->
while (cursor.moveToNext()) {
events.put(JSONObject().apply {
put("taskId", cursor.getLong(0))
put("stage", cursor.getString(1))
put("reason", cursor.getString(2))
put("attempt", cursor.getInt(3))
put("elapsedMs", cursor.getLong(4))
putNullable("attemptId", cursor, 5)
if (!cursor.isNull(6)) put("deviceId", cursor.getLong(6))
putNullable("errorCode", cursor, 7)
putNullable("message", cursor, 8)
putNullable("pageEvidence", cursor, 9)
putNullable("lastStep", cursor, 10)
put("agentVersion", cursor.getString(11))
put("createdAt", cursor.getLong(12))
})
}
}
return JSONObject().put("taskId", taskId).put("events", events).toString()
}
/** Writes a task export into app-specific external storage so it can be pulled over USB. */
@Synchronized
fun exportTaskJsonFile(taskId: Long, fileName: String = "task-$taskId.json"): java.io.File {
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_").take(96)
val directory = context.getExternalFilesDir("diagnostics") ?: error("诊断导出目录不可用")
if (!directory.exists() && !directory.mkdirs()) error("诊断导出目录创建失败")
return java.io.File(directory, safeName).apply { writeText(exportTaskJson(taskId), Charsets.UTF_8) }
}
private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) {
value?.let { put(key, if (it) 1 else 0) }
}
@@ -256,25 +194,11 @@ class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(cont
value?.let { put(key, it.coerceAtLeast(0)) }
}
private fun ContentValues.putNullableLong(key: String, value: Long?) {
value?.let { put(key, it) }
}
private fun JSONObject.putNullable(key: String, cursor: android.database.Cursor, index: Int) {
if (!cursor.isNull(index)) put(key, cursor.getString(index))
}
private fun sanitizeText(value: String): String = value
.replace(Regex("(?i)(addressSuffix|收货地址|详细地址)\\s*[:=:][^;,,\\]]+"), "[REDACTED]")
.take(MAX_TEXT_CHARS)
companion object {
private const val DATABASE_NAME = "goauto_diagnostics.db"
private const val DATABASE_VERSION = AgentDiagnosticSchema.VERSION
private const val MAX_CLASS_NAME_CHARS = 160
private const val MAX_ROW_VALUE_COUNTS_CHARS = 160
private const val MAX_TEXT_CHARS = 512
private const val MAX_ERROR_CODE_CHARS = 96
private val ROW_VALUE_COUNTS_PATTERN = Regex("[0-9]+(?:,[0-9]+)*")
private val ALLOWED_ZONES = setOf("top-left", "top-center", "top-right", "middle", "bottom")
}
@@ -445,10 +445,7 @@ class AgentForegroundService : Service() {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
var diagnosticRecorded = false
var activeTask = initial
val lastStep = AtomicReference("started")
val lastPanelEvidence = AtomicReference<String?>(null)
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -456,7 +453,6 @@ class AgentForegroundService : Service() {
val task = if (claimed.status == "pending") {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed
activeTask = task
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
@@ -490,10 +486,7 @@ class AgentForegroundService : Service() {
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence ->
lastPanelEvidence.set(evidence)
Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence")
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -533,10 +526,6 @@ class AgentForegroundService : Service() {
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
if (outcome.resultType == "failed") {
recordPurchaseDiagnostic(task, outcome, lastStep.get(), lastPanelEvidence.get())
diagnosticRecorded = true
}
PurchaseResultBubblePolicy.create(
taskId = task.taskId,
resultType = outcome.resultType,
@@ -551,24 +540,8 @@ class AgentForegroundService : Service() {
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
} catch (error: AgentApiException) {
if (!diagnosticRecorded) {
recordPurchaseDiagnostic(
activeTask,
PurchaseExecutionOutcome("failed", error.code, error.message ?: "采购接口调用失败"),
lastStep.get(),
lastPanelEvidence.get(),
)
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
} catch (error: Exception) {
if (!diagnosticRecorded) {
recordPurchaseDiagnostic(
activeTask,
PurchaseExecutionOutcome("failed", "AGENT_PURCHASE_EXCEPTION", error.message ?: "采购执行异常"),
lastStep.get(),
lastPanelEvidence.get(),
)
}
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
@@ -576,45 +549,6 @@ class AgentForegroundService : Service() {
}
}
private fun recordPurchaseDiagnostic(
task: PurchaseAgentTask,
outcome: PurchaseExecutionOutcome,
lastStep: String,
panelEvidence: String?,
) {
if (task.taskId <= 0L) return
val deviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull()
val event = AgentDiagnosticEvent(
taskId = task.taskId,
stage = purchaseDiagnosticStage(lastStep),
reason = AgentDiagnosticReason.PURCHASE_FAILURE,
attempt = task.attemptNumber,
attemptId = task.taskAttemptId.takeIf { it.isNotBlank() },
deviceId = deviceId,
errorCode = outcome.errorCode,
failureMessage = outcome.message,
pageEvidence = panelEvidence,
lastStep = lastStep,
)
diagnosticExecutor.execute {
runCatching {
diagnosticStore.record(event)
val export = diagnosticStore.exportTaskJsonFile(task.taskId)
Log.i("GoAutoDiagnostic", "purchase diagnostic exported task=${task.taskId};file=${export.absolutePath}")
}.onFailure { error ->
Log.w("GoAutoDiagnostic", "purchase diagnostic export failed: ${error.javaClass.simpleName}")
}
}
}
private fun purchaseDiagnosticStage(step: String): AgentDiagnosticStage = when (step) {
"openProduct" -> AgentDiagnosticStage.DETAIL_ENTRY
"openSpecPanel" -> AgentDiagnosticStage.SPEC_PANEL_ENTRY
"selectSpec" -> AgentDiagnosticStage.SIZE_DISCOVERY
"verifyUnitPrice", "verifyOrderSummary" -> AgentDiagnosticStage.PAGE_STABILITY
else -> AgentDiagnosticStage.DETAIL_ENTRY
}
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? {
val snapshot = accessibility.capture()
val activity = snapshot.activityName ?: return null
@@ -788,21 +788,6 @@ class PurchaseRehearsalExecutorTest {
assertFalse(outcome.message.orEmpty().contains("确认款式"))
}
@Test
fun `changed product page retries a still visible spec entry once`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
entryActionChangesPageWithoutPanel = true,
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.toString(), "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.openEntryClickCount)
assertEquals(1, driver.specTapCount)
}
@Test
fun `unchanged spec entry action uses one verified center gesture`() {
val driver = FakePurchaseDriver(
@@ -861,7 +846,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
)
assertEquals(21, pauses.count { it == 100L })
@@ -879,7 +864,7 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.clickedPaths.contains("buy"))
// One 100ms pause belongs to the existing open-product foreground poll;
// two belong to the entry-ready wait before the bottom bar appears.
assertEquals(4, pauses.count { it == 100L })
assertEquals(3, pauses.count { it == 100L })
}
@Test
@@ -892,7 +877,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
// Two stable reads belong to reopening the product; verifyProduct then
// independently requires its second stable read before continuing.
assertEquals(4, pauses.count { it == 100L })
assertEquals(3, pauses.count { it == 100L })
}
@Test
@@ -1057,7 +1042,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
outcome.message,
)
@@ -1067,7 +1052,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
assertEquals(
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;source=explicit_selection;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=1;entryReadyWaitMillis=100]",
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]",
ambiguous.message,
)
}
@@ -1242,7 +1227,6 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val entryActionChangesPageWithoutPanel: Boolean = false,
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1290,7 +1274,6 @@ class PurchaseRehearsalExecutorTest {
private var reviewPage = false
private var pddCaptureCount = 0
private var browserCaptureCount = 0
private var entryActionChanged = false
private var hiddenColorRestored = false
private var horizontalColorPage = 0
private var horizontalSizePage = 0
@@ -1364,7 +1347,6 @@ class PurchaseRehearsalExecutorTest {
} else if (!missingSpecEntry && specEntryReady) {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (entryActionChanged) nodes += node("rerender", "页面已刷新", 20, 1100, 400, 1180)
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
@@ -1481,10 +1463,7 @@ class PurchaseRehearsalExecutorTest {
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
return result
}
"选择规格", "免拼购买" -> {
if (entryActionChangesPageWithoutPanel) entryActionChanged = true
if (entryActionHasEffect) panel = true
}
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
in (horizontalSizePages.orEmpty().flatten() + sizes) -> {
sizeClickCount++
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
@@ -1541,9 +1520,6 @@ class PurchaseRehearsalExecutorTest {
return FreshActionResult.SUCCESS
}
val openEntryClickCount: Int
get() = clicked.count { it == "选择规格" || it == "免拼购买" }
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
quantity = value.toLong()
return FreshActionResult.SUCCESS
@@ -83,24 +83,6 @@ class AgentDiagnosticStoreMigrationTest {
assertEquals(1, rowCount(db))
}
@Test
fun v2MigrationAddsPurchaseFailureColumnsWithoutDroppingRows() = withDatabase { db ->
db.createStatement().use { statement ->
statement.execute(CREATE_V1_TABLE_SQL)
statement.execute(
"INSERT INTO agent_diagnostic " +
"(task_id, stage, reason, attempt, elapsed_ms, agent_version, created_at) " +
"VALUES (153, 'SPEC_PANEL_ENTRY', 'PURCHASE_FAILURE', 2, 120, '0.9.99', 1000)",
)
}
AgentDiagnosticSchema.migrationStatements(1, 3, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseFailureColumns.keys))
assertEquals(1, rowCount(db))
assertTrue(AgentDiagnosticSchema.migrationStatements(3, 3, columnNames(db)).isEmpty())
}
private fun migrateV1ToV2(db: Connection) {
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
synchronized_at: 2026-09-05T07:16:39Z
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
synchronized_at: 2026-09-07T09:27:40Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -61,6 +61,7 @@ GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布
## 环境与凭据
- 服务端正式环境默认必须使用 HTTPS。仅当管理员接受 Device Token、任务内容和执行结果明文传输风险,并显式设置 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 时,Agent `/api/agent/v1/**` 可通过 HTTP;管理端和第三方服务不因此放宽。
- #237 客户端密钥例外(用户 2026-09-07 明确接受风险):提交 `71f7751` 起,管理员密钥管理及 `/api/client/v1` 默认兼容 HTTP/HTTPS,无开关;HTTP 明文传输密钥及业务数据,建议优先 HTTPS。实际迁移部署仍须单独授权;此例外不改变其他第三方服务的安全边界。
- 每台设备使用独立 Device Token;Token 只存安全配置,不进入仓库。
- PDD 账号密码、Cookie、验证码和用户个人数据不得进入日志。
- 根目录 `gitea.env` 是本机工单访问配置,已被 Git 忽略。
+13 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:50Z
wiki_revision: 0b9d4e6e0e97f495d19a53e1aa0ecea3a66ba495
synchronized_at: 2026-09-07T09:27:49Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -333,3 +333,14 @@ PddProductDetailCollector
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
## 客户端密钥访问架构(#237)
代码基线 `71f7751`(含用户确认的默认 HTTP/HTTPS 兼容),2026-09-07 完成 Server/Web 代码与隔离测试;实际迁移、权限写入和部署尚未执行,不表示现有线上能力。
- `server/app/goauto/clientkey` 管理独立密钥、授权及审计;`clientapi/routes.go` 的显式 Inventory 将 `/api/client/v1` 映射到既有业务处理器,绝不转发任意 Admin 路由。
- `clientapi/gateway.go` 默认接受 HTTP 与 HTTPS,无协议开关或转发协议头门禁,按 Bearer 密钥、模块及能力顺序校验,每次请求读取数据库,无授权缓存。`common/clientprincipal` 传递独立客户端身份,不生成或伪装管理员 JWT。
- `client_api_key` 保存名称、随机 256 位密钥的 SHA-256 摘要、前缀、授权 JSON、启用状态、版本、创建/修改人和最后使用时间;完整密钥仅创建响应一次返回。`client_api_key_audit` 保存管理变更和客户端请求元数据,不保存请求正文、查询参数、响应正文或凭据。
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
- Admin 页面 `web/src/views/goauto/client-keys/index.vue` 复用创建/编辑授权弹窗;菜单位于“采采管理”,仅管理员可见。新追加迁移 `1788798000000_client_api_key.go` 创建两表及管理员菜单,不改 Android。
+19 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 670a5592a6db8301cd115295bf820f7f4b6e06d7
synchronized_at: 2026-09-07T03:21:47Z
wiki_revision: 17e346d6e9398abe2188280112fc04d88fd10d88
synchronized_at: 2026-09-07T09:27:54Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -73,7 +73,7 @@ synchronized_at: 2026-09-07T03:21:47Z
## SYB 后台导入记录
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
- 导入允许管理员和采购员(purchaser)创建,创建成功后立即转入后台执行;页面关闭不取消任务。其他已登录角色只能查看同步记录,不能开始导入(#236)。
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
@@ -168,7 +168,7 @@ synchronized_at: 2026-09-07T03:21:47Z
- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。
- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
- AI 规格匹配菜单对采购员硬排除:采购员不显示该菜单,角色配置也不能为采购员选中该模块;既有 API 权限不变,仍只允许读取是否启用,不得读取 Provider 地址、模型、API Key,也不得保存或测试。
- GoAuto 菜单由代码维护的模块定义幂等写入系统菜单和菜单/API 关联;迁移只为采购员追加首次引入且默认开放的模块,不会把采购员人工取消勾选的既有模块重新加回。
- 采购员 Casbin API 白名单由代码权限矩阵全量重建,不从角色菜单勾选反推;减少权限时旧策略必须删除。菜单勾选只控制可见模块,不能扩大采购员 API 权限。
@@ -230,7 +230,7 @@ synchronized_at: 2026-09-07T03:21:47Z
- 定时任务失败时明确记录失败原因,不自动重试。服务重启后由既有启动恢复逻辑处理遗留的运行中记录。
- 单次同步内部的 SYB 只读请求(货运单总数、列表和明细)遇到暂时网络故障、5xx 或异常响应时最多执行 3 次,分别退避 1 秒、2 秒,单次 HTTP 总超时 60 秒;这不等于失败任务自动重试。明确未登录、业务失败、数据完整性错误以及任何写操作均不自动重试。
- SYB 商品页不再提供“导入”和“同步记录”快捷按钮,也不查询、展示或轮询同步状态;后台同步成功、失败或中断均不在商品页弹出消息,商品数据由用户主动查询或刷新获取。
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员可在同步记录页人工发起覆盖昨天和今天的同步,采购员只读;人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
- 独立“SYB 同步记录”页面是同步结果的唯一 Web 展示位置,保留运行状态、进度、数量、失败原因和店铺统计;go-admin 定时任务中的 `GoAutoSYBHourlySync` 提供“执行记录”入口。管理员和采购员可在同步记录页人工发起覆盖昨天和今天的同步,其他角色只读(#236);人工与定时同步共用导入服务、执行记录和互斥,不排队、不并发,也不立即重试。
## cmautobuy 商品导入
@@ -444,3 +444,17 @@ synchronized_at: 2026-09-07T03:21:47Z
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
## 客户端密钥与可编辑模块授权(#237)
以下为提交 `71f7751` 已实现、尚待实际迁移与部署的规则。
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
- 勾选模块默认只读,至少保留一个模块。读写仅开放清单中的普通写操作;同步、采集、采购、删除、导入、重解析、匹配、回写及切换当前采购规则分别独立授权,默认关闭。没有普通写接口的模块不允许勾选读写。
- 编辑既有密钥不会更换密钥,名称和前缀只读;移除模块同时移除其动作。取消保留原授权;失败保留输入;版本冲突要求刷新重开。停用不可编辑或恢复。
- 保存后新请求按最新授权校验,已经通过校验的在途请求可能完成,不追溯回滚。模块授权不是行级、店铺级或租户隔离,授予读取即允许读取该模块明确接口可返回的业务范围。
- 用户于 2026-09-07 明确接受明文风险:密钥管理及客户端接口默认兼容 HTTP/HTTPS,无需开关。HTTP 会明文传输密钥与业务数据,建议优先使用 HTTPS;兼容不改变管理员身份与模块授权边界。
- 客户端与 Admin 登录 JWT、Agent Device Token 独立。响应排除凭据及原始载荷字段;AI 设置只返回 enabled,不开放 Provider 配置读写或连接测试。设备仅开放列表,不开放身份重置、令牌或解锁接口。
- 执行动作复用既有业务门禁、幂等参数及状态机;客户端采购 batch-retry 沿用 Admin 原有语义,不等同于 Agent 就地 reset。授权重采购、支付复核、取消订单等未列入接口不开放;永久禁止付款。
- 创建响应丢失时不可找回完整密钥,应核对列表并停用可能已创建的记录,再明确创建新密钥,不能盲目自动重试。完整密钥只在创建结果弹窗内存中显示,关闭或离开页面清空,不写浏览器持久存储。
+102 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: f3242938de4ac55c47f5c6eebd69184024e6a0ea
synchronized_at: 2026-09-05T09:04:42Z
wiki_revision: 36f6f6081f0cfdfd8a4a395a27eaef0a6cd88cda
synchronized_at: 2026-09-07T09:28:25Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -75,7 +75,7 @@ POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
`import` 仅允许管理员调用。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
@@ -867,3 +867,102 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
## 客户端 API 与管理员密钥管理(#237)
实现基线 `71f7751`;以下接口已通过隔离测试,尚未完成真实迁移/部署联调。Android 接口不变。
### 管理接口
前缀 `/api/admin/v1/client-keys`,要求 Admin JWT 和 admin 角色,默认接受 HTTP/HTTPS,响应 `Cache-Control: no-store`。
| 方法与相对路径 | 输入 | 成功 data |
|---|---|---|
| GET 空路径 | page,固定每页 20 | items、total、page、pageSize |
| GET /modules | 无 | 模块数组:key、title、group、writable、actions |
| POST 空路径 | name(1~80 字符)、grants | key 元数据与仅本次返回的 secret |
| PATCH /:keyId/grants | version、grants | 更新后的元数据 |
| POST /:keyId/disable | version | 停用后的元数据 |
授权示例:`{"module":"pdd_products","write":false,"actions":[]}`;grants 为非空数组。元数据包括 id、name、prefix、enabled、version、grants、createdBy、updatedBy、createdAt、updatedAt、lastUsedAt,不返回摘要或完整密钥。管理请求只接受单个 JSON 对象、拒绝未知字段、最大 64 KiB。成功 code=200;无效输入 422、不存在 404、授权版本冲突或已停用 409。
### 客户端访问与错误语义
- 前缀 `/api/client/v1`,只接受 `Authorization: Bearer <客户端密钥>`,不接受 Cookie 或 URL 凭据,不与 JWT、Device Token 通用。
- 根据用户 2026-09-07 的明确确认,管理与客户端接口在所有环境默认接受 HTTP/HTTPS,不设置协议开关,也不依赖 X-Forwarded-Proto 或 GOAUTO_TRUST_FORWARDED_PROTO。HTTP 明文传输密钥及业务数据,优先使用 HTTPS;不影响其他接口各自的协议要求。
- 不再因 HTTP 返回 426;401 为缺失、无效或停用密钥;403 为模块/动作未授权;400 为查询参数携带凭据;503 为认证或审计暂不可用。业务错误沿用各既有接口。
- 一般请求体最大 16 MiB;响应只支持有限 JSON(32 MiB),递归过滤凭据与原始载荷字段。不支持直接流式/二进制文件接口。响应不可序列化或超限时返回 502;业务可能已执行,必须先核对结果,不要自动重试。
- 通过密钥认证的请求由服务端生成 `X-Client-Request-Id` 关联审计;它不是业务幂等键,原业务接口要求的 requestId 等字段仍须提供。允许请求必须先落审计意图;完成状态更新失败可留下 status=0。无效密钥没有 key_id 关联审计;拒绝授权的 403 审计为尽力记录。
- GET `/ai-matching-settings` 只返回 `data.enabled`。POST `/ai-matching-settings/resolve` 接受 targetColor、targetSize、colors、sizes;每组最多 200 项,每个值最多 255 字符,总请求最大 64 KiB;确定性优先,必要时使用当前 Provider,返回 mappedColor、mappedSize、source;不保存映射、不创建任务,无法可靠匹配返回 422 安全提示。
### 明确开放的接口清单
下表路径均相对 `/api/client/v1`;普通业务请求字段沿用本文对应 Admin 业务契约。read=选中模块,write=模块读写,其他值均需 actions 逐项授权。没有列出的 Admin 接口不能用客户端密钥调用;新增 Admin 路由不会自动开放。
| 方法 | 路径 | 模块键 | 能力 |
|---|---|---|---|
| POST | `/ai-matching-settings/resolve` | ai_matching | match |
| GET | `/devices` | devices | read |
| GET | `/pdd-products` | pdd_products | read |
| GET | `/pdd-products/:productId` | pdd_products | read |
| POST | `/pdd-products` | pdd_products | write |
| PATCH | `/pdd-products/:productId` | pdd_products | write |
| GET | `/shopee-products` | shopee_products | read |
| GET | `/shopee-products/:productId` | shopee_products | read |
| POST | `/shopee-products` | shopee_products | write |
| PATCH | `/shopee-products/:productId` | shopee_products | write |
| POST | `/shopee-products/:productId/link-pdd` | shopee_products | write |
| POST | `/shopee-products/:productId/specs/values` | shopee_products | write |
| PUT | `/shopee-products/:productId/specs/mapping` | shopee_products | write |
| DELETE | `/shopee-products/:productId/specs/values` | shopee_products | delete |
| DELETE | `/shopee-products/:productId/specs/mapping` | shopee_products | delete |
| POST | `/shopee-products/batch-delete` | shopee_products | delete |
| POST | `/shopee-products/:productId/specs/mapping/auto-match` | shopee_products | match |
| POST | `/shopee-products/:productId/specs/mapping/confirm` | shopee_products | match |
| GET | `/syb-products` | syb_products | read |
| GET | `/syb-products/:productId` | syb_products | read |
| PATCH | `/syb-products/:productId/correction` | syb_products | write |
| POST | `/syb-products/:productId/reparse` | syb_products | reparse |
| POST | `/syb-products/reparse-batch` | syb_products | reparse |
| GET | `/syb-products/sync-runs` | syb_sync_runs | read |
| GET | `/syb-products/sync-runs/:runId` | syb_sync_runs | read |
| POST | `/syb-products/import` | syb_sync_runs | sync |
| GET | `/syb-inner-codes` | syb_inner_codes | read |
| GET | `/syb-inner-codes/:recordId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/match-jobs/:jobId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/apply-batches/:batchId` | syb_inner_codes | read |
| POST | `/syb-inner-codes/rematch` | syb_inner_codes | match |
| POST | `/syb-inner-codes/import` | syb_inner_codes | import |
| POST | `/syb-inner-codes/batch-delete` | syb_inner_codes | delete |
| POST | `/syb-inner-codes/apply-preview` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/apply` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/:recordId/recheck` | syb_inner_codes | match |
| GET | `/syb-shops` | syb_shops | read |
| POST | `/syb-shops` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/name` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/enabled` | syb_shops | write |
| DELETE | `/syb-shops/:shopId` | syb_shops | delete |
| GET | `/collection-rules` | collection_rules | read |
| POST | `/collection-rules` | collection_rules | write |
| PATCH | `/collection-rules/:ruleId` | collection_rules | write |
| DELETE | `/collection-rules/:ruleId` | collection_rules | delete |
| GET | `/purchase-rules` | purchase_rules | read |
| GET | `/purchase-rules/current` | purchase_rules | read |
| POST | `/purchase-rules` | purchase_rules | write |
| PATCH | `/purchase-rules/:ruleId` | purchase_rules | write |
| DELETE | `/purchase-rules/:ruleId` | purchase_rules | delete |
| PUT | `/purchase-rules/current` | purchase_rules | activate |
| GET | `/collection-tasks` | collection_tasks | read |
| GET | `/collection-tasks/:taskId` | collection_tasks | read |
| POST | `/collection-tasks` | collection_tasks | collect |
| POST | `/collection-tasks/batch` | collection_tasks | collect |
| POST | `/collection-tasks/:taskId/reset` | collection_tasks | collect |
| DELETE | `/collection-tasks/:taskId` | collection_tasks | delete |
| GET | `/purchase-tasks` | purchase_tasks | read |
| GET | `/purchase-tasks/:taskId` | purchase_tasks | read |
| POST | `/purchase-tasks/batch-preview` | purchase_tasks | purchase |
| POST | `/purchase-tasks` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch-retry` | purchase_tasks | purchase |
| POST | `/purchase-tasks/stock` | purchase_tasks | purchase |
| GET | `/ai-matching-settings` | ai_matching | read |
+5 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: df0fe874c7f3f3e9c031f2f793f8f6a0511ace25
synchronized_at: 2026-09-07T06:25:27Z
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
synchronized_at: 2026-09-07T07:02:57Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -239,6 +239,9 @@ expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应
### 4.4 统一日期范围同步与覆盖游标
GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)使用(#236),固定覆盖昨天和今天;其他已登录角色仅可查看记录。复用既有同步互斥、日期校验、启用店铺筛选及操作人审计,不授予店铺配置、凭据或定时任务管理权限。权限代码发布并完成启动对账后生效。
页面只有一个同步入口,操作员确认工具条上的开始日和结束日后发起。首次打开页面
固定默认昨天到今天,不因 `last_synced_at` 更早而自动扩大范围;需要补历史缺口时
由操作员明确选择日期,单次仍不得超过 31 天。
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:08Z
wiki_revision: e936b9e3a5850dea31c139c09e8aca8584d9a6dd
synchronized_at: 2026-09-07T09:28:03Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -70,3 +70,15 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
## 客户端密钥部署与验证(#237)
实现基线 `71f7751`;2026-09-07 仅完成代码及隔离测试,尚未执行实际数据库迁移、权限写入、服务重启或部署。
- 发布前须单独授权追加迁移 `server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go`,按既有迁移流程创建 client_api_key、client_api_key_audit 和“采采管理/客户端密钥”管理员菜单。前置父菜单必须存在;不应以赋予普通用户管理员角色代替迁移或权限核验。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。当前并未实际部署,不能据此宣称线上已经可用。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
- 隔离验证:Server `go test ./app/goauto/clientkey ./app/goauto/clientapi ./cmd/migrate/migration/version-local`;Web `pnpm exec jest tests/unit/client-keys.spec.js --runInBand` 与 `pnpm run build:prod`。浏览器模拟入口 `/tests/fixtures/client-keys.html` 仅由本地 Vite 开发服务承载,使用内存模拟请求和无效示例密钥,不连接真实数据库,不证明线上鉴权已验收。
- 真实部署验收须另行验证实际 HTTP/HTTPS 入口、管理员创建/编辑/停用、普通用户拒绝、读写/独立动作隔离、停用后的后续请求拒绝及日志无密钥;任何真实业务执行继续按独立授权范围进行。
+2
View File
@@ -1,6 +1,7 @@
package router
import (
goautoclientapi "go-admin/app/goauto/clientapi"
"os"
"github.com/gin-gonic/gin"
@@ -53,6 +54,7 @@ func InitRouter() {
// 注册 GoAuto Agent 与设备管理路由。
goautodevice.InitRouter(r, authMiddleware)
goautoclientapi.InitRouter(r, authMiddleware)
goautoapprelease.InitRouter(r, authMiddleware)
goautoaimatching.InitRouter(r, authMiddleware)
goautotask.InitRouter(r, authMiddleware)
+2
View File
@@ -136,6 +136,8 @@ func moduleKeyForAPI(path string) string {
return ModulePDDProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-spec-auto-match"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
return ModuleSYBSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
+3 -1
View File
@@ -48,13 +48,15 @@ var AdminAPIs = []APIPermission{
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
{"手动执行虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs", "POST", false},
{"查看最近虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs/latest", "GET", false},
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
{"重新解析 SYB 商品", "/api/admin/v1/syb-products/:productId/reparse", "POST", true},
{"批量重新解析 SYB 商品", "/api/admin/v1/syb-products/reparse-batch", "POST", true},
{"人工修正 SYB 商品", "/api/admin/v1/syb-products/:productId/correction", "PATCH", true},
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", false},
{"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", true},
{"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true},
{"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true},
+6 -4
View File
@@ -15,10 +15,12 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
denied := map[string]bool{
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-products/import": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-shops/discover": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
}
for _, permission := range PurchaserAPIs() {
if denied[permission.Method+" "+permission.Path] {
+3 -1
View File
@@ -55,7 +55,9 @@ func TestReconcilePurchaserPermissions(t *testing.T) {
}
assertPolicyCount(t, db, "custom-role", "/custom", "GET", 1)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 0)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-products/import", "POST", 1)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops/discover", "POST", 0)
assertPolicyCount(t, db, RolePurchaser, "/api/admin/v1/syb-shops", "POST", 0)
}
func TestReconcilePurchaserPermissionsRemovesOnlyStalePurchaserGrant(t *testing.T) {
+96 -2
View File
@@ -25,12 +25,28 @@ const (
defaultAutoConfirmMinConfidence = 0.9
)
// MaxProviderTimeout is also the total budget used by composite synchronous
// AI operations. This keeps their HTTP response inside the Admin and API
// transport windows even when an operation needs more than one provider call.
const MaxProviderTimeout = 600 * time.Second
// Service owns the internal AI Provider configuration. The API key exception
// is deliberately narrow: it is plain text only in the dedicated settings
// table and is returned only by the administrator settings handler.
type Service struct {
DB *gorm.DB
HTTPClient *http.Client
DB *gorm.DB
HTTPClient *http.Client
ProviderFailureLogger func(ProviderFailureDiagnostic)
}
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
// candidates, response body or credential. It is safe for operational logs.
type ProviderFailureDiagnostic struct {
CallID string
Operation string
Kind string
StatusCode int
Duration time.Duration
}
func NewService(db *gorm.DB) *Service {
@@ -179,6 +195,58 @@ func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResul
return result, nil
}
// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's
// exact color/size labels. Unlike Resolve, this operation does not map to PDD:
// every non-empty answer must be an exact member of the supplied Shopee set.
func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) {
request.ProductSpec = strings.TrimSpace(request.ProductSpec)
request.Colors = usableCandidates(request.Colors)
request.Sizes = usableCandidates(request.Sizes)
if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选")
}
setting, apiKey, err := s.activeSetting(ctx)
if err != nil {
return SYBSpecParseResult{}, err
}
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
{Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"},
{Role: "user", Content: sybSpecParsePrompt(request)},
}}
body, err := json.Marshal(payload)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析请求生成失败", Cause: err}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body))
if err != nil {
return SYBSpecParseResult{}, fail(CodeInvalidSetting, "AI 服务地址无效")
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := s.httpClient().Do(httpRequest)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err}
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用")
}
choice, err := parseProviderChoice(responseBody)
if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格")
}
if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由")
}
return SYBSpecParseResult{
Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible,
Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence,
}, nil
}
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
setting, err := s.setting(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
@@ -294,6 +362,22 @@ func validChoice(target, selected string, candidates []string) bool {
return false
}
func validClosedChoice(selected string, candidates []string) bool {
selected = strings.TrimSpace(selected)
if len(candidates) == 0 {
return selected == ""
}
if selected == "" {
return false
}
for _, candidate := range candidates {
if candidate == selected {
return true
}
}
return false
}
func safeReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
@@ -316,6 +400,16 @@ func matchPrompt(request MatchRequest) string {
return string(raw)
}
func sybSpecParsePrompt(request SYBSpecParseRequest) string {
payload := struct {
ProductSpec string `json:"productSpec"`
Colors []string `json:"shopeeColorCandidates,omitempty"`
Sizes []string `json:"shopeeSizeCandidates,omitempty"`
}{request.ProductSpec, request.Colors, request.Sizes}
raw, _ := json.Marshal(payload)
return string(raw)
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -2,6 +2,7 @@ package aimatching
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -135,3 +136,61 @@ func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) {
t.Fatalf("invalid threshold err=%v", err)
}
}
func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
var sent map[string]any
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
raw, err := io.ReadAll(request.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &sent); err != nil {
t.Fatal(err)
}
body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}})
if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 {
t.Fatalf("result=%+v err=%v", result, err)
}
encoded, _ := json.Marshal(sent)
for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded)
}
}
for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} {
if !strings.Contains(string(encoded), required) {
t.Fatalf("provider payload missing %q: %s", required, encoded)
}
}
}
func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
responses := []string{
`{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`,
`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`,
}
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
body := responses[0]
responses = responses[1:]
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
for i := 0; i < 2; i++ {
_, err := service.ResolveSYBSpec(context.Background(), request)
var target *Error
if !errors.As(err, &target) || target.Code != CodeNoMatch {
t.Fatalf("attempt %d err=%v", i, err)
}
}
}
+28 -1
View File
@@ -10,6 +10,9 @@ import (
"net/http"
"strings"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid"
)
// Suggestion limits are enforced defensively here too, even though callers
@@ -95,18 +98,26 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
callID, startedAt := uuid.NewString(), time.Now()
response, err := s.httpClient().Do(httpRequest)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: providerNetworkErrorKind(err), Duration: time.Since(startedAt)})
return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议服务暂时不可用", Cause: err}
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, 1<<20)
responseBody, readErr := io.ReadAll(limited)
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if readErr != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "read_error", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "http_status", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
raw, err := parseSuggestChoices(responseBody)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "invalid_response", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议响应无效")
}
@@ -145,6 +156,22 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
return SuggestResult{Decisions: decisions, Provider: ProviderOpenAICompatible, Model: setting.Model}, nil
}
func (s *Service) logProviderFailure(diagnostic ProviderFailureDiagnostic) {
if s.ProviderFailureLogger != nil {
s.ProviderFailureLogger(diagnostic)
return
}
log.Warnf("AI provider call failed: call_id=%s operation=%s kind=%s status=%d duration_ms=%d",
diagnostic.CallID, diagnostic.Operation, diagnostic.Kind, diagnostic.StatusCode, diagnostic.Duration.Milliseconds())
}
func providerNetworkErrorKind(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "network_error"
}
func suggestSystemPrompt(dimension string) string {
noun := "颜色或尺码"
switch dimension {
@@ -6,7 +6,9 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
@@ -134,3 +136,93 @@ func TestSuggestBatchRequiresConfiguredProvider(t *testing.T) {
t.Fatalf("expected CodeNotConfigured, got %v", target.Code)
}
}
func TestSuggestBatchLogsSafeDiagnosticForProvider502(t *testing.T) {
const sensitiveBody = "api-key-and-provider-body-must-not-be-logged"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, sensitiveBody, http.StatusBadGateway)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "sensitive-source"}},
Candidates: []SuggestCandidate{{ID: "c1", Label: "sensitive-candidate"}},
})
if err == nil {
t.Fatal("expected provider failure")
}
if diagnostic.Operation != "suggest_batch" || diagnostic.Kind != "http_status" || diagnostic.StatusCode != http.StatusBadGateway || diagnostic.CallID == "" {
t.Fatalf("unexpected diagnostic: %+v", diagnostic)
}
printed := fmt.Sprintf("%+v", diagnostic)
for _, secret := range []string{sensitiveBody, "sensitive-source", "sensitive-candidate", "test-key", server.URL} {
if strings.Contains(printed, secret) {
t.Fatalf("diagnostic leaked %q: %s", secret, printed)
}
}
}
func TestSuggestBatchClassifiesProviderTimeout(t *testing.T) {
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, "http://provider.invalid")
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})}
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "timeout" || diagnostic.StatusCode != 0 {
t.Fatalf("timeout was not safely classified: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchClassifiesInvalidResponseWithoutLoggingBody(t *testing.T) {
const sensitiveBody = "not-json-with-sensitive-provider-details"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(sensitiveBody))
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "invalid_response" || strings.Contains(fmt.Sprintf("%+v", diagnostic), sensitiveBody) {
t.Fatalf("invalid response diagnostic is unsafe or missing: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchAllowsProviderResponseAfterTwoSeconds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2100 * time.Millisecond)
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"match"}]}`)(w, r)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
result, err := NewService(db).SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err != nil || result.Decisions["s1"].CandidateID != "c1" {
t.Fatalf("delayed provider response failed: result=%+v err=%v", result, err)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
+18
View File
@@ -29,6 +29,24 @@ type MatchRequest struct {
Sizes []string
}
// SYBSpecParseRequest contains the only source text and closed Shopee
// candidate sets that may leave GoAuto for an AI-assisted SYB parse. It must
// never contain the shipment/order, account, address, price or full raw JSON.
type SYBSpecParseRequest struct {
ProductSpec string
Colors []string
Sizes []string
}
type SYBSpecParseResult struct {
Color string
Size string
Provider string
Model string
Reason string
Confidence *float64
}
type CandidateSnapshot struct {
Colors []string `json:"colors,omitempty"`
Sizes []string `json:"sizes,omitempty"`
+158
View File
@@ -0,0 +1,158 @@
package clientapi
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"go-admin/app/goauto/clientkey"
"go-admin/common/clientprincipal"
"gorm.io/gorm"
)
// Both HTTP and HTTPS are supported by explicit operator decision (#237).
// Transport does not grant identity or permissions; secrets remain non-cacheable.
func NoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Next()
}
func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
deny := func(status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
// Never accept a credential supplied through a URL or cookie fallback.
for k := range c.Request.URL.Query() {
if sensitive(k) || strings.EqualFold(k, "token") {
deny(400, "密钥只能通过 Authorization 请求头发送")
return
}
}
header := strings.Fields(c.GetHeader("Authorization"))
if len(header) != 2 || !strings.EqualFold(header[0], "Bearer") {
deny(401, "需要客户端密钥")
return
}
connection := db
var err error
if connection == nil {
connection, err = pkg.GetOrm(c)
}
if err != nil || connection == nil {
deny(503, "客户端认证暂不可用")
return
}
key, err := (clientkey.Service{DB: connection}).Authenticate(c.Request.Context(), header[1])
if err != nil {
if errors.Is(err, clientkey.ErrCredential) {
deny(401, "客户端密钥无效或已停用")
} else {
deny(503, "客户端认证暂不可用")
}
return
}
random := make([]byte, 16)
if _, err = rand.Read(random); err != nil {
deny(503, "审计暂不可用")
return
}
requestID := hex.EncodeToString(random)
c.Header("X-Client-Request-Id", requestID)
record := clientkey.Audit{KeyID: key.ID, Event: "request", RequestID: requestID, Method: e.Method, Route: "/api/client/v1" + e.Path}
if !key.Allows(e.Module, e.Capability) {
record.Status = 403
_ = connection.Create(&record).Error
deny(403, "密钥未授权此模块或执行动作")
return
}
// The intent must be durable before any business handler can run.
if err = connection.WithContext(c.Request.Context()).Create(&record).Error; err != nil {
deny(503, "审计暂不可用,未执行操作")
return
}
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
original := c.Writer
buffer := &responseBuffer{ResponseWriter: original, status: 200}
c.Writer = buffer
defer func() { c.Writer = original }()
c.Next()
c.Writer = original
status := buffer.status
var value any
if buffer.overflow || json.Unmarshal(buffer.body.Bytes(), &value) != nil {
status = 502
value = gin.H{"code": 502, "message": "无法生成客户端响应,请先核对操作结果,不要自动重试"}
} else {
value = redact(value)
}
auditCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = connection.WithContext(auditCtx).Model(&clientkey.Audit{}).Where("id = ?", record.ID).Update("status", status).Error
now := time.Now().UTC()
_ = connection.WithContext(auditCtx).Model(&clientkey.Key{}).Where("id = ?", key.ID).UpdateColumn("last_used_at", now).Error
c.JSON(status, value)
}
}
type responseBuffer struct {
gin.ResponseWriter
body bytes.Buffer
status int
overflow bool
}
func (w *responseBuffer) WriteHeader(status int) { w.status = status }
func (w *responseBuffer) WriteHeaderNow() {}
func (w *responseBuffer) Status() int { return w.status }
func (w *responseBuffer) Size() int { return w.body.Len() }
func (w *responseBuffer) Written() bool { return w.body.Len() > 0 }
func (w *responseBuffer) Write(b []byte) (int, error) {
if w.body.Len()+len(b) > 32<<20 {
w.overflow = true
return len(b), nil
}
return w.body.Write(b)
}
func (w *responseBuffer) WriteString(s string) (int, error) { return w.Write([]byte(s)) }
func (w *responseBuffer) Flush() {}
func sensitive(k string) bool {
key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(k, "_", ""), "-", ""))
for _, part := range []string{"password", "passwd", "secret", "credential", "cookie", "authorization", "apikey", "accesstoken", "devicetoken", "refreshtoken", "recoverycode"} {
if strings.Contains(key, part) {
return true
}
}
switch key {
case "token", "tokenhash", "digest", "rawjson", "rawpayload", "rawresponse", "requestheaders", "responseheaders":
return true
}
return false
}
func redact(v any) any {
switch value := v.(type) {
case map[string]any:
for k, item := range value {
if sensitive(k) {
delete(value, k)
} else {
value[k] = redact(item)
}
}
case []any:
for i, item := range value {
value[i] = redact(item)
}
}
return v
}
+165
View File
@@ -0,0 +1,165 @@
package clientapi
import (
"context"
"crypto/tls"
"encoding/json"
"github.com/gin-gonic/gin"
"go-admin/app/goauto/clientkey"
"go-admin/common/clientprincipal"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"net/http/httptest"
"strings"
"testing"
)
func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
sql.SetMaxOpenConns(1)
t.Cleanup(func() { sql.Close() })
if err = db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
t.Fatal(err)
}
return db, clientkey.Service{DB: db, Modules: Catalog(Inventory())}
}
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
db, s := fixture(t)
routes := Inventory()
if len(s.Modules) != 12 {
t.Fatal("menu groups lost")
}
router := gin.New()
seen := map[string]bool{}
for _, e := range routes {
key := e.Method + e.Path
if seen[key] {
t.Fatal("duplicate route")
}
seen[key] = true
if strings.Contains(e.Path, "payment") || strings.Contains(e.Path, "token") || strings.Contains(e.Path, "client-keys") || e.Handle == nil {
t.Fatal("forbidden route")
}
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { c.JSON(200, gin.H{"data": "ok"}) })
if e.Method != "GET" && e.Capability == "read" {
t.Fatal("mutating read permission")
}
}
for _, e := range routes {
for _, scheme := range []string{"http", "https"} {
grant := clientkey.Grant{Module: e.Module}
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1)
if err != nil {
t.Fatal(err)
}
path := e.Path
for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} {
path = strings.ReplaceAll(path, param, "1")
}
req := httptest.NewRequest(e.Method, scheme+"://example.test/api/client/v1"+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 200
if e.Capability != "read" {
want = 403
}
if rr.Code != want {
t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want)
}
if e.Capability == "write" {
grant.Write = true
} else if e.Capability != "read" {
grant.Actions = []string{e.Capability}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req.Clone(context.Background()))
if rr.Code != 200 {
t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code)
}
}
}
}
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
db, s := fixture(t)
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
if err != nil {
t.Fatal(err)
}
e := Endpoint{Method: "GET", Path: "/pdd-products", Module: "pdd_products", Capability: "read"}
router := gin.New()
calls := 0
router.GET("/api/client/v1/pdd-products", Gate(db, e), func(c *gin.Context) {
calls++
id, ok := clientprincipal.Get(c)
if !ok || id.KeyID != v.ID {
t.Error("client attribution missing")
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"apiKey": "sentinel-secret", "rawJson": "sentinel-raw", "title": "product", "nested": []any{gin.H{"device_token": "sentinel-token"}}}})
})
send := func(tlsOn bool, credential, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "http://example.test"+path, nil)
if tlsOn {
req.TLS = &tls.ConnectionState{}
}
req.Header.Set("Authorization", "Bearer "+credential)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
path := "/api/client/v1/pdd-products"
for _, tlsOn := range []bool{false, true} {
if send(tlsOn, "jwt", path).Code != 401 || send(tlsOn, "", path).Code != 401 || send(tlsOn, token, path+"?token=invalid").Code != 400 {
t.Fatal("credential fallback accepted")
}
rr := send(tlsOn, token, path)
if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") {
t.Fatal("redaction failed")
}
if rr.Header().Get("Cache-Control") != "no-store" || rr.Header().Get("X-Client-Request-Id") == "" {
t.Fatal("cache or audit headers missing")
}
}
if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil {
t.Fatal(err)
}
if send(true, token, path).Code != 401 || send(false, token, path).Code != 401 || calls != 2 {
t.Fatal("revocation not immediate")
}
var logs []clientkey.Audit
db.Find(&logs)
raw, _ := json.Marshal(logs)
if strings.Contains(string(raw), token) || strings.Contains(string(raw), "sentinel") {
t.Fatal("audit leaked payload")
}
}
func TestAuditUnavailableDoesNotExecute(t *testing.T) {
db, s := fixture(t)
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
if err != nil {
t.Fatal(err)
}
db.Migrator().DropTable(&clientkey.Audit{})
router := gin.New()
called := false
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
router.POST(e.Path, Gate(db, e), func(c *gin.Context) { called = true; c.JSON(200, gin.H{}) })
req := httptest.NewRequest("POST", "https://example.test"+e.Path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 503 || called {
t.Fatal("executed without audit")
}
}
@@ -0,0 +1,81 @@
package clientapi
import (
"context"
"encoding/json"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/models"
"go-admin/app/goauto/product"
"go-admin/common/middleware"
"net/http/httptest"
"strings"
"testing"
)
func TestClientCanCreateProductWithoutLoginAndReplay(t *testing.T) {
db, s := fixture(t)
if err := db.AutoMigrate(&models.PDDProduct{}); err != nil {
t.Fatal(err)
}
_, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1)
if err != nil {
t.Fatal(err)
}
e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"}
router := gin.New()
router.Use(func(c *gin.Context) { c.Set("db", db); c.Next() })
router.POST("/api/client/v1/pdd-products", Gate(db, e), product.Handler{}.Create)
for n := 0; n < 2; n++ {
req := httptest.NewRequest("POST", "https://example.test/api/client/v1/pdd-products", strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000237","url":"https://mobile.yangkeduo.com/goods.html?goods_id=100000000001"}`))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("product create failed status %d", rr.Code)
}
var body struct {
Data struct {
Replayed bool `json:"replayed"`
}
}
json.Unmarshal(rr.Body.Bytes(), &body)
if (n == 1) != body.Data.Replayed {
t.Fatal("business idempotency changed")
}
}
var count int64
db.Model(&models.PDDProduct{}).Count(&count)
if count != 1 {
t.Fatal("duplicate business write")
}
}
func TestManagementRequiresAdminNotClientIdentity(t *testing.T) {
db, s := fixture(t)
h := clientkey.Handler{DB: db, Modules: s.Modules}
for _, role := range []string{"admin", "purchaser", "client", ""} {
for _, scheme := range []string{"http", "https"} {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)})
c.Next()
})
router.POST("/keys", middleware.RequireRoleKey("admin"), NoStore, h.Create)
req := httptest.NewRequest("POST", scheme+"://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
want := 403
if role == "admin" {
want = 200
}
if rr.Code != want {
t.Fatalf("role %q status %d", role, rr.Code)
}
if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" {
t.Fatal("one-time secret cacheable")
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package clientapi
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"go-admin/app/goauto/aimatching"
"io"
"net/http"
)
// resolveSpecs accepts only specification text, never a provider URL/key,
// prompt, raw order, address or account. It returns a suggestion, not an order.
func resolveSpecs(c *gin.Context) {
var req struct {
TargetColor string `json:"targetColor"`
TargetSize string `json:"targetSize"`
Colors []string `json:"colors"`
Sizes []string `json:"sizes"`
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(&req) != nil || d.Decode(&struct{}{}) != io.EOF || len(req.Colors) > 200 || len(req.Sizes) > 200 {
c.JSON(422, gin.H{"code": 422, "message": "规格请求无效"})
return
}
for _, s := range append(append([]string{req.TargetColor, req.TargetSize}, req.Colors...), req.Sizes...) {
if len([]rune(s)) > 255 {
c.JSON(422, gin.H{"code": 422, "message": "规格值过长"})
return
}
}
db, err := pkg.GetOrm(c)
if err != nil {
c.JSON(503, gin.H{"code": 503})
return
}
v, err := aimatching.NewService(db).Resolve(c.Request.Context(), aimatching.MatchRequest{TargetColor: req.TargetColor, TargetSize: req.TargetSize, Colors: req.Colors, Sizes: req.Sizes})
if err != nil {
c.JSON(422, gin.H{"code": 422, "message": "未获得可靠匹配,请检查候选规格或联系管理员"})
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"mappedColor": v.MappedColor, "mappedSize": v.MappedSize, "source": v.Source}})
}
+170
View File
@@ -0,0 +1,170 @@
// Package clientapi exposes only the reviewed client route inventory. It never
// forwards arbitrary URLs or synthesizes a logged-in administrator identity.
package clientapi
import (
"go-admin/app/goauto/access"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/clientkey"
"go-admin/app/goauto/device"
"go-admin/app/goauto/product"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/purchaserule"
"go-admin/app/goauto/rule"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"go-admin/app/goauto/sybinnercode"
"go-admin/app/goauto/sybshop"
"go-admin/app/goauto/task"
"go-admin/common/middleware"
"sort"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
type Endpoint struct {
Method, Path, Module, Capability string
Handle gin.HandlerFunc
}
func Inventory() []Endpoint {
p := product.Handler{}
s := shopeeproduct.Handler{}
y := sybimport.Handler{}
i := sybinnercode.Handler{}
shop := sybshop.Handler{}
r := rule.Handler{}
pr := purchaserule.Handler{}
t := task.Handler{}
buy := purchase.Handler{}
return []Endpoint{
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
{"PATCH", "/pdd-products/:productId", access.ModulePDDProducts, "write", p.Update},
{"GET", "/shopee-products", access.ModuleShopeeProducts, "read", s.List},
{"GET", "/shopee-products/:productId", access.ModuleShopeeProducts, "read", s.Detail},
{"POST", "/shopee-products", access.ModuleShopeeProducts, "write", s.Create},
{"PATCH", "/shopee-products/:productId", access.ModuleShopeeProducts, "write", s.Update},
{"POST", "/shopee-products/:productId/link-pdd", access.ModuleShopeeProducts, "write", s.LinkPDD},
{"POST", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "write", s.AddSpecValue},
{"PUT", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "write", s.SetMapping},
{"DELETE", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "delete", s.RemoveSpecValue},
{"DELETE", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "delete", s.ClearMapping},
{"POST", "/shopee-products/batch-delete", access.ModuleShopeeProducts, "delete", s.BatchDelete},
{"POST", "/shopee-products/:productId/specs/mapping/auto-match", access.ModuleShopeeProducts, "match", s.AutoMatchMappings},
{"POST", "/shopee-products/:productId/specs/mapping/confirm", access.ModuleShopeeProducts, "match", s.ConfirmMapping},
{"GET", "/syb-products", access.ModuleSYBProducts, "read", y.List},
{"GET", "/syb-products/:productId", access.ModuleSYBProducts, "read", y.Detail},
{"PATCH", "/syb-products/:productId/correction", access.ModuleSYBProducts, "write", y.ManualCorrect},
{"POST", "/syb-products/:productId/reparse", access.ModuleSYBProducts, "reparse", y.Reparse},
{"POST", "/syb-products/reparse-batch", access.ModuleSYBProducts, "reparse", y.ReparseBatch},
{"GET", "/syb-products/sync-runs", access.ModuleSYBSyncRuns, "read", y.ListSyncRuns},
{"GET", "/syb-products/sync-runs/:runId", access.ModuleSYBSyncRuns, "read", y.SyncRunDetail},
{"POST", "/syb-products/import", access.ModuleSYBSyncRuns, "sync", y.Import},
{"GET", "/syb-inner-codes", access.ModuleSYBInnerCodes, "read", i.List},
{"GET", "/syb-inner-codes/:recordId", access.ModuleSYBInnerCodes, "read", i.Detail},
{"GET", "/syb-inner-codes/match-jobs/:jobId", access.ModuleSYBInnerCodes, "read", i.MatchJob},
{"GET", "/syb-inner-codes/apply-batches/:batchId", access.ModuleSYBInnerCodes, "read", i.ApplyBatch},
{"POST", "/syb-inner-codes/rematch", access.ModuleSYBInnerCodes, "match", i.Rematch},
{"POST", "/syb-inner-codes/import", access.ModuleSYBInnerCodes, "import", i.Import},
{"POST", "/syb-inner-codes/batch-delete", access.ModuleSYBInnerCodes, "delete", i.Delete},
{"POST", "/syb-inner-codes/apply-preview", access.ModuleSYBInnerCodes, "writeback", i.ApplyPreview},
{"POST", "/syb-inner-codes/apply", access.ModuleSYBInnerCodes, "writeback", i.Apply},
{"POST", "/syb-inner-codes/:recordId/recheck", access.ModuleSYBInnerCodes, "match", i.Recheck},
{"GET", "/syb-shops", access.ModuleSYBShops, "read", shop.List},
{"POST", "/syb-shops", access.ModuleSYBShops, "write", shop.Create},
{"PATCH", "/syb-shops/:shopId/name", access.ModuleSYBShops, "write", shop.Rename},
{"PATCH", "/syb-shops/:shopId/enabled", access.ModuleSYBShops, "write", shop.SetEnabled},
{"DELETE", "/syb-shops/:shopId", access.ModuleSYBShops, "delete", shop.Delete},
{"GET", "/collection-rules", access.ModuleCollectionRules, "read", r.List},
{"POST", "/collection-rules", access.ModuleCollectionRules, "write", r.Create},
{"PATCH", "/collection-rules/:ruleId", access.ModuleCollectionRules, "write", r.Update},
{"DELETE", "/collection-rules/:ruleId", access.ModuleCollectionRules, "delete", r.Delete},
{"GET", "/purchase-rules", access.ModulePurchaseRules, "read", pr.List},
{"GET", "/purchase-rules/current", access.ModulePurchaseRules, "read", pr.Current},
{"POST", "/purchase-rules", access.ModulePurchaseRules, "write", pr.Create},
{"PATCH", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "write", pr.Update},
{"DELETE", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "delete", pr.Delete},
{"PUT", "/purchase-rules/current", access.ModulePurchaseRules, "activate", pr.SetCurrent},
{"GET", "/collection-tasks", access.ModuleCollectionTasks, "read", t.AdminList},
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
{"GET", "/purchase-tasks/:taskId", access.ModulePurchaseTasks, "read", buy.AdminDetail},
{"POST", "/purchase-tasks/batch-preview", access.ModulePurchaseTasks, "purchase", buy.AdminBatchPreview},
{"POST", "/purchase-tasks", access.ModulePurchaseTasks, "purchase", buy.AdminCreate},
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
db, err := pkg.GetOrm(c)
if err != nil {
c.JSON(500, gin.H{"code": 500})
return
}
v, err := aimatching.NewService(db).Settings(c.Request.Context())
if err != nil {
c.JSON(500, gin.H{"code": 500})
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"enabled": v.Enabled}})
}},
}
}
func Catalog(routes []Endpoint) []clientkey.Module {
mods := map[string]clientkey.Module{}
for _, m := range access.GoAutoModules() {
mods[m.Key] = clientkey.Module{Key: m.Key, Title: m.Title, Actions: []string{}}
}
for _, e := range routes {
m := mods[e.Module]
if e.Capability == "write" {
m.Writable = true
} else if e.Capability != "read" {
found := false
for _, a := range m.Actions {
if a == e.Capability {
found = true
}
}
if !found {
m.Actions = append(m.Actions, e.Capability)
}
}
mods[e.Module] = m
}
out := []clientkey.Module{}
for _, g := range access.GoAutoMenuGroups() {
for _, key := range g.ModuleKeys {
m := mods[key]
m.Group = g.Title
sort.Strings(m.Actions)
out = append(out, m)
}
}
return out
}
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
routes := Inventory()
catalog := Catalog(routes)
h := clientkey.Handler{Modules: catalog}
management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), NoStore)
management.GET("", h.List)
management.GET("/modules", h.Catalog)
management.POST("", h.Create)
management.PATCH("/:keyId/grants", h.Edit)
management.POST("/:keyId/disable", h.Disable)
for _, e := range routes {
engine.Handle(e.Method, "/api/client/v1"+e.Path, Gate(nil, e), e.Handle)
}
}
+127
View File
@@ -0,0 +1,127 @@
package clientkey
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"gorm.io/gorm"
)
type Handler struct {
DB *gorm.DB
Modules []Module
}
func (h Handler) service(c *gin.Context) (Service, bool) {
db := h.DB
var err error
if db == nil {
db, err = pkg.GetOrm(c)
}
if err != nil || db == nil {
failure(c, errors.New("database unavailable"))
return Service{}, false
}
c.Header("Cache-Control", "no-store")
return Service{db, h.Modules}, true
}
func failure(c *gin.Context, err error) {
status, message := 500, "服务端处理失败"
switch {
case errors.Is(err, ErrInvalid):
status, message = 422, ErrInvalid.Error()
case errors.Is(err, ErrConflict):
status, message = 409, ErrConflict.Error()
case errors.Is(err, gorm.ErrRecordNotFound):
status, message = 404, "密钥不存在"
}
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
func (h Handler) Catalog(c *gin.Context) { c.JSON(200, gin.H{"code": 200, "data": h.Modules}) }
func (h Handler) List(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := 20
var total int64
var keys []Key
if err := s.DB.WithContext(c.Request.Context()).Model(&Key{}).Count(&total).Error; err != nil {
failure(c, err)
return
}
if err := s.DB.WithContext(c.Request.Context()).Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&keys).Error; err != nil {
failure(c, err)
return
}
items := make([]View, 0, len(keys))
for _, k := range keys {
items = append(items, view(k))
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"items": items, "total": total, "page": page, "pageSize": size}})
}
func decode(c *gin.Context, v any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(v) != nil || d.Decode(&struct{}{}) != io.EOF {
failure(c, ErrInvalid)
return false
}
return true
}
func (h Handler) Create(c *gin.Context) {
var req struct {
Name string `json:"name"`
Grants []Grant `json:"grants"`
}
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
result, secret, err := s.Create(c.Request.Context(), req.Name, req.Grants, uint64(user.GetUserId(c)))
if err != nil {
failure(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"key": result, "secret": secret}})
}
func (h Handler) Edit(c *gin.Context) { h.update(c, false) }
func (h Handler) Disable(c *gin.Context) { h.update(c, true) }
func (h Handler) update(c *gin.Context, disable bool) {
id, err := strconv.ParseUint(c.Param("keyId"), 10, 64)
if err != nil {
failure(c, ErrInvalid)
return
}
var req struct {
Version uint64 `json:"version"`
Grants []Grant `json:"grants"`
}
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
result, err := s.Update(c.Request.Context(), id, req.Version, uint64(user.GetUserId(c)), req.Grants, disable)
if err != nil {
failure(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": result})
}
+222
View File
@@ -0,0 +1,222 @@
// Package clientkey implements independent, revocable client credentials (#237).
package clientkey
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
var ErrInvalid = errors.New("名称或授权无效")
var ErrConflict = errors.New("密钥已停用或授权已被修改,请刷新后重试")
var ErrCredential = errors.New("客户端密钥无效或已停用")
type Grant struct {
Module string `json:"module"`
Write bool `json:"write"`
Actions []string `json:"actions"`
}
type Module struct {
Key string `json:"key"`
Title string `json:"title"`
Group string `json:"group"`
Writable bool `json:"writable"`
Actions []string `json:"actions"`
}
type Key struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:80;not null" json:"name"`
Prefix string `gorm:"size:24;not null" json:"prefix"`
Digest string `gorm:"size:64;uniqueIndex;not null" json:"-"`
GrantsJSON string `gorm:"type:text;not null" json:"-"`
Enabled bool `gorm:"not null" json:"enabled"`
Version uint64 `gorm:"not null" json:"version"`
CreatedBy uint64 `json:"createdBy"`
UpdatedBy uint64 `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
}
func (Key) TableName() string { return "client_api_key" }
type Audit struct {
ID uint64 `gorm:"primaryKey"`
KeyID uint64 `gorm:"index;not null"`
ActorID uint64
Event string `gorm:"size:32;not null"`
RequestID string `gorm:"size:32;index"`
Method string `gorm:"size:10"`
Route string `gorm:"size:180"`
Status int
Changes string `gorm:"type:text"`
CreatedAt time.Time
}
func (Audit) TableName() string { return "client_api_key_audit" }
type View struct {
Key
Grants []Grant `json:"grants"`
}
func view(k Key) View {
var g []Grant
_ = json.Unmarshal([]byte(k.GrantsJSON), &g)
return View{Key: k, Grants: g}
}
type Service struct {
DB *gorm.DB
Modules []Module
}
func (s Service) Normalize(in []Grant) ([]Grant, error) {
if len(in) == 0 || len(in) > len(s.Modules) {
return nil, ErrInvalid
}
catalog := map[string]Module{}
for _, m := range s.Modules {
catalog[m.Key] = m
}
seen := map[string]bool{}
out := make([]Grant, 0, len(in))
for _, g := range in {
m, ok := catalog[g.Module]
if !ok || seen[g.Module] || (g.Write && !m.Writable) {
return nil, ErrInvalid
}
seen[g.Module] = true
allowed := map[string]bool{}
for _, a := range m.Actions {
allowed[a] = true
}
used := map[string]bool{}
actions := []string{}
for _, a := range g.Actions {
if !allowed[a] || used[a] {
return nil, ErrInvalid
}
used[a] = true
actions = append(actions, a)
}
sort.Strings(actions)
out = append(out, Grant{g.Module, g.Write, actions})
}
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
return out, nil
}
func (s Service) Create(ctx context.Context, name string, grants []Grant, actor uint64) (View, string, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 80 || actor == 0 {
return View{}, "", ErrInvalid
}
g, err := s.Normalize(grants)
if err != nil {
return View{}, "", err
}
raw, _ := json.Marshal(g)
secret := make([]byte, 32)
if _, err = rand.Read(secret); err != nil {
return View{}, "", err
}
token := "gak_" + hex.EncodeToString(secret)
digest := sha256.Sum256([]byte(token))
k := Key{Name: name, Prefix: token[:16], Digest: hex.EncodeToString(digest[:]), GrantsJSON: string(raw), Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor}
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&k).Error; err != nil {
return err
}
return tx.Create(&Audit{KeyID: k.ID, ActorID: actor, Event: "create", Changes: string(raw)}).Error
})
if err != nil {
return View{}, "", err
}
return view(k), token, nil
}
func (s Service) Update(ctx context.Context, id, version, actor uint64, grants []Grant, disable bool) (View, error) {
if id == 0 || version == 0 || actor == 0 {
return View{}, ErrInvalid
}
var raw []byte
if !disable {
g, err := s.Normalize(grants)
if err != nil {
return View{}, err
}
raw, _ = json.Marshal(g)
}
var k Key
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&k, id).Error; err != nil {
return err
}
changes := map[string]any{"version": version + 1, "updated_by": actor, "updated_at": time.Now().UTC()}
event := "edit"
if disable {
changes["enabled"] = false
event = "disable"
} else {
changes["grants_json"] = string(raw)
}
result := tx.Model(&Key{}).Where("id = ? AND version = ? AND enabled = ?", id, version, true).Updates(changes)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrConflict
}
diff, _ := json.Marshal(map[string]string{"before": k.GrantsJSON, "after": string(raw)})
if err := tx.Create(&Audit{KeyID: id, ActorID: actor, Event: event, Changes: string(diff)}).Error; err != nil {
return err
}
return tx.First(&k, id).Error
})
return view(k), err
}
func (s Service) Authenticate(ctx context.Context, token string) (Key, error) {
if len(token) != 68 || !strings.HasPrefix(token, "gak_") {
return Key{}, ErrCredential
}
if _, err := hex.DecodeString(token[4:]); err != nil {
return Key{}, ErrCredential
}
digest := sha256.Sum256([]byte(token))
var k Key
err := s.DB.WithContext(ctx).Where("digest = ? AND enabled = ?", hex.EncodeToString(digest[:]), true).First(&k).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Key{}, ErrCredential
}
return k, err
}
func (k Key) Allows(module, capability string) bool {
var grants []Grant
if json.Unmarshal([]byte(k.GrantsJSON), &grants) != nil {
return false
}
for _, g := range grants {
if g.Module != module {
continue
}
if capability == "read" {
return true
}
if capability == "write" {
return g.Write
}
for _, a := range g.Actions {
if a == capability {
return true
}
}
}
return false
}
+115
View File
@@ -0,0 +1,115 @@
package clientkey
import (
"context"
"encoding/json"
"errors"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"strings"
"testing"
)
func testService(t *testing.T) Service {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
sql.SetMaxOpenConns(1)
t.Cleanup(func() { sql.Close() })
if err = db.AutoMigrate(&Key{}, &Audit{}); err != nil {
t.Fatal(err)
}
return Service{db, []Module{{Key: "products", Writable: true, Actions: []string{"delete"}}, {Key: "devices", Actions: []string{}}}}
}
func TestCredentialLifecycle(t *testing.T) {
s := testService(t)
ctx := context.Background()
v, token, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 7)
if err != nil {
t.Fatal(err)
}
if len(token) != 68 || v.Digest == token {
t.Fatal("invalid credential generation")
}
wire, _ := json.Marshal(v)
if strings.Contains(string(wire), token) || strings.Contains(string(wire), v.Digest) {
t.Fatal("secret serialized")
}
k, err := s.Authenticate(ctx, token)
if err != nil || !k.Allows("products", "read") || k.Allows("products", "write") || k.Allows("devices", "read") {
t.Fatal("default grants")
}
updated, err := s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "products", Write: true, Actions: []string{"delete"}}}, false)
if err != nil {
t.Fatal(err)
}
k, err = s.Authenticate(ctx, token)
if err != nil || !k.Allows("products", "write") || !k.Allows("products", "delete") || updated.Version != 2 || k.Digest != v.Digest {
t.Fatal("edit changed credential or failed to apply grants")
}
if _, err = s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "devices"}}, false); !errors.Is(err, ErrConflict) {
t.Fatal("stale edit accepted")
}
if _, err = s.Update(ctx, k.ID, 2, 8, nil, true); err != nil {
t.Fatal(err)
}
if _, err = s.Authenticate(ctx, token); !errors.Is(err, ErrCredential) {
t.Fatal("disabled credential accepted")
}
if _, err = s.Update(ctx, k.ID, 3, 8, []Grant{{Module: "products"}}, false); !errors.Is(err, ErrConflict) {
t.Fatal("disabled key edited")
}
var logs []Audit
s.DB.Find(&logs)
if len(logs) != 3 {
t.Fatalf("audit count %d", len(logs))
}
data, _ := json.Marshal(logs)
if strings.Contains(string(data), token) {
t.Fatal("secret in audit")
}
}
func TestInvalidGrantsAndCredentials(t *testing.T) {
s := testService(t)
for _, g := range [][]Grant{nil, {{Module: "admin"}}, {{Module: "devices", Write: true}}, {{Module: "products", Actions: []string{"payment"}}}, {{Module: "products"}, {Module: "products"}}} {
if _, err := s.Normalize(g); err == nil {
t.Fatal("invalid grant accepted")
}
}
for _, token := range []string{"", "jwt", "gak_" + strings.Repeat("z", 64), strings.Repeat("a", 68)} {
if _, err := s.Authenticate(context.Background(), token); !errors.Is(err, ErrCredential) {
t.Fatal("invalid credential accepted")
}
}
}
func TestAuditFailureRollsBack(t *testing.T) {
s := testService(t)
ctx := context.Background()
v, _, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 1)
if err != nil {
t.Fatal(err)
}
if err = s.DB.Migrator().DropTable(&Audit{}); err != nil {
t.Fatal(err)
}
if _, err = s.Update(ctx, v.ID, 1, 1, nil, true); err == nil {
t.Fatal("audit failure ignored")
}
var k Key
s.DB.First(&k, v.ID)
if !k.Enabled || k.Version != 1 {
t.Fatal("mutation not rolled back")
}
if _, secret, err := s.Create(ctx, "Failed", []Grant{{Module: "products"}}, 1); err == nil || secret != "" {
t.Fatal("creation audit failure ignored")
}
var count int64
s.DB.Model(&Key{}).Count(&count)
if count != 1 {
t.Fatal("key persisted without audit")
}
}
+4
View File
@@ -34,7 +34,11 @@ func MigratedModels() []any {
&models.PDDProduct{},
&models.AIMatchingSetting{},
&models.ShopeeProduct{},
&models.ShopeeSpecAutoMatchRun{},
&models.ShopeeSpecAutoMatchWorkItem{},
&models.SYBProduct{},
&models.SYBSpecAIParseRun{},
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBShop{},
&models.SYBSyncRun{},
+9
View File
@@ -536,6 +536,15 @@ type SYBProduct struct {
// recording the parser's own last output for audit even after a manual
// correction; it is not overwritten by the correction itself.
ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"`
// AIConfirmed is independent of ParseStatus: ParseStatus remains the
// deterministic parser's audit result, while these fields record a closed-
// candidate, high-confidence AI decision. Human correction always clears
// and supersedes this decision.
AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"`
AIConfidence *float64 `json:"aiConfidence,omitempty"`
AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"`
AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"`
AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"`
// RawJSON is the untouched `details[]` element as SYB returned it. It is
// what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始
@@ -0,0 +1,56 @@
package models
import "time"
// ShopeeSpecAutoMatchRun is one scheduled or administrator-triggered batch.
// ActiveSlot is 1 only while running; its nullable unique index is the
// database-level cross-process mutex shared by both trigger paths.
type ShopeeSpecAutoMatchRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_shopee_spec_auto_match_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_shopee_spec_auto_match_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
RequestedBy *uint64 `json:"requestedBy,omitempty"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
// ShopeeSpecAutoMatchWorkItem remembers the last input fingerprint and retry
// state for each product, preventing unchanged low-confidence inputs from
// repeatedly spending AI calls.
type ShopeeSpecAutoMatchWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;uniqueIndex:ux_shopee_spec_auto_match_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:128;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchWorkItem) TableName() string {
return "shopee_spec_auto_match_work_item"
}
@@ -0,0 +1,48 @@
package models
import "time"
// SYBSpecAIParseRun is one globally serialized scheduled batch.
type SYBSpecAIParseRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_syb_spec_ai_parse_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_spec_ai_parse_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseRun) TableName() string { return "syb_spec_ai_parse_run" }
// SYBSpecAIParseWorkItem prevents unchanged ambiguous input from repeatedly
// spending provider calls and owns the per-row recovery lease.
type SYBSpecAIParseWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
SYBProductID uint64 `json:"sybProductId" gorm:"not null;uniqueIndex:ux_syb_spec_ai_parse_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:64;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseWorkItem) TableName() string { return "syb_spec_ai_parse_work_item" }
@@ -32,10 +32,44 @@ type skuCombinationRow struct {
}
func sybSpecsTrusted(syb models.SYBProduct) bool {
if syb.ParseStatus == models.SYBParseStatusFailed {
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
return false
}
return strings.TrimSpace(syb.TargetColor) != "" || strings.TrimSpace(syb.TargetSize) != ""
return syb.ParseStatus != models.SYBParseStatusFailed || syb.ManuallyConfirmed || syb.AIConfirmed
}
func aiConfirmedSpecsCurrent(syb models.SYBProduct, shopee models.ShopeeProduct) bool {
if !syb.AIConfirmed {
return true
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
return false
}
wanted := map[string]string{shopeeproduct.RoleColor: strings.TrimSpace(syb.TargetColor), shopeeproduct.RoleSize: strings.TrimSpace(syb.TargetSize)}
foundAny := false
for role, target := range wanted {
if target == "" {
continue
}
foundAny = true
found := false
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == target {
found = true
break
}
}
}
if !found {
return false
}
}
return foundAny
}
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
+4
View File
@@ -388,6 +388,10 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
}
if shopee.PDDProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
return item
+25
View File
@@ -54,6 +54,9 @@ func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
{"uncertain with color", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "套装"}, true},
{"uncertain with size", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetSize: "均码"}, true},
{"failed with values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色"}, false},
{"failed AI confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", AIConfirmed: true}, true},
{"failed manually confirmed", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色", ManuallyConfirmed: true}, true},
{"AI confirmed without values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, AIConfirmed: true}, false},
{"uncertain without values", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain}, false},
}
for _, tt := range tests {
@@ -65,6 +68,28 @@ func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
}
}
func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) {
row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "黑色", AIConfirmed: true}
if !sybSpecsTrusted(row) {
t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate")
}
if row.ManuallyConfirmed {
t.Fatal("AI confirmation must not be represented as human confirmation")
}
}
func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) {
row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`}
if aiConfirmedSpecsCurrent(row, product) {
t.Fatal("removed Shopee candidate must invalidate the AI parse gate")
}
product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
if !aiConfirmedSpecsCurrent(row, product) {
t.Fatal("unchanged exact Shopee candidates should keep AI parse valid")
}
}
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
+7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"go-admin/common/clientprincipal"
"io"
"net/http"
"strconv"
@@ -470,6 +471,9 @@ func writeAdminReplay(c *gin.Context, data any, replayed bool) {
}
func allowedOperator(c *gin.Context) bool {
if _, ok := clientprincipal.Get(c); ok {
return true
}
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
if role == "admin" || role == "purchaser" {
return true
@@ -544,6 +548,9 @@ func writeError(c *gin.Context, err error) {
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
}
func operatorID(c *gin.Context) uint64 {
if id, ok := clientprincipal.Get(c); ok {
return id.AuthorizedBy
}
claims := jwt.ExtractClaims(c)
switch v := claims["identity"].(type) {
case float64:
+1 -1
View File
@@ -136,7 +136,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
if !ok {
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
}
if syb.ParseStatus == models.SYBParseStatusFailed {
if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed {
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
}
if syb.ShopeeProductID == nil {
@@ -48,6 +48,9 @@ type autoMatchRef struct {
// before the transaction; the transaction rechecks the complete spec context
// and PDD candidate set so a stale decision can never be written.
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
defer cancel()
requestID := strings.TrimSpace(request.RequestID)
if _, err := uuid.Parse(requestID); err != nil {
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
@@ -0,0 +1,360 @@
package shopeeproduct
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAutoMatchInvokeTarget = "GoAutoShopeeSpecAutoMatch"
defaultAutoMatchBatchLimit = 20
autoMatchLeaseDuration = 30 * time.Minute
autoMatchRetryDelay = time.Hour
maxAutoMatchAttempts = 3
)
type AutoMatchRunView struct {
models.ShopeeSpecAutoMatchRun
AlreadyRunning bool `json:"alreadyRunning,omitempty"`
Replayed bool `json:"replayed,omitempty"`
}
// StartAutoMatchRun acquires the single database-backed activity slot. A
// repeated requestId is idempotent; a concurrent trigger receives the current
// run instead of starting a second batch.
func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestID string, requestedBy *uint64, batchLimit int) (AutoMatchRunView, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return AutoMatchRunView{}, false, invalidRequest("requestId 必须是 UUID")
}
if trigger != "manual" && trigger != "scheduled" {
return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
}
if batchLimit <= 0 {
batchLimit = defaultAutoMatchBatchLimit
}
if batchLimit > 100 {
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
owner := uuid.NewString()
one := uint8(1)
var result models.ShopeeSpecAutoMatchRun
created := false
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; err == nil {
result.ActiveSlot = &one
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
result = models.ShopeeSpecAutoMatchRun{RequestID: requestID, Trigger: trigger, Status: "running", ActiveSlot: &one, LeaseOwner: owner, LeaseExpiresAt: &lease, RequestedBy: requestedBy, BatchLimit: batchLimit, StartedAt: now}
if err := tx.Create(&result).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
// A unique-slot race means another instance won after our read. Return
// its run as the stable, non-error result.
if findErr := service.DB.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; findErr == nil {
return AutoMatchRunView{ShopeeSpecAutoMatchRun: result, AlreadyRunning: true}, false, nil
}
return AutoMatchRunView{}, false, internalError(err)
}
view := AutoMatchRunView{ShopeeSpecAutoMatchRun: result}
if !created {
view.AlreadyRunning = result.RequestID != requestID
view.Replayed = result.RequestID == requestID
}
return view, created, nil
}
func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunView, error) {
var run models.ShopeeSpecAutoMatchRun
err := service.DB.WithContext(ctx).Order("id DESC").First(&run).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, internalError(err)
}
return &AutoMatchRunView{ShopeeSpecAutoMatchRun: run}, nil
}
// ProcessAutoMatchRun performs a bounded batch. It is safe to call from an
// HTTP-launched goroutine or the scheduler because only the run owning the
// active slot may update and finish itself.
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
var run models.ShopeeSpecAutoMatchRun
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAutoMatchBatchLimit
}
var candidates []models.ShopeeProduct
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
if err := service.DB.WithContext(ctx).
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
Where("shopee_product.pdd_product_id IS NOT NULL").
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, product := range candidates {
if processed >= limit {
break
}
fingerprint, ok, err := service.autoMatchEligibility(ctx, product)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !claimed {
continue
}
processed++
service.renewAutoMatchRun(run)
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
postFingerprint = next
}
if matchErr != nil {
failed++
if firstError == "" {
firstError = safeBatchError(matchErr)
}
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr)
continue
}
confirmed += response.ConfirmedCount
unmatched += response.UnmatchedCount
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
if product.PDDProductID == nil {
return "", false, nil
}
var pdd models.PDDProduct
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
return "", false, err
}
if pdd.Status != "active" {
return "", false, nil
}
shopeeSpecs, err := Unmarshal(product.SpecsJSON)
if err != nil {
return "", false, err
}
shared, needsMatch := false, false
for _, role := range []string{RoleColor, RoleSize} {
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
if err != nil {
return "", false, err
}
if len(pddValues) == 0 {
continue
}
for _, dimension := range shopeeSpecs {
if dimension.Role != role || len(dimension.Values) == 0 {
continue
}
shared = true
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
needsMatch = true
}
}
}
}
if !shared || !needsMatch {
return "", false, nil
}
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
var setting struct{ UpdatedAt time.Time }
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
// exact version to #194 without re-reading a potentially drifting input.
return contextVersion + hex.EncodeToString(h[:]), true, nil
}
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
var work models.ShopeeSpecAutoMatchWorkItem
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
var errWorkNotClaimed = errors.New("auto match work not claimed")
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
if matchErr == nil {
if unmatched > 0 {
updates["status"] = "unmatched"
} else {
updates["status"] = "completed"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := batchErrorCode(matchErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeBatchError(matchErr)
if code == CodeAIUnavailable && work.AttemptCount < maxAutoMatchAttempts {
next := now.Add(autoMatchRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) {
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, "error_summary": truncateBatchText(summary), "finished_at": now}
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error
}
func batchErrorCode(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return serviceErr.Code
}
return CodeInternal
}
func safeBatchError(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return truncateBatchText(serviceErr.Message)
}
return "服务端处理失败"
}
func truncateBatchText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledAutoMatchArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledAutoMatchJob struct{}
func (ScheduledAutoMatchJob) Exec(_ interface{}) error {
return errors.New("规格自动匹配定时任务缺少数据库连接")
}
func (ScheduledAutoMatchJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledAutoMatchArgs{BatchLimit: defaultAutoMatchBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("规格自动匹配参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("规格自动匹配 batchLimit 必须在 1 到 100 之间")
}
service := NewService(db)
run, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, args.BatchLimit)
if err != nil || !created {
return err
}
return service.ProcessAutoMatchRun(context.Background(), run.ID)
}
@@ -0,0 +1,86 @@
package shopeeproduct
import (
"context"
"testing"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
func TestAutoMatchRunIsIdempotentAndGloballySerialized(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
requestID := uuid.NewString()
first, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || !created {
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
}
replay, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || created || !replay.Replayed || replay.ID != first.ID {
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
}
concurrent, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20)
if err != nil || created || !concurrent.AlreadyRunning || concurrent.ID != first.ID {
t.Fatalf("concurrent=%+v created=%v err=%v", concurrent, created, err)
}
}
func TestProcessAutoMatchRunConfirmsExactSizeAndFinishes(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
service := NewService(db)
createdProduct, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ShopeeItemID: "SP-BATCH-EXACT", PDDProductID: &pdd.ID,
Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}},
})
if err != nil {
t.Fatal(err)
}
run, started, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil || !started {
t.Fatalf("run=%+v started=%v err=%v", run, started, err)
}
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
t.Fatal(err)
}
latest, err := service.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest == nil || latest.Status != "completed" || latest.ProcessedCount != 1 || latest.ConfirmedCount != 1 || latest.ActiveSlot != nil {
t.Fatalf("latest=%+v", latest)
}
detail, err := service.Detail(context.Background(), createdProduct.Product.ID)
if err != nil {
t.Fatal(err)
}
mapping := detail.Product.Specs[0].Values[0].Mapping
if mapping == nil || mapping.Status != MappingStatusConfirmed || mapping.PDDValue != "XL" {
t.Fatalf("mapping=%+v", mapping)
}
}
func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
one := uint8(1)
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
work, claimed, err := service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || !claimed {
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
}
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil)
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || claimed {
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
}
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "changed")
if err != nil || !claimed {
t.Fatalf("changed input claimed=%v err=%v", claimed, err)
}
}
@@ -5,11 +5,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -116,6 +118,20 @@ func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
}
}
func TestWriteErrorMapsAIUnavailableToStructured503(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
writeError(context, aiUnavailable("AI 匹配服务暂时不可用,请稍后重试"))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"code":"AI_MATCHING_UNAVAILABLE"`) {
t.Fatalf("response is not structured: %s", recorder.Body.String())
}
}
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
@@ -1,6 +1,7 @@
package shopeeproduct
import (
"context"
"encoding/json"
"errors"
"io"
@@ -300,6 +301,45 @@ func (handler Handler) AutoMatchMappings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func (handler Handler) StartAutoMatchRun(c *gin.Context) {
var request struct {
RequestID string `json:"requestId"`
}
if err := decodeJSON(c, &request); err != nil {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
service, ok := handler.service(c)
if !ok {
return
}
operator := currentUserID(c)
run, created, err := service.StartAutoMatchRun(c.Request.Context(), "manual", request.RequestID, &operator, defaultAutoMatchBatchLimit)
if err != nil {
writeError(c, err)
return
}
if created {
go func(runID uint64, db *gorm.DB) {
_ = NewService(db).ProcessAutoMatchRun(context.Background(), runID)
}(run.ID, service.DB)
}
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) LatestAutoMatchRun(c *gin.Context) {
service, ok := handler.service(c)
if !ok {
return
}
run, err := service.LatestAutoMatchRun(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) BatchDelete(c *gin.Context) {
var request BatchDeleteRequest
if err := decodeJSON(c, &request); err != nil {
@@ -10,6 +10,9 @@ import (
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
adminOnlyRuns.GET("/latest", handler.LatestAutoMatchRun)
admin.GET("", handler.List)
admin.POST("", handler.Create)
admin.POST("/batch-delete", handler.BatchDelete)
@@ -0,0 +1,489 @@
package sybimport
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAIParseInvokeTarget = "GoAutoSYBSpecAIParse"
defaultAIParseBatchLimit = 20
aiParseLeaseDuration = 30 * time.Minute
aiParseRetryDelay = time.Hour
maxAIParseAttempts = 3
)
var (
errAIParseWorkNotClaimed = errors.New("syb spec ai parse work not claimed")
errAIParseInputChanged = errors.New("syb spec ai parse input changed")
)
type aiParseInput struct {
ProductSpec string
Colors []string
Sizes []string
Fingerprint string
}
func StartSpecAIParseRun(ctx context.Context, db *gorm.DB, requestID string, batchLimit int) (models.SYBSpecAIParseRun, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("requestId 必须是 UUID")
}
if batchLimit <= 0 {
batchLimit = defaultAIParseBatchLimit
}
if batchLimit > 100 {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
one := uint8(1)
owner := uuid.NewString()
var run models.SYBSpecAIParseRun
created := false
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.SYBSpecAIParseRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
run = models.SYBSpecAIParseRun{
RequestID: requestID, Trigger: "scheduled", Status: "running", ActiveSlot: &one,
LeaseOwner: owner, LeaseExpiresAt: &lease, BatchLimit: batchLimit, StartedAt: now,
}
if err := tx.Create(&run).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
if findErr := db.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; findErr == nil {
return run, false, nil
}
return models.SYBSpecAIParseRun{}, false, err
}
return run, created, nil
}
func ProcessSpecAIParseRun(ctx context.Context, db *gorm.DB, runID uint64) error {
var run models.SYBSpecAIParseRun
if err := db.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAIParseBatchLimit
}
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
var candidates []models.SYBProduct
if err := db.WithContext(ctx).
Where("manually_confirmed = ?", false).
Where("parse_status IN ?", []string{models.SYBParseStatusUncertain, models.SYBParseStatusFailed}).
Order("updated_at ASC, id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
finishSpecAIParseRun(db, run, "failed", 0, 0, 0, 0, 0, 1, "扫描异常规格失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, candidate := range candidates {
if processed >= limit {
break
}
if candidate.AIConfirmed {
current, currentErr := aiConfirmationTargetsCurrent(ctx, db, candidate)
if currentErr != nil {
failed++
continue
}
if current {
continue
}
if err := db.WithContext(ctx).Model(&models.SYBProduct{}).
Where("id = ? AND manually_confirmed = ?", candidate.ID, false).
Updates(map[string]any{"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": ""}).Error; err != nil {
failed++
continue
}
candidate.AIConfirmed = false
}
outcome, err := Reparse(ctx, db, candidate.ID, false)
if err != nil {
failed++
if firstError == "" {
firstError = "确定性重新解析失败"
}
continue
}
if outcome.NewStatus == models.SYBParseStatusSuccess {
processed++
confirmed++
continue
}
if err := db.WithContext(ctx).First(&candidate, candidate.ID).Error; err != nil {
failed++
continue
}
input, ok, err := buildAIParseInput(ctx, db, candidate)
if err != nil {
failed++
if firstError == "" {
firstError = "读取 AI 解析上下文失败"
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := claimSpecAIParseWork(ctx, db, run, candidate.ID, input.Fingerprint)
if err != nil {
failed++
continue
}
if !claimed {
continue
}
processed++
renewSpecAIParseRun(db, run)
matcher := aimatching.NewService(db)
result, matchErr := matcher.ResolveSYBSpec(ctx, aimatching.SYBSpecParseRequest{
ProductSpec: input.ProductSpec, Colors: input.Colors, Sizes: input.Sizes,
})
if matchErr == nil {
settings, settingsErr := matcher.Settings(ctx)
if settingsErr != nil {
matchErr = settingsErr
} else if result.Confidence == nil || *result.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(result.Reason) == "" {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
} else {
matchErr = applyAIParseResult(ctx, db, candidate.ID, input.Fingerprint, result)
}
}
if matchErr != nil {
if isNoAIParseMatch(matchErr) || errors.Is(matchErr, errAIParseInputChanged) {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
}
failed++
if firstError == "" {
firstError = safeAIParseError(matchErr)
}
completeSpecAIParseWork(db, work, input.Fingerprint, false, matchErr)
continue
}
confirmed++
completeSpecAIParseWork(db, work, input.Fingerprint, true, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return finishSpecAIParseRun(db, run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func buildAIParseInput(ctx context.Context, db *gorm.DB, record models.SYBProduct) (aiParseInput, bool, error) {
if record.ManuallyConfirmed || (record.ParseStatus != models.SYBParseStatusUncertain && record.ParseStatus != models.SYBParseStatusFailed) || record.ShopeeProductID == nil {
return aiParseInput{}, false, nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
return aiParseInput{}, false, err
}
raw.ProductSpec = strings.TrimSpace(raw.ProductSpec)
if raw.ProductSpec == "" {
return aiParseInput{}, false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return aiParseInput{}, false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous || (len(colors) == 0 && len(sizes) == 0) {
return aiParseInput{}, false, nil
}
var setting models.AIMatchingSetting
if err := db.WithContext(ctx).First(&setting, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
if !setting.Enabled || strings.TrimSpace(setting.APIKey) == "" {
return aiParseInput{}, false, nil
}
fingerprintPayload := struct {
ProductSpec string
ShopeeProductID uint64
ShopeeSpecsJSON string
SettingUpdatedAt string
}{raw.ProductSpec, product.ID, product.SpecsJSON, setting.UpdatedAt.UTC().Format(time.RFC3339Nano)}
encoded, _ := json.Marshal(fingerprintPayload)
hash := sha256.Sum256(encoded)
return aiParseInput{ProductSpec: raw.ProductSpec, Colors: colors, Sizes: sizes, Fingerprint: hex.EncodeToString(hash[:])}, true, nil
}
func aiConfirmationTargetsCurrent(ctx context.Context, db *gorm.DB, record models.SYBProduct) (bool, error) {
if !record.AIConfirmed || record.ShopeeProductID == nil {
return false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous {
return false, nil
}
return closedCandidateContains(record.TargetColor, colors) && closedCandidateContains(record.TargetSize, sizes) &&
(strings.TrimSpace(record.TargetColor) != "" || strings.TrimSpace(record.TargetSize) != ""), nil
}
func closedCandidateContains(value string, candidates []string) bool {
value = strings.TrimSpace(value)
if len(candidates) == 0 {
return value == ""
}
for _, candidate := range candidates {
if candidate == value {
return true
}
}
return false
}
func closedShopeeCandidates(specs []shopeeproduct.SpecDimension) (colors, sizes []string, ambiguous bool) {
roleDimensions := map[string]int{}
for _, dimension := range specs {
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
continue
}
values := make([]string, 0, len(dimension.Values))
seen := map[string]bool{}
for _, value := range dimension.Values {
name := strings.TrimSpace(value.Name)
if name != "" && !seen[name] {
seen[name] = true
values = append(values, name)
}
}
if len(values) == 0 {
continue
}
roleDimensions[dimension.Role]++
if roleDimensions[dimension.Role] > 1 {
return nil, nil, true
}
if dimension.Role == shopeeproduct.RoleColor {
colors = values
} else {
sizes = values
}
}
return colors, sizes, false
}
func applyAIParseResult(ctx context.Context, db *gorm.DB, id uint64, fingerprint string, result aimatching.SYBSpecParseResult) error {
now := time.Now().UTC()
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record models.SYBProduct
if err := tx.First(&record, id).Error; err != nil {
return err
}
input, ok, err := buildAIParseInput(ctx, tx, record)
if err != nil {
return err
}
if !ok || input.Fingerprint != fingerprint {
return errAIParseInputChanged
}
if result.Confidence == nil || strings.TrimSpace(result.Reason) == "" {
return errAIParseInputChanged
}
write := tx.Model(&models.SYBProduct{}).Where("id = ? AND manually_confirmed = ? AND ai_confirmed = ?", id, false, false).Updates(map[string]any{
"target_color": result.Color, "target_size": result.Size,
"ai_confirmed": true, "ai_confidence": *result.Confidence, "ai_reason": truncateAIParseText(result.Reason),
"ai_confirmed_at": now, "ai_input_fingerprint": fingerprint,
})
if write.Error != nil {
return write.Error
}
if write.RowsAffected != 1 {
return errAIParseInputChanged
}
return mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: result.Color, Size: result.Size, Status: models.SYBParseStatusSuccess})
})
}
func claimSpecAIParseWork(ctx context.Context, db *gorm.DB, run models.SYBSpecAIParseRun, productID uint64, fingerprint string) (models.SYBSpecAIParseWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
var work models.SYBSpecAIParseWorkItem
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("syb_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.SYBSpecAIParseWorkItem{SYBProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAIParseAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errAIParseWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errAIParseWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
func completeSpecAIParseWork(db *gorm.DB, work models.SYBSpecAIParseWorkItem, fingerprint string, confirmed bool, parseErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil}
if parseErr == nil {
if confirmed {
updates["status"] = "completed"
} else {
updates["status"] = "unmatched"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := aiParseErrorCode(parseErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeAIParseError(parseErr)
if code == aimatching.CodeProviderUnavailable && work.AttemptCount < maxAIParseAttempts {
next := now.Add(aiParseRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = db.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func renewSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun) {
lease := time.Now().UTC().Add(aiParseLeaseDuration)
_ = db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func finishSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
return db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(map[string]any{
"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil,
"scanned_count": scanned, "eligible_count": eligible, "processed_count": processed,
"confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed,
"error_summary": truncateAIParseText(summary), "finished_at": now,
}).Error
}
func isNoAIParseMatch(err error) bool { return aiParseErrorCode(err) == aimatching.CodeNoMatch }
func aiParseErrorCode(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return target.Code
}
return CodeInternal
}
func safeAIParseError(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return truncateAIParseText(target.Message)
}
return "服务端处理失败"
}
func truncateAIParseText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledSpecAIParseArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledSpecAIParseJob struct{}
func (ScheduledSpecAIParseJob) Exec(_ interface{}) error {
return errors.New("SYB 规格 AI 解析定时任务缺少数据库连接")
}
func (ScheduledSpecAIParseJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledSpecAIParseArgs{BatchLimit: defaultAIParseBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("SYB 规格 AI 解析参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("SYB 规格 AI 解析 batchLimit 必须在 1 到 100 之间")
}
run, created, err := StartSpecAIParseRun(context.Background(), db, uuid.NewString(), args.BatchLimit)
if err != nil || !created {
return err
}
return ProcessSpecAIParseRun(context.Background(), db, run.ID)
}
@@ -0,0 +1,204 @@
package sybimport_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"github.com/google/uuid"
"gorm.io/gorm"
)
func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID uint64, productSpec string) models.SYBProduct {
t.Helper()
detail := realDetailA()
detail.ID = detailID
detail.ProductSpec = productSpec
detail.Raw = []byte(fmt.Sprintf(`{"id":%d,"productSpec":%q}`, detail.ID, productSpec))
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail)
if err != nil {
t.Fatal(err)
}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}, {Name: "白色", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "L", Source: shopeeproduct.ValueSourceImport}, {Name: "XL", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
t.Fatal(err)
}
setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: serverURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9}
if err := db.Save(&setting).Error; err != nil {
t.Fatal(err)
}
return applied.SYBProduct
}
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
rawBefore := record.RawJSON
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start run: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if !record.AIConfirmed || record.ManuallyConfirmed || record.ParseStatus != models.SYBParseStatusUncertain || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfidence == nil || *record.AIConfidence != 0.95 || record.AIReason == "" || record.AIInputFingerprint == "" {
t.Fatalf("unexpected confirmed record: %+v", record)
}
if record.RawJSON != rawBefore {
t.Fatal("AI confirmation must not rewrite RawJSON")
}
var finished models.SYBSpecAIParseRun
if err := db.First(&finished, run.ID).Error; err != nil {
t.Fatal(err)
}
if finished.Status != "completed" || finished.ConfirmedCount != 1 || finished.ProcessedCount != 1 {
t.Fatalf("unexpected run: %+v", finished)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("second start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, second.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("unchanged confirmed input called provider %d times", calls.Load())
}
}
func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"仍有歧义\",\"confidence\":0.4}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
for i := 0; i < 2; i++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", i, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || calls.Load() != 1 {
t.Fatalf("low confidence must remain unconfirmed and not repeat: confirmed=%v calls=%d", record.AIConfirmed, calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.Status != "unmatched" {
t.Fatalf("work status=%s", work.Status)
}
}
func TestScheduledAIParseSkipsEmptySourceAndManualConfirmation(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusInternalServerError)
}))
defer provider.Close()
db := openTestDB(t)
empty := seedAIParseCandidate(t, db, provider.URL, 19803, "")
manual := seedAIParseCandidate(t, db, provider.URL, 19804, "黑色 XL")
if _, err := sybimport.ManualCorrect(context.Background(), db, manual.ID, "黑色", "XL"); err != nil {
t.Fatal(err)
}
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 0 {
t.Fatalf("ineligible rows called provider %d times", calls.Load())
}
var workCount int64
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id IN ?", []uint64{empty.ID, manual.ID}).Count(&workCount).Error; err != nil {
t.Fatal(err)
}
if workCount != 0 {
t.Fatalf("ineligible rows created %d work items", workCount)
}
}
func TestSpecAIParseRunHasSingleGlobalActiveSlot(t *testing.T) {
db := openTestDB(t)
first, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("first: created=%v err=%v", created, err)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || created || second.ID != first.ID {
t.Fatalf("second must reuse active run: first=%d second=%d created=%v err=%v", first.ID, second.ID, created, err)
}
}
func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusBadGateway)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
for attempt := 1; attempt <= 4; attempt++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", attempt, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if attempt < 3 {
past := time.Now().UTC().Add(-time.Minute)
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id = ?", record.ID).Update("next_attempt_at", past).Error; err != nil {
t.Fatal(err)
}
}
}
if calls.Load() != 3 {
t.Fatalf("provider calls=%d, want 3", calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.AttemptCount != 3 || work.NextAttemptAt != nil {
t.Fatalf("retry state=%+v", work)
}
}
+33 -2
View File
@@ -130,13 +130,37 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
result.Outcome = OutcomeCreated
case err == nil:
record.ID = existing.ID
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{
// Human-confirmed target values are authoritative and survive every
// source re-import. ParseStatus/ParseNote below still record what the
// current deterministic parser observed for audit.
if existing.ManuallyConfirmed {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
record.ManuallyConfirmed = true
}
updates := map[string]any{
"stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID,
"shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle,
"target_color": record.TargetColor, "target_size": record.TargetSize,
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
}).Error; err != nil {
}
// An identical re-import keeps a valid AI decision. Changed source,
// link, or a newly deterministic parse invalidates it atomically.
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
existing.RawJSON == record.RawJSON && sameOptionalID(existing.ShopeeProductID, record.ShopeeProductID)
if preserveAI {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
record.AIConfirmed, record.AIConfidence, record.AIReason = true, existing.AIConfidence, existing.AIReason
record.AIConfirmedAt, record.AIInputFingerprint = existing.AIConfirmedAt, existing.AIInputFingerprint
} else {
updates["ai_confirmed"], updates["ai_confidence"], updates["ai_reason"] = false, nil, ""
updates["ai_confirmed_at"], updates["ai_input_fingerprint"] = nil, ""
}
if existing.ManuallyConfirmed {
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return err
}
result.Outcome = OutcomeUpdated
@@ -161,6 +185,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
return result, nil
}
func sameOptionalID(left, right *uint64) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
// findOrCreateShopeeProduct implements #40's revival rule: a live match wins,
// a soft-deleted match is revived (keeping its prior mapping), and only when
// neither exists does the import create a minimal archive. On an existing
+12 -4
View File
@@ -3,6 +3,8 @@ package sybimport
import (
"context"
"errors"
"fmt"
"go-admin/common/clientprincipal"
"net/http"
"strconv"
"strings"
@@ -113,8 +115,10 @@ func isImportAlreadyRunning(err error) bool {
// `[必须]` This remains the authenticated manual entry point. Both it and the
// scheduler delegate to StartImport, and every downstream SYB call is read-only.
func (handler Handler) Import(c *gin.Context) {
if claimString(jwt.ExtractClaims(c)["rolekey"]) != "admin" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员可以开始导入"})
role := claimString(jwt.ExtractClaims(c)["rolekey"])
client, clientOK := clientprincipal.Get(c)
if role != "admin" && role != "purchaser" && !clientOK {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
return
}
var request ImportRequest
@@ -127,9 +131,13 @@ func (handler Handler) Import(c *gin.Context) {
return
}
claims := jwt.ExtractClaims(c)
result, err := StartImport(c.Request.Context(), service.DB, request, ImportActor{
actor := ImportActor{
ID: claimUint64(claims["identity"]), Name: claimString(claims["nice"]),
}, false)
}
if clientOK {
actor = ImportActor{ID: client.AuthorizedBy, Name: fmt.Sprintf("client-key:%d", client.KeyID)}
}
result, err := StartImport(c.Request.Context(), service.DB, request, actor, false)
if err != nil {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
@@ -53,17 +53,26 @@ func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
}
}
func TestImportRequiresAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": "purchaser"}); c.Next() })
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "只有管理员") {
t.Fatalf("采购员应被拒绝: status=%d body=%s", recorder.Code, recorder.Body.String())
func TestImportRoleBoundary(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "viewer", "", "custom-role"} {
t.Run(role, func(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(func(c *gin.Context) { c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role}); c.Next() })
engine.POST("/api/admin/v1/syb-products/import", Handler{}.Import)
// Authorized roles reach JSON validation; no DB or external SYB call is made.
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", bytes.NewBufferString(`{`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
want := http.StatusForbidden
if role == "admin" || role == "purchaser" {
want = http.StatusUnprocessableEntity
}
if recorder.Code != want {
t.Fatalf("role=%q status=%d want=%d body=%s", role, recorder.Code, want, recorder.Body.String())
}
})
}
}
+18 -3
View File
@@ -28,7 +28,7 @@ type rawDetailSpec struct {
// result page's per-line feedback.
type ReparseOutcome struct {
SYBProductID uint64 `json:"sybProductId"`
Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged
Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged
OldStatus string `json:"oldStatus"`
NewStatus string `json:"newStatus"`
}
@@ -36,6 +36,7 @@ type ReparseOutcome struct {
const (
ReparseOutcomeReparsed = "reparsed"
ReparseOutcomeSkippedManual = "skipped_manual"
ReparseOutcomeSkippedAI = "skipped_ai"
ReparseOutcomeUnchanged = "unchanged"
)
@@ -65,6 +66,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
outcome.NewStatus = record.ParseStatus
return nil
}
if record.AIConfirmed && !force {
outcome.Outcome = ReparseOutcomeSkippedAI
outcome.NewStatus = record.ParseStatus
return nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
@@ -76,8 +82,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
outcome.Outcome = ReparseOutcomeUnchanged
if force {
record.ManuallyConfirmed = false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil {
record.ManuallyConfirmed, record.AIConfirmed = false, false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
"manually_confirmed": false, "ai_confirmed": false, "ai_confidence": nil,
"ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
}
@@ -87,6 +96,8 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
updates := map[string]any{
"target_color": parsed.Color, "target_size": parsed.Size,
"parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
return err
@@ -140,8 +151,12 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color,
return err
}
record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true
record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, ""
record.AIConfirmedAt, record.AIInputFingerprint = nil, ""
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{
"target_color": color, "target_size": size, "manually_confirmed": true,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
+106
View File
@@ -60,6 +60,35 @@ func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) {
}
}
func TestReparseSkipsAIConfirmationUnlessForced(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "AI 结果", "ai_input_fingerprint": strings.Repeat("c", 64),
}).Error; err != nil {
t.Fatal(err)
}
outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false)
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedAI {
t.Fatalf("unforced outcome=%+v err=%v", outcome, err)
}
if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true); err != nil {
t.Fatal(err)
}
var record models.SYBProduct
if err := db.First(&record, applied.SYBProduct.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || record.AIConfidence != nil || record.AIReason != "" || record.AIInputFingerprint != "" {
t.Fatalf("forced reparse retained AI state: %+v", record)
}
}
// 可勾选强制覆盖.
func TestReparseWithForceOverridesManualCorrection(t *testing.T) {
db := openTestDB(t)
@@ -151,6 +180,83 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) {
}
}
func TestManualCorrectSupersedesAIConfirmation(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.96
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64),
}).Error; err != nil {
t.Fatal(err)
}
corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码")
if err != nil {
t.Fatal(err)
}
if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" {
t.Fatalf("human correction did not supersede AI state: %+v", corrected)
}
}
func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64),
}).Error; err != nil {
t.Fatal(err)
}
same, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !same.SYBProduct.AIConfirmed {
t.Fatal("identical re-import must preserve AI confirmation")
}
if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" {
t.Fatal("identical re-import must preserve AI-confirmed target values")
}
detail.ProductSpec += " 新备注"
detail.Raw = []byte(`{"productSpec":"changed"}`)
changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" {
t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct)
}
}
func TestReimportNeverOverwritesManualTargetValues(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
t.Fatal(err)
}
detail.ProductSpec = "来源新颜色,来源新尺码"
detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`)
updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" {
t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct)
}
}
// Regression test: ReparseOutcome originally had no json tags at all, so Go's
// default marshaling produced PascalCase keys ("SYBProductID", "OldStatus")
// instead of the camelCase the rest of this API and the admin frontend use.
+1 -1
View File
@@ -19,7 +19,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin.POST("/reparse-batch", handler.ReparseBatch)
admin.POST("/import", handler.Import)
// Run history is intentionally authenticated but not Casbin-gated: #50
// makes it read-only for every role, while Import enforces admin itself.
// makes it readable for every role; Import also requires admin/purchaser.
readonly := engine.Group("/api/admin/v1/syb-products").Use(auth.MiddlewareFunc())
readonly.GET("/sync-runs", handler.ListSyncRuns)
readonly.GET("/sync-runs/:runId", handler.SyncRunDetail)
+10 -3
View File
@@ -3,6 +3,7 @@ package sybinnercode
import (
"encoding/json"
"errors"
"go-admin/common/clientprincipal"
"io"
"net/http"
"strconv"
@@ -81,13 +82,19 @@ func (h Handler) Import(c *gin.Context) {
if !ok {
return
}
result, err := service.Import(c.Request.Context(), src, file.Filename, uint64(user.GetUserId(c)), c.PostForm("requestId"))
result, err := service.Import(c.Request.Context(), src, file.Filename, clientOperator(c), c.PostForm("requestId"))
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": result})
}
func clientOperator(c *gin.Context) uint64 {
if p, ok := clientprincipal.Get(c); ok {
return p.AuthorizedBy
}
return uint64(user.GetUserId(c))
}
func (h Handler) Delete(c *gin.Context) {
var request DeleteRequest
if err := decode(c, &request); err != nil {
@@ -98,7 +105,7 @@ func (h Handler) Delete(c *gin.Context) {
if !ok {
return
}
result, err := service.Delete(c.Request.Context(), uint64(user.GetUserId(c)), request)
result, err := service.Delete(c.Request.Context(), clientOperator(c), request)
if err != nil {
writeError(c, err)
return
@@ -150,7 +157,7 @@ func (h Handler) Apply(c *gin.Context) {
if !ok {
return
}
result, err := service.QueueApply(c.Request.Context(), uint64(user.GetUserId(c)), request)
result, err := service.QueueApply(c.Request.Context(), clientOperator(c), request)
if err != nil {
writeError(c, err)
return
+87
View File
@@ -0,0 +1,87 @@
package apis
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
jobservice "go-admin/app/jobs/service"
)
func (e SysJob) ListExecutionLogs(c *gin.Context) {
jobID, err := strconv.Atoi(c.Param("id"))
if err != nil || jobID < 1 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "jobId 无效"})
return
}
page, err := positiveQueryInt(c.Query("pageIndex"), 1)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageIndex 必须是正整数"})
return
}
pageSize, err := positiveQueryInt(c.Query("pageSize"), 20)
if err != nil || pageSize > 100 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageSize 必须是 1 到 100 的整数"})
return
}
startedFrom, err := optionalTime(c.Query("startedFrom"))
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedFrom 必须是 RFC3339 时间"})
return
}
startedTo, err := optionalTime(c.Query("startedTo"))
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedTo 必须是 RFC3339 时间"})
return
}
e.MakeContext(c)
db, err := e.GetOrm()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
return
}
response, err := jobservice.NewExecutionLogService(db).List(c.Request.Context(), jobID, jobservice.ExecutionLogListRequest{
Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")),
StartedFrom: startedFrom, StartedTo: startedTo,
})
if err != nil {
switch {
case errors.Is(err, jobservice.ErrExecutionLogInvalidRequest):
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": err.Error()})
case errors.Is(err, jobservice.ErrExecutionLogJobNotFound):
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "定时任务不存在"})
default:
e.GetLogger().Errorf("list scheduled job execution logs failed job_id=%d: %v", jobID, err)
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
}
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func positiveQueryInt(value string, fallback int) (int, error) {
if strings.TrimSpace(value) == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 {
return 0, errors.New("invalid positive integer")
}
return parsed, nil
}
func optionalTime(value string) (*time.Time, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, err
}
return &parsed, nil
}
+5 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"time"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
)
@@ -12,8 +13,10 @@ import (
// 字典 key 可以配置到 自动任务 调用目标 中;
func InitJob() {
jobList = map[string]JobExec{
"ExamplesOne": ExamplesOne{},
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
"ExamplesOne": ExamplesOne{},
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
// ...
}
}
+132
View File
@@ -0,0 +1,132 @@
package jobs
import (
"context"
"errors"
"fmt"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
const (
executionErrorTargetMissing = "JOB_TARGET_NOT_FOUND"
executionErrorExecFailed = "JOB_EXECUTION_FAILED"
executionErrorHTTPFailed = "JOB_HTTP_FAILED"
executionErrorPanicked = "JOB_EXECUTION_PANICKED"
executionErrorInterrupted = "JOB_INTERRUPTED"
)
type executionFailure struct {
code string
message string
cause error
}
func (failure *executionFailure) Error() string {
if failure.cause != nil {
return failure.cause.Error()
}
return failure.message
}
func newExecutionFailure(code, message string, cause error) error {
return &executionFailure{code: code, message: message, cause: cause}
}
func runWithExecutionLog(db *gorm.DB, core JobCore, execute func() error) (executionErr error) {
startedAt := time.Now().UTC()
record := &models.SysJobExecutionLog{
ExecutionID: uuid.NewString(), JobID: core.JobId,
JobNameSnapshot: core.Name, InvokeTargetSnapshot: core.InvokeTarget,
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning,
StartedAt: startedAt,
}
created := false
if db != nil {
if err := db.WithContext(context.Background()).Create(record).Error; err != nil {
log.Errorf("[Job] execution log create failed job_id=%d: %v", core.JobId, err)
} else {
created = true
}
}
defer func() {
if recovered := recover(); recovered != nil {
executionErr = newExecutionFailure(executionErrorPanicked, "任务执行异常中断", nil)
if created {
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
}
return
}
if created {
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
}
}()
executionErr = execute()
return executionErr
}
func finishExecutionLog(db *gorm.DB, jobID int, record *models.SysJobExecutionLog, startedAt time.Time, executionErr error) {
finishedAt := time.Now().UTC()
updates := map[string]any{
"status": models.JobExecutionSucceeded, "finished_at": finishedAt,
"duration_ms": finishedAt.Sub(startedAt).Milliseconds(), "error_code": "", "error_message": "",
}
if executionErr != nil {
code, message := publicExecutionFailure(executionErr)
updates["status"] = models.JobExecutionFailed
updates["error_code"] = code
updates["error_message"] = message
}
if err := db.WithContext(context.Background()).Model(&models.SysJobExecutionLog{}).
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(updates).Error; err != nil {
log.Errorf("[Job] execution log finish failed job_id=%d execution_id=%s: %v", jobID, record.ExecutionID, err)
}
}
func publicExecutionFailure(err error) (string, string) {
var failure *executionFailure
if errors.As(err, &failure) {
return failure.code, failure.message
}
return executionErrorExecFailed, "任务执行失败,请查看受控服务日志"
}
// RecoverInterruptedExecutionLogs closes invocations left running by the
// previous process. Production currently runs one scheduler per database.
func RecoverInterruptedExecutionLogs(db *gorm.DB) error {
if db == nil {
return nil
}
now := time.Now().UTC()
var records []models.SysJobExecutionLog
if err := db.WithContext(context.Background()).Where("status = ?", models.JobExecutionRunning).Find(&records).Error; err != nil {
return err
}
return db.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error {
for _, record := range records {
duration := now.Sub(record.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
if err := tx.Model(&models.SysJobExecutionLog{}).
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(map[string]any{
"status": models.JobExecutionInterrupted, "finished_at": now,
"duration_ms": duration, "error_code": executionErrorInterrupted,
"error_message": "服务重启前任务未完成",
}).Error; err != nil {
return err
}
}
return nil
})
}
func missingExecutionTarget(target string) error {
return newExecutionFailure(executionErrorTargetMissing, "任务调用目标未注册", fmt.Errorf("job target %q is not registered", target))
}
+113
View File
@@ -0,0 +1,113 @@
package jobs
import (
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
func jobExecutionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SysJobExecutionLog{}); err != nil {
t.Fatal(err)
}
return db
}
func TestRunWithExecutionLogRecordsSuccessAndSanitizedFailure(t *testing.T) {
db := jobExecutionTestDB(t)
core := JobCore{JobId: 7, Name: "测试任务", InvokeTarget: "TestTarget"}
if err := runWithExecutionLog(db, core, func() error { return nil }); err != nil {
t.Fatal(err)
}
rawSecret := "token=secret-value productSpec=private"
if err := runWithExecutionLog(db, core, func() error { return errors.New(rawSecret) }); err == nil {
t.Fatal("failed execution must return its error")
}
var records []models.SysJobExecutionLog
if err := db.Order("id asc").Find(&records).Error; err != nil {
t.Fatal(err)
}
if len(records) != 2 || records[0].Status != models.JobExecutionSucceeded || records[1].Status != models.JobExecutionFailed {
t.Fatalf("unexpected records: %+v", records)
}
if records[1].ErrorCode != executionErrorExecFailed || records[1].ErrorMessage == rawSecret || records[1].ErrorMessage == "" {
t.Fatalf("failure was not safely summarized: %+v", records[1])
}
if records[0].FinishedAt == nil || records[1].FinishedAt == nil || records[0].ExecutionID == records[1].ExecutionID {
t.Fatal("execution lifecycle or unique IDs were not recorded")
}
}
func TestRecoverInterruptedExecutionLogs(t *testing.T) {
db := jobExecutionTestDB(t)
started := time.Now().UTC().Add(-2 * time.Second)
record := models.SysJobExecutionLog{
ExecutionID: "00000000-0000-4000-8000-000000000010", JobID: 9,
JobNameSnapshot: "中断任务", InvokeTargetSnapshot: "Interrupted",
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning, StartedAt: started,
}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
if err := RecoverInterruptedExecutionLogs(db); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionInterrupted || record.FinishedAt == nil || record.ErrorCode != executionErrorInterrupted || record.DurationMS < 1000 {
t.Fatalf("record was not safely interrupted: %+v", record)
}
}
func TestMissingExecutionTargetHasPublicSafeMessage(t *testing.T) {
code, message := publicExecutionFailure(missingExecutionTarget("SecretTarget"))
if code != executionErrorTargetMissing || message != "任务调用目标未注册" {
t.Fatalf("unexpected target failure %s %s", code, message)
}
}
func TestExecJobRecordsMissingTargetAsFailure(t *testing.T) {
db := jobExecutionTestDB(t)
previousJobList := jobList
jobList = map[string]JobExec{}
t.Cleanup(func() { jobList = previousJobList })
job := &ExecJob{JobCore: JobCore{JobId: 10, Name: "未注册任务", InvokeTarget: "MissingTarget"}, DB: db}
job.Run()
var record models.SysJobExecutionLog
if err := db.First(&record).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorTargetMissing || record.ErrorMessage != "任务调用目标未注册" {
t.Fatalf("missing target was not safely recorded: %+v", record)
}
}
func TestRunWithExecutionLogSafelyRecordsPanic(t *testing.T) {
db := jobExecutionTestDB(t)
err := runWithExecutionLog(db, JobCore{JobId: 11, Name: "异常任务", InvokeTarget: "Panic"}, func() error {
panic("provider-secret")
})
if code, message := publicExecutionFailure(err); code != executionErrorPanicked || message != "任务执行异常中断" {
t.Fatalf("panic was not returned as a safe failure: %s %s", code, message)
}
var record models.SysJobExecutionLog
if err := db.First(&record).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorPanicked || record.ErrorMessage != "任务执行异常中断" {
t.Fatalf("panic was not safely persisted: %+v", record)
}
}
+33 -22
View File
@@ -33,6 +33,7 @@ type JobCore struct {
// HttpJob 任务类型 http
type HttpJob struct {
JobCore
DB *gorm.DB
}
type ExecJob struct {
@@ -42,14 +43,16 @@ type ExecJob struct {
func (e *ExecJob) Run() {
startTime := time.Now()
var obj = jobList[e.InvokeTarget]
if obj == nil {
log.Warn("[Job] ExecJob Run job nil")
return
}
err := CallExecWithDB(obj.(JobExec), e.DB, e.Args)
err := runWithExecutionLog(e.DB, e.JobCore, func() error {
obj := jobList[e.InvokeTarget]
if obj == nil {
return missingExecutionTarget(e.InvokeTarget)
}
return CallExecWithDB(obj, e.DB, e.Args)
})
if err != nil {
log.Errorf("[Job] JobCore %s failed: %v", e.Name, err)
code, _ := publicExecutionFailure(err)
log.Errorf("[Job] JobCore %s failed error_code=%s", e.Name, code)
return
}
// 结束时间
@@ -68,22 +71,26 @@ func (e *ExecJob) Run() {
func (h *HttpJob) Run() {
startTime := time.Now()
var count = 0
var err error
var str string
/* 循环 */
LOOP:
if count < retryCount {
/* 跳过迭代 */
str, err = pkg.Get(h.InvokeTarget)
if err != nil {
// 如果失败暂停一段时间重试
log.Warnf("[Job] mission failed! %v", err)
log.Warnf("[Job] Retry after the task fails %d seconds! %s \n", (count+1)*5, str)
time.Sleep(time.Duration(count+1) * 5 * time.Second)
count = count + 1
goto LOOP
err := runWithExecutionLog(h.DB, h.JobCore, func() error {
var lastErr error
for count := 0; count < retryCount; count++ {
_, requestErr := pkg.Get(h.InvokeTarget)
if requestErr == nil {
return nil
}
lastErr = requestErr
log.Warnf("[Job] HTTP mission failed attempt=%d", count+1)
if count+1 < retryCount {
log.Warnf("[Job] Retry after the task fails %d seconds!\n", (count+1)*5)
time.Sleep(time.Duration(count+1) * 5 * time.Second)
}
}
return newExecutionFailure(executionErrorHTTPFailed, "HTTP 任务请求失败", lastErr)
})
if err != nil {
code, _ := publicExecutionFailure(err)
log.Errorf("[Job] JobCore %s failed error_code=%s", h.Name, code)
return
}
// 结束时间
endTime := time.Now()
@@ -102,6 +109,9 @@ func Setup(dbs map[string]*gorm.DB) {
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
for k, db := range dbs {
if err := RecoverInterruptedExecutionLogs(db); err != nil {
log.Errorf("[Job] recover interrupted execution logs failed: %v", err)
}
sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds())
setup(k, db)
}
@@ -127,6 +137,7 @@ func setup(key string, db *gorm.DB) {
for i := 0; i < len(jobList); i++ {
if jobList[i].JobType == 1 {
j := &HttpJob{}
j.DB = db
j.InvokeTarget = jobList[i].InvokeTarget
j.CronExpression = jobList[i].CronExpression
j.JobId = jobList[i].JobId
@@ -0,0 +1,32 @@
package models
import "time"
const (
JobExecutionRunning = "running"
JobExecutionSucceeded = "succeeded"
JobExecutionFailed = "failed"
JobExecutionInterrupted = "interrupted"
JobTriggerScheduled = "scheduled"
)
// SysJobExecutionLog stores one scheduler invocation. Job arguments and raw
// provider responses are deliberately excluded from this audit record.
type SysJobExecutionLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ExecutionID string `json:"executionId" gorm:"size:36;not null;uniqueIndex:ux_sys_job_execution_id"`
JobID int `json:"jobId" gorm:"not null;index:idx_sys_job_execution_job_started,priority:1"`
JobNameSnapshot string `json:"jobName" gorm:"size:255;not null"`
InvokeTargetSnapshot string `json:"invokeTarget" gorm:"size:255;not null"`
TriggerType string `json:"triggerType" gorm:"size:16;not null"`
Status string `json:"status" gorm:"size:16;not null;index:idx_sys_job_execution_status_started,priority:1"`
StartedAt time.Time `json:"startedAt" gorm:"not null;index:idx_sys_job_execution_job_started,priority:2;index:idx_sys_job_execution_status_started,priority:2"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMS int64 `json:"durationMs" gorm:"not null;default:0"`
ErrorCode string `json:"errorCode,omitempty" gorm:"size:64;not null;default:''"`
ErrorMessage string `json:"errorMessage,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SysJobExecutionLog) TableName() string { return "sys_job_execution_log" }
+2
View File
@@ -24,6 +24,8 @@ func registerSysJobRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
list := make([]models2.SysJob, 0)
return &list
}))
jobAPI := apis.SysJob{}
r.GET("/:id/execution-logs", actions.PermissionAction(), jobAPI.ListExecutionLogs)
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto2.SysJobById), func() interface{} {
return &dto2.SysJobItem{}
}))
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
var (
ErrExecutionLogInvalidRequest = errors.New("invalid execution log request")
ErrExecutionLogJobNotFound = errors.New("scheduled job not found")
)
type ExecutionLogListRequest struct {
Page int
PageSize int
Status string
StartedFrom *time.Time
StartedTo *time.Time
}
type ExecutionLogJob struct {
JobID int `json:"jobId"`
JobName string `json:"jobName"`
InvokeTarget string `json:"invokeTarget"`
Deleted bool `json:"deleted"`
}
type ExecutionLogListResponse struct {
Job ExecutionLogJob `json:"job"`
Items []models.SysJobExecutionLog `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type ExecutionLogService struct{ db *gorm.DB }
func NewExecutionLogService(db *gorm.DB) *ExecutionLogService {
return &ExecutionLogService{db: db}
}
func (service *ExecutionLogService) List(ctx context.Context, jobID int, request ExecutionLogListRequest) (ExecutionLogListResponse, error) {
if service.db == nil || jobID < 1 {
return ExecutionLogListResponse{}, fmt.Errorf("%w: jobId 无效", ErrExecutionLogInvalidRequest)
}
if request.Page < 1 {
request.Page = 1
}
if request.PageSize < 1 {
request.PageSize = 20
}
if request.PageSize > 100 {
return ExecutionLogListResponse{}, fmt.Errorf("%w: pageSize 必须是 1 到 100 的整数", ErrExecutionLogInvalidRequest)
}
if request.Status != "" && !validExecutionStatus(request.Status) {
return ExecutionLogListResponse{}, fmt.Errorf("%w: status 无效", ErrExecutionLogInvalidRequest)
}
if request.StartedFrom != nil && request.StartedTo != nil && request.StartedFrom.After(*request.StartedTo) {
return ExecutionLogListResponse{}, fmt.Errorf("%w: 开始时间范围无效", ErrExecutionLogInvalidRequest)
}
var job models.SysJob
if err := service.db.WithContext(ctx).Unscoped().First(&job, jobID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ExecutionLogListResponse{}, ErrExecutionLogJobNotFound
}
return ExecutionLogListResponse{}, err
}
query := service.db.WithContext(ctx).Model(&models.SysJobExecutionLog{}).Where("job_id = ?", jobID)
if request.Status != "" {
query = query.Where("status = ?", request.Status)
}
if request.StartedFrom != nil {
query = query.Where("started_at >= ?", request.StartedFrom.UTC())
}
if request.StartedTo != nil {
query = query.Where("started_at <= ?", request.StartedTo.UTC())
}
var total int64
if err := query.Count(&total).Error; err != nil {
return ExecutionLogListResponse{}, err
}
items := make([]models.SysJobExecutionLog, 0, request.PageSize)
if err := query.Order("started_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&items).Error; err != nil {
return ExecutionLogListResponse{}, err
}
return ExecutionLogListResponse{
Job: ExecutionLogJob{JobID: job.JobId, JobName: job.JobName, InvokeTarget: job.InvokeTarget, Deleted: job.DeletedAt.Valid},
Items: items, Total: total, Page: request.Page, PageSize: request.PageSize,
}, nil
}
func validExecutionStatus(status string) bool {
switch status {
case models.JobExecutionRunning, models.JobExecutionSucceeded, models.JobExecutionFailed, models.JobExecutionInterrupted:
return true
default:
return false
}
}
@@ -0,0 +1,87 @@
package service
import (
"context"
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
func executionLogTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SysJob{}, &models.SysJobExecutionLog{}); err != nil {
t.Fatal(err)
}
return db
}
func TestExecutionLogListFiltersPaginatesAndKeepsDeletedJob(t *testing.T) {
db := executionLogTestDB(t)
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
base := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
records := []models.SysJobExecutionLog{
{ExecutionID: "00000000-0000-4000-8000-000000000001", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionSucceeded, StartedAt: base},
{ExecutionID: "00000000-0000-4000-8000-000000000002", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(time.Hour)},
{ExecutionID: "00000000-0000-4000-8000-000000000003", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(2 * time.Hour)},
}
if err := db.Create(&records).Error; err != nil {
t.Fatal(err)
}
from, to := base.Add(30*time.Minute), base.Add(3*time.Hour)
result, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{
Page: 1, PageSize: 1, Status: models.JobExecutionFailed, StartedFrom: &from, StartedTo: &to,
})
if err != nil {
t.Fatal(err)
}
if result.Total != 2 || len(result.Items) != 1 || result.Items[0].ExecutionID != records[2].ExecutionID {
t.Fatalf("unexpected filtered page: %+v", result)
}
if err := db.Delete(&job).Error; err != nil {
t.Fatal(err)
}
deleted, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{Page: 1, PageSize: 20})
if err != nil {
t.Fatal(err)
}
if !deleted.Job.Deleted || deleted.Job.JobName != job.JobName || deleted.Total != 3 {
t.Fatalf("deleted job history unavailable: %+v", deleted)
}
}
func TestExecutionLogListValidationAndNotFound(t *testing.T) {
db := executionLogTestDB(t)
service := NewExecutionLogService(db)
if _, err := service.List(context.Background(), 0, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid job id error = %v", err)
}
if _, err := service.List(context.Background(), 1, ExecutionLogListRequest{PageSize: 101}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid page size error = %v", err)
}
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{Status: "unknown"}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid status error = %v", err)
}
from, to := time.Now(), time.Now().Add(-time.Hour)
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{StartedFrom: &from, StartedTo: &to}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid range error = %v", err)
}
if _, err := service.List(context.Background(), 999, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogJobNotFound) {
t.Fatalf("not found error = %v", err)
}
}
+1
View File
@@ -62,6 +62,7 @@ func (e *SysJob) StartJob(c *dto.GeneralGetDto) error {
if data.JobType == 1 {
var j = &jobs.HttpJob{}
j.DB = e.Orm.WithContext(context.Background())
j.InvokeTarget = data.InvokeTarget
j.CronExpression = data.CronExpression
j.JobId = data.JobId
+19 -1
View File
@@ -54,6 +54,12 @@ var (
}
)
// The synchronous Admin AI endpoints allow a provider timeout of up to 600
// seconds and the browser waits 610 seconds. Keep the HTTP server alive a
// little longer so it can return the domain response instead of truncating
// the connection and surfacing a proxy-level 502.
const minimumAPIWriteTimeout = 620 * time.Second
var AppRouters = make([]func(), 0)
func init() {
@@ -125,11 +131,15 @@ func run() error {
)
}
writeTimeout, err := validatedAPIWriteTimeout(config.ApplicationConfig.WriterTimeout)
if err != nil {
return err
}
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
Handler: sdk.Runtime.GetEngine(),
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
WriteTimeout: writeTimeout,
}
go func() {
@@ -193,6 +203,14 @@ func run() error {
return nil
}
func validatedAPIWriteTimeout(seconds int) (time.Duration, error) {
timeout := time.Duration(seconds) * time.Second
if timeout < minimumAPIWriteTimeout {
return 0, fmt.Errorf("application writetimeout must be at least %s for synchronous AI requests", minimumAPIWriteTimeout)
}
return timeout, nil
}
type policyLoader interface {
LoadPolicy() error
}
+19
View File
@@ -0,0 +1,19 @@
package api
import (
"testing"
"time"
)
func TestValidatedAPIWriteTimeoutProtectsSynchronousAIRequests(t *testing.T) {
if _, err := validatedAPIWriteTimeout(2); err == nil {
t.Fatal("two-second write timeout must be rejected")
}
got, err := validatedAPIWriteTimeout(620)
if err != nil {
t.Fatalf("620-second write timeout should be accepted: %v", err)
}
if got != 620*time.Second {
t.Fatalf("write timeout = %s, want 620s", got)
}
}
@@ -38,11 +38,11 @@ func TestEnsurePurchaserRoleAndPolicies(t *testing.T) {
if count != int64(len(access.PurchaserAPIs())) {
t.Fatalf("got %d policies, want %d", count, len(access.PurchaserAPIs()))
}
var forbidden int64
var allowed int64
db.Model(&purchaserCasbinRule{}).
Where("v0 = ? AND v1 = ? AND v2 = ?", access.RolePurchaser, "/api/admin/v1/syb-products/import", "POST").
Count(&forbidden)
if forbidden != 0 {
t.Fatal("manual SYB import must remain administrator-only")
Count(&allowed)
if allowed != 1 {
t.Fatal("manual SYB import must follow the current purchaser permission matrix")
}
}
@@ -0,0 +1,47 @@
package version_local
import (
"errors"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/shopeeproduct"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateShopeeSpecAutoMatch)
}
func migrateShopeeSpecAutoMatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := ensureShopeeSpecAutoMatchJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureShopeeSpecAutoMatchJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "蝦皮规格自动匹配", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 15 * * * *", InvokeTarget: shopeeproduct.SpecAutoMatchInvokeTarget,
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
}).Error
}
@@ -0,0 +1,50 @@
package version_local
import (
"testing"
"go-admin/app/goauto/shopeeproduct"
jobsmodels "go-admin/app/jobs/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureShopeeSpecAutoMatchJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:shopee-spec-auto-match-job?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
t.Fatal(err)
}
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
t.Fatal(err)
}
var job jobsmodels.SysJob
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 1 || job.CronExpression != "0 15 * * * *" || job.Args != `{"batchLimit":20}` {
t.Fatalf("unexpected seed: %+v", job)
}
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 30 * * * *", "args": `{"batchLimit":5}`}).Error; err != nil {
t.Fatal(err)
}
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("count=%d", count)
}
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 2 || job.CronExpression != "0 30 * * * *" || job.Args != `{"batchLimit":5}` {
t.Fatalf("admin changes overwritten: %+v", job)
}
}
@@ -0,0 +1,47 @@
package version_local
import (
"errors"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/sybimport"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecAIParse)
}
func migrateSYBSpecAIParse(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := ensureSYBSpecAIParseJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureSYBSpecAIParseJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "SYB 异常规格 AI 解析", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 5 * * * *", InvokeTarget: sybimport.SpecAIParseInvokeTarget,
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
}).Error
}
@@ -0,0 +1,50 @@
package version_local
import (
"testing"
"go-admin/app/goauto/sybimport"
jobsmodels "go-admin/app/jobs/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureSYBSpecAIParseJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
t.Fatal(err)
}
if err := ensureSYBSpecAIParseJob(db); err != nil {
t.Fatal(err)
}
var job jobsmodels.SysJob
if err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 1 || job.CronExpression != "0 5 * * * *" || job.Args != `{"batchLimit":20}` {
t.Fatalf("unexpected initial job: %+v", job)
}
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 7 * * * *", "args": `{"batchLimit":7}`}).Error; err != nil {
t.Fatal(err)
}
if err := ensureSYBSpecAIParseJob(db); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("job count=%d, want 1", count)
}
if err := db.First(&job, job.JobId).Error; err != nil {
t.Fatal(err)
}
if job.Status != 2 || job.CronExpression != "0 7 * * * *" || job.Args != `{"batchLimit":7}` {
t.Fatalf("existing administrator changes were overwritten: %+v", job)
}
}
@@ -0,0 +1,68 @@
package version_local
import (
"fmt"
"runtime"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
const jobExecutionLogsAPIPath = "/api/v1/sysjob/:id/execution-logs"
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSysJobExecutionLog)
}
func migrateSysJobExecutionLog(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ensureSysJobExecutionLog(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureSysJobExecutionLog(db *gorm.DB) error {
if err := db.AutoMigrate(&jobsmodels.SysJobExecutionLog{}); err != nil {
return err
}
var logMenu migrationmodels.SysMenu
if err := db.Where("menu_name = ?", "JobLog").First(&logMenu).Error; err != nil {
return fmt.Errorf("find JobLog menu: %w", err)
}
api := migrationmodels.SysApi{}
if err := db.Where(migrationmodels.SysApi{Path: jobExecutionLogsAPIPath, Action: "GET"}).
Attrs(migrationmodels.SysApi{Title: "查询定时任务执行日志", Type: "BUS"}).FirstOrCreate(&api).Error; err != nil {
return err
}
if err := db.Model(&logMenu).Association("SysApi").Append(&api); err != nil {
return err
}
var roleIDs []int
if err := db.Table("sys_role_menu").Where("menu_id = ?", logMenu.MenuId).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
return err
}
if len(roleIDs) == 0 {
return nil
}
var roles []migrationmodels.SysRole
if err := db.Where("role_id IN ?", roleIDs).Find(&roles).Error; err != nil {
return err
}
for _, role := range roles {
rule := purchaserCasbinRule{Ptype: "p", V0: role.RoleKey, V1: jobExecutionLogsAPIPath, V2: "GET"}
if err := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", rule.Ptype, rule.V0, rule.V1, rule.V2).
FirstOrCreate(&rule).Error; err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,78 @@
package version_local
import (
"testing"
jobsmodels "go-admin/app/jobs/models"
migrationmodels "go-admin/cmd/migrate/migration/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureSysJobExecutionLogIsIdempotentAndGrantsOnlyBoundRoles(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{},
); err != nil {
t.Fatal(err)
}
menu := migrationmodels.SysMenu{MenuName: "JobLog", Title: "日志", Path: "/schedule/log", Component: "/schedule/log"}
if err := db.Create(&menu).Error; err != nil {
t.Fatal(err)
}
bound := migrationmodels.SysRole{RoleName: "日志管理员", RoleKey: "job_logger"}
unbound := migrationmodels.SysRole{RoleName: "无日志权限", RoleKey: "no_job_logs"}
if err := db.Create(&bound).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&unbound).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&bound).Association("SysMenu").Append(&menu); err != nil {
t.Fatal(err)
}
custom := purchaserCasbinRule{Ptype: "p", V0: bound.RoleKey, V1: "/custom", V2: "GET"}
if err := db.Create(&custom).Error; err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err != nil {
t.Fatalf("migration helper must be repeatable: %v", err)
}
if !db.Migrator().HasTable(&jobsmodels.SysJobExecutionLog{}) {
t.Fatal("execution log table was not created")
}
var apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy int64
db.Model(&migrationmodels.SysApi{}).Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").Count(&apiCount)
var api migrationmodels.SysApi
if err := db.Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").First(&api).Error; err != nil {
t.Fatal(err)
}
db.Table("sys_menu_api_rule").Where("sys_menu_menu_id = ? AND sys_api_id = ?", menu.MenuId, api.Id).Count(&menuAPI)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", bound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&boundPolicy)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", unbound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&unboundPolicy)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ?", bound.RoleKey, "/custom").Count(&customPolicy)
if apiCount != 1 || menuAPI != 1 || boundPolicy != 1 || unboundPolicy != 0 || customPolicy != 1 {
t.Fatalf("unexpected permission state api=%d menu=%d bound=%d unbound=%d custom=%d", apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy)
}
}
func TestEnsureSysJobExecutionLogRequiresExistingMenu(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{}); err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err == nil {
t.Fatal("missing JobLog menu must fail closed")
}
}
@@ -0,0 +1,42 @@
package version_local
import (
"fmt"
"go-admin/app/goauto/clientkey"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateClientAPIKey)
}
func migrateClientAPIKey(db *gorm.DB, version string) error {
if err := db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
var parent migrationmodels.SysMenu
if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
return err
}
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{MenuName: "GoAutoClientKeys", Title: "客户端密钥", Icon: "lock", Path: "/client-keys/index", MenuType: "C", Action: "无", ParentId: parent.MenuId, Component: "/goauto/client-keys/index", Sort: 6, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
return err
}
var admin migrationmodels.SysRole
if err = tx.Where("role_key = ?", "admin").First(&admin).Error; err != nil {
return err
}
if err = tx.Model(&admin).Association("SysMenu").Append(&child); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,45 @@
package version_local
import (
"go-admin/app/goauto/clientkey"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"testing"
)
func TestClientKeyMigrationOnlyAdminMenu(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
defer sql.Close()
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
admin := migrationmodels.SysRole{RoleKey: "admin"}
purchaser := migrationmodels.SysRole{RoleKey: "purchaser"}
parent := migrationmodels.SysMenu{MenuName: "GoAutoCollectionManagement"}
for _, v := range []any{&admin, &purchaser, &parent} {
if err = db.Create(v).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateClientAPIKey(db, "client-key-test"); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasTable(&clientkey.Key{}) || !db.Migrator().HasTable(&clientkey.Audit{}) {
t.Fatal("missing tables")
}
var child migrationmodels.SysMenu
if err = db.Where("menu_name = ?", "GoAutoClientKeys").First(&child).Error; err != nil {
t.Fatal(err)
}
if child.ParentId != parent.MenuId {
t.Fatal("wrong menu group")
}
assertRoleHasMenu(t, db, admin.RoleId, child.MenuId, true)
assertRoleHasMenu(t, db, purchaser.RoleId, child.MenuId, false)
}
@@ -0,0 +1,20 @@
// Package clientprincipal carries an authenticated client identity, never an
// administrator or purchaser JWT. Only the client gateway sets it.
package clientprincipal
import "github.com/gin-gonic/gin"
const contextKey = "goauto.authenticatedClient"
type Identity struct {
KeyID uint64
RequestID string
AuthorizedBy uint64
}
func Set(c *gin.Context, id Identity) { c.Set(contextKey, id) }
func Get(c *gin.Context) (Identity, bool) {
v, ok := c.Get(contextKey)
id, valid := v.(Identity)
return id, ok && valid && id.KeyID > 0
}
+6
View File
@@ -24,6 +24,12 @@ import (
// LoggerToFile 日志记录到文件
func LoggerToFile() gin.HandlerFunc {
return func(c *gin.Context) {
// #237: client request/response bodies and one-time credentials must never
// enter the legacy operation logger. The client gateway keeps metadata-only audit.
if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") {
c.Next()
return
}
log := api.GetRequestLogger(c)
// 开始时间
startTime := time.Now()
+1 -1
View File
@@ -9,7 +9,7 @@ settings:
# 端口号
port: 8000 # 服务端口号
readtimeout: 1
writertimeout: 2
writertimeout: 620
# 数据权限功能开关
enabledp: false
ssl:
+2 -2
View File
@@ -9,7 +9,7 @@ settings:
# 端口号
port: 8000 # 服务端口号
readtimeout: 1
writertimeout: 2
writertimeout: 620
# 数据权限功能开关
enabledp: false
logger:
@@ -84,4 +84,4 @@ settings:
# blockingTimeout: 5
# reclaimInterval: 1
locker:
redis:
redis:
+8
View File
@@ -0,0 +1,8 @@
import request from '@/utils/request'
const base = '/api/admin/v1/client-keys'
export const listClientKeys = page => request({ url: base, method: 'get', params: { page }, suppressErrorMessage: true })
export const clientKeyModules = () => request({ url: `${base}/modules`, method: 'get', suppressErrorMessage: true })
export const createClientKey = data => request({ url: base, method: 'post', data, suppressErrorMessage: true })
export const editClientKey = (id, data) => request({ url: `${base}/${id}/grants`, method: 'patch', data, suppressErrorMessage: true })
export const disableClientKey = (id, version) => request({ url: `${base}/${id}/disable`, method: 'post', data: { version }, suppressErrorMessage: true })
+8
View File
@@ -70,6 +70,14 @@ export function autoMatchShopeeSpecMappings(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
}
export function startShopeeSpecAutoMatchRun(data) {
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs', method: 'post', data })
}
export function getLatestShopeeSpecAutoMatchRun() {
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs/latest', method: 'get' })
}
export function batchDeleteShopeeProducts(data) {
return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data })
}
+9 -1
View File
@@ -17,6 +17,15 @@ export function getSysJob(jobId) {
})
}
// 查询指定定时任务的持久化执行历史
export function listJobExecutionLogs(jobId, query) {
return request({
url: '/api/v1/sysjob/' + jobId + '/execution-logs',
method: 'get',
params: query
})
}
// 新增SysJob
export function addSysJob(data) {
return request({
@@ -59,4 +68,3 @@ export function startJob(jobId) {
method: 'get'
})
}
+121
View File
@@ -0,0 +1,121 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card shadow="never">
<el-alert v-if="!isAdmin" title="只有管理员可以管理客户端密钥" type="warning" :closable="false" />
<template v-else>
<div class="heading"><div><h2>客户端密钥</h2><p>按菜单模块开放客户端访问。完整密钥仅在创建成功后显示一次。</p></div><el-button type="primary" :disabled="loading || !!loadError" @click="openEditor()">创建密钥</el-button></div>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false"><el-button @click="load">重新加载</el-button></el-alert>
<el-table v-loading="loading" :data="items" row-key="id" border empty-text="还没有客户端密钥,点击右上角创建">
<el-table-column label="名称 / 标识" min-width="210"><template #default="{ row }"><strong>{{ row.name }}</strong><p class="muted">{{ row.prefix }}••••</p></template></el-table-column>
<el-table-column label="授权模块" min-width="250"><template #default="{ row }"><div v-for="g in row.grants" :key="g.module">{{ moduleName(g.module) }} · {{ g.write ? '读写' : '只读' }}<span v-if="g.actions.length"> / {{ g.actions.map(actionName).join('、') }}</span></div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用中' : '已停用' }}</el-tag></template></el-table-column>
<el-table-column label="最后使用" min-width="170"><template #default="{ row }">{{ row.lastUsedAt ? formatTime(row.lastUsedAt) : '尚未使用' }}</template></el-table-column>
<el-table-column label="操作" width="255"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row, true)">查看授权</el-button><el-button link type="primary" :disabled="!row.enabled || saving" @click="openEditor(row)">编辑授权</el-button><el-button link type="danger" :disabled="!row.enabled || saving" @click="disable(row)">停用</el-button></template></el-table-column>
</el-table>
<el-pagination v-if="total" v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next, total" @current-change="load" />
<p class="muted">客户端地址:/api/client/v1。支持 HTTP / HTTPS;HTTP 明文传输密钥和数据。不开放支付、账号权限管理及敏感密钥读取。</p>
</template>
</el-card>
<el-dialog v-model="editorOpen" :title="readonly ? '查看授权' : editing ? '编辑授权' : '创建客户端密钥'" width="min(1080px, 95vw)" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving" :before-close="closeEditor">
<el-alert v-if="editError" :title="editError" type="error" :closable="false" class="notice" />
<el-form label-position="top" @submit.prevent="save">
<el-form-item label="名称" required><el-input v-model.trim="name" maxlength="80" :disabled="readonly || editing || saving" placeholder="例如:商品同步工具" /></el-form-item>
<p v-if="editing" class="muted">标识:{{ selected.prefix }}•••• · 保存不会更换 API Key,完整密钥不可再次查看。</p>
<el-alert title="新选模块默认只读;同步、采集、采购、回写、删除等动作需单独勾选。新增菜单不会自动授权。" type="info" :closable="false" class="notice" />
<div class="groups">
<section v-for="group in groups" :key="group.title" class="module-group">
<el-checkbox :model-value="group.modules.every(m => !!grants[m.key])" :indeterminate="group.modules.some(m => !!grants[m.key]) && !group.modules.every(m => !!grants[m.key])" :disabled="readonly || saving" @change="value => selectGroup(group, value)">{{ group.title }}</el-checkbox>
<div v-for="m in group.modules" :key="m.key" class="module-row">
<div class="module-main"><el-checkbox :model-value="!!grants[m.key]" :disabled="readonly || saving" @change="value => selectModule(m.key, value)">{{ m.title }}</el-checkbox><el-radio-group v-if="grants[m.key]" v-model="grants[m.key].write" :disabled="readonly || saving" size="small" :aria-label="`${m.title}访问方式`"><el-radio-button :value="false">只读</el-radio-button><el-radio-button :value="true" :disabled="!m.writable">读写</el-radio-button></el-radio-group></div>
<div v-if="grants[m.key] && m.actions.length" class="actions"><span class="muted">独立动作:</span><el-checkbox-group v-model="grants[m.key].actions" :disabled="readonly || saving"><el-checkbox v-for="action in m.actions" :key="action" :value="action">{{ actionName(action) }}</el-checkbox></el-checkbox-group></div>
</div>
</section>
</div>
<p v-if="editing" class="muted">{{ selected.enabled ? '保存成功后,后续请求按新授权校验;已执行操作不回滚。' : '密钥已停用,只允许查看授权。' }} 最近修改:管理员 #{{ selected.updatedBy }} · {{ formatTime(selected.updatedAt) }}</p>
<p v-if="!Object.keys(grants).length" class="validation" role="alert">至少选择一个模块;要禁止全部访问,请使用停用。</p>
</el-form>
<template #footer><el-button :disabled="saving" @click="closeEditor()">{{ readonly ? '关闭' : '取消' }}</el-button><el-button v-if="!readonly" type="primary" :loading="saving" :disabled="!valid || !changed || conflicted" @click="save">{{ editing ? '保存授权' : '创建密钥' }}</el-button></template>
</el-dialog>
<el-dialog v-model="secretOpen" title="密钥已创建,请立即保存" width="min(650px, 95vw)" :close-on-click-modal="false" @closed="clearSecret">
<el-alert title="完整密钥只显示这一次;关闭后不可再次查看。请勿放入 URL、日志或前端代码。" type="warning" :closable="false" />
<pre class="secret">{{ secret }}</pre><el-button type="primary" @click="copySecret">复制密钥</el-button>
<p>请求头:Authorization: Bearer &lt;客户端密钥&gt;</p>
<template #footer><el-button type="primary" @click="secretOpen = false">已保存,返回列表</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { listClientKeys, clientKeyModules, createClientKey, editClientKey, disableClientKey } from '@/api/goauto/client-keys'
const actionLabels = { sync: '立即同步', import: '导入', match: '匹配 / 确认', collect: '创建 / 重新采集', purchase: '创建 / 重试采购', writeback: '回写', delete: '删除', reparse: '重新解析', activate: '设置当前规则' }
const snapshot = grants => JSON.stringify(Object.values(grants).map(g => ({ ...g, actions: [...g.actions].sort() })).sort((a, b) => a.module.localeCompare(b.module)))
const errorText = error => error.response?.data?.message || '请求失败,请检查网络后重试'
export default {
name: 'GoAutoClientKeys',
data() { return { active: true, items: [], modules: [], total: 0, page: 1, loading: false, loadError: '', saving: false, editorOpen: false, editing: false, readonly: false, selected: null, name: '', grants: {}, initial: '', editError: '', conflicted: false, secret: '', secretOpen: false } },
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
groups() { return [...new Set(this.modules.map(m => m.group))].map(title => ({ title, modules: this.modules.filter(m => m.group === title) })) },
valid() { return !!this.name.trim() && Object.keys(this.grants).length > 0 },
changed() { return !this.editing || snapshot(this.grants) !== this.initial }
},
mounted() { this.load() },
activated() { this.active = true },
deactivated() { this.active = false; this.clearSecret(); this.secretOpen = false },
beforeUnmount() { this.active = false; this.clearSecret() },
methods: {
moduleName(key) { return this.modules.find(m => m.key === key)?.title || key },
actionName(action) { return actionLabels[action] || action },
formatTime(value) { return value ? new Date(value).toLocaleString() : '—' },
async load() {
if (!this.isAdmin || this.loading) return
this.loading = true; this.loadError = ''
try { const [rows, catalog] = await Promise.all([listClientKeys(this.page), clientKeyModules()]); this.items = rows.data.items; this.total = rows.data.total; this.modules = catalog.data } catch (error) { this.loadError = errorText(error) } finally { this.loading = false }
},
openEditor(row = null, readonly = false) {
if (!this.isAdmin || (row && !row.enabled && !readonly)) return
this.selected = row; this.editing = !!row; this.readonly = readonly; this.name = row?.name || ''; this.grants = {}
for (const g of row?.grants || []) this.grants[g.module] = { ...g, actions: [...g.actions] }
this.initial = snapshot(this.grants); this.editError = ''; this.conflicted = false; this.editorOpen = true
},
selectModule(key, value) { if (value) { if (!this.grants[key]) this.grants[key] = { module: key, write: false, actions: [] } } else delete this.grants[key] },
selectGroup(group, value) { for (const m of group.modules) this.selectModule(m.key, value) },
closeEditor(done) { if (this.saving) return; this.editorOpen = false; this.grants = {}; if (typeof done === 'function') done() },
async save() {
if (!this.valid || !this.changed || this.saving || this.readonly || this.conflicted) return
this.saving = true; this.editError = ''
try {
if (this.editing) { await editClientKey(this.selected.id, { version: this.selected.version, grants: Object.values(this.grants) }); ElMessage.success('授权已更新,API Key 保持不变') } else { const result = await createClientKey({ name: this.name, grants: Object.values(this.grants) }); if (this.active) { this.secret = result.data.secret; this.secretOpen = true } }
this.editorOpen = false; await this.load()
} catch (error) { this.editError = errorText(error); if (!this.editing) this.editError += '。响应丢失时可能已创建,请先检查列表;丢失密钥需停用后重建。'; this.conflicted = error.response?.status === 409; if (this.conflicted) { this.editError += '。请取消并刷新列表后重新打开。'; await this.load() } } finally { this.saving = false }
},
async disable(row) {
try { await ElMessageBox.confirm(`停用“${row.name}”后,新请求将被拒绝,已执行操作不回滚。首版不支持恢复。`, '停用客户端密钥', { confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning' }) } catch { return }
this.saving = true
try { await disableClientKey(row.id, row.version); ElMessage.success('密钥已停用'); await this.load() } catch (error) { ElMessage.error(errorText(error)); await this.load() } finally { this.saving = false }
},
async copySecret() { try { await navigator.clipboard.writeText(this.secret); ElMessage.success('已复制,请妥善保存') } catch { ElMessage.warning('复制失败,请手动选择并复制密钥') } },
clearSecret() { this.secret = '' }
}
}
</script>
<style scoped>
.heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
h2 { margin: 0 0 12px; }
.muted, .heading p { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
.groups { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.module-group { padding: 16px; border: 1px solid var(--el-border-color); border-radius: 6px; min-width: 0; }
.module-row { padding: 10px 0; border-top: 1px solid var(--el-border-color-lighter); }
.module-main { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.actions { padding: 4px 0 0 22px; }
.notice, .el-pagination { margin: 16px 0; }
.validation { color: var(--el-color-danger); }
.secret { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; background: var(--el-fill-color-light); }
@media (max-width: 760px) { .groups { grid-template-columns: 1fr; } .heading { align-items: flex-start; } }
</style>
+12 -6
View File
@@ -1,7 +1,7 @@
<template>
<BasicLayout><template #wrapper>
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item></el-form>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item><el-form-item v-if="isAdmin"><el-button type="success" plain :loading="autoMatchRun.submitting" :disabled="autoMatchRun.latest?.status === 'running'" @click="startAutoMatchRun">{{ autoMatchRun.latest?.status === 'running' ? '自动匹配进行中' : '执行规格自动匹配' }}</el-button><span class="run-summary">{{ autoMatchRunSummary }}</span></el-form-item></el-form>
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" /><p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
@@ -19,9 +19,9 @@
</template>
<script>
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts } from '@/api/goauto/shopee-products'
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts, startShopeeSpecAutoMatchRun, getLatestShopeeSpecAutoMatchRun } from '@/api/goauto/shopee-products'
import { listPddProducts } from '@/api/goauto/pdd-products'
import { createRequestId } from '@/utils/request-id'
import ShopeeProductDetailDrawer from './ShopeeProductDetailDrawer.vue'
@@ -29,10 +29,16 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
export default {
name: 'GoAutoShopeeProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search, Delete } },
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }} },
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail() }, activated() { this.applyRouteDetail() },
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
autoMatchRunSummary() { const run = this.autoMatchRun.latest; if (!run) return '最近一次:暂无'; if (run.status === 'running') return `最近一次:运行中,已处理 ${run.processedCount || 0}`; const status = run.status === 'completed' ? '完成' : run.status === 'completed_partial' ? '部分完成' : '失败'; return `最近一次:${status},处理 ${run.processedCount || 0},确认 ${run.confirmedCount || 0},未匹配 ${run.unmatchedCount || 0}` }
},
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, activated() { this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, beforeUnmount() { if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer) },
methods: {
emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } }, emptyBatchDelete() { return { open: false, saving: false, step: 'confirm', products: [], results: [], deletedCount: 0, skippedCount: 0 } },
async loadLatestAutoMatchRun() { if (!this.isAdmin) return; const response = await getLatestShopeeSpecAutoMatchRun(); this.autoMatchRun.latest = response.data.run || null; if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer); if (this.autoMatchRun.latest?.status === 'running') this.autoMatchPollTimer = setTimeout(() => this.loadLatestAutoMatchRun(), 2000) },
async startAutoMatchRun() { await ElMessageBox.confirm('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', '执行规格自动匹配', { type: 'warning', confirmButtonText: '开始执行', cancelButtonText: '取消' }); this.autoMatchRun.submitting = true; try { const response = await startShopeeSpecAutoMatchRun({ requestId: createRequestId() }); this.autoMatchRun.latest = response.data.run; ElMessage.success(response.data.run.alreadyRunning ? '已有自动匹配正在运行' : '已开始后台匹配'); await this.loadLatestAutoMatchRun() } finally { this.autoMatchRun.submitting = false } },
applyRouteDetail() { const id = Number(this.$route.query.productId); if (!Number.isInteger(id) || id <= 0) return; this.detail = { open: true, productId: id, targetColor: String(this.$route.query.targetColor || '').trim(), action: String(this.$route.query.action || '') } },
async load() { this.loading = true; this.selectedProducts = []; this.$refs.productTable?.clearSelection(); try { const r = await listShopeeProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } }, search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; this.load() }, handleSelectionChange(rows) { this.selectedProducts = rows },
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` }, mappingMeta(product) { if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }; const values = (product.specs || []).flatMap(d => d.values); if (!values.length) return { label: '待完善', type: 'warning' }; const confirmed = values.filter(v => v.mapping?.status === 'confirmed').length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
@@ -45,5 +51,5 @@ export default {
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.run-summary{margin-left:10px;font-size:12px;color:#606266}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
</style>
+4 -4
View File
@@ -3,9 +3,9 @@
<template #wrapper>
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员可以立即同步今天和昨天的数据。</p></div>
<div><h1>SYB 同步记录</h1><p>查看每次 SYB 同步的进度、结果和按店铺统计。管理员和采购员可以立即同步今天和昨天的数据。</p></div>
<el-button
v-if="isAdmin"
v-if="canStartSync"
type="primary"
:loading="manualSyncStarting"
:disabled="manualSyncStarting || hasRunningSync"
@@ -86,7 +86,7 @@ export default {
}
},
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
hasRunningSync() { return this.items.some(item => item.status === 'running') }
},
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
@@ -109,7 +109,7 @@ export default {
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
},
async startManualSync() {
if (this.manualSyncStarting || this.hasRunningSync) return
if (!this.canStartSync || this.manualSyncStarting || this.hasRunningSync) return
const today = new Date()
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
this.manualSyncStarting = true
+24
View File
@@ -0,0 +1,24 @@
export function jobLogRoute(selectedIds) {
if (!Array.isArray(selectedIds) || selectedIds.length !== 1) return null
const jobId = Number(selectedIds[0])
if (!Number.isInteger(jobId) || jobId < 1) return null
return { name: 'JobLog', query: { jobId: String(jobId) }}
}
export function executionStatusMeta(status) {
return {
running: { label: '执行中', type: 'primary' },
succeeded: { label: '成功', type: 'success' },
failed: { label: '失败', type: 'danger' },
interrupted: { label: '已中断', type: 'warning' }
}[status] || { label: status || '未知', type: 'info' }
}
export function executionLogQuery(query, dateRange) {
const result = { ...query }
if (Array.isArray(dateRange) && dateRange.length === 2) {
result.startedFrom = new Date(dateRange[0]).toISOString()
result.startedTo = new Date(dateRange[1]).toISOString()
}
return result
}
+4 -2
View File
@@ -55,7 +55,7 @@
<el-button v-permisaction="['job:sysJob:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增</el-button>
<el-button v-permisaction="['job:sysJob:edit']" type="primary" size="small" :icon="Edit" :disabled="single" @click="handleUpdate">修改</el-button>
<el-button v-permisaction="['job:sysJob:remove']" type="danger" size="small" :icon="Delete" :disabled="multiple" @click="handleDelete">删除</el-button>
<el-button v-permisaction="['job:sysJob:log']" size="small" @click="handleLog">日志</el-button>
<el-button v-permisaction="['job:sysJob:log']" size="small" :disabled="single" @click="handleLog">日志</el-button>
</div>
<el-table v-loading="loading" :data="sysjobList" border stripe @selection-change="handleSelectionChange">
@@ -267,6 +267,7 @@
<script>
import { addSysJob, delSysJob, getSysJob, listSysJob, updateSysJob, removeJob, startJob } from '@/api/job/sys-job'
import { Search, Refresh, Plus, Edit, Delete } from '@element-plus/icons-vue'
import { jobLogRoute } from './execution-log'
export default {
name: 'SysJobManage',
@@ -500,7 +501,8 @@ export default {
}).catch(function() {})
},
handleLog() {
this.$router.push({ name: 'job_log', params: { }})
const route = jobLogRoute(this.ids)
if (route) this.$router.push(route)
},
handleSybSyncRuns() {
this.$router.push('/syb-sync-runs/index')
+86 -71
View File
@@ -1,101 +1,116 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<el-form>
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div>
<div class="eyebrow">定时任务 / 执行日志</div>
<h1>{{ job.jobName || '定时任务执行日志' }}<span v-if="job.jobId">(#{{ job.jobId }})</span></h1>
<p>查看该任务每次调度的开始时间、结果、耗时和脱敏错误摘要。</p>
</div>
<el-button :icon="ArrowLeft" @click="backToList">返回任务列表</el-button>
</div>
<el-alert v-if="job.deleted" title="该定时任务已删除,以下为保留的历史执行记录。" type="warning" show-icon :closable="false" class="notice" role="status" />
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部状态" style="width:150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开始时间">
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
</el-form-item>
<el-form-item>
<el-button type="success" size="mini">状态</el-button>
<el-button type="primary" size="mini">清空</el-button>
<el-button type="primary" :icon="Search" :disabled="!jobId" @click="search">查询</el-button>
<el-button :icon="RefreshLeft" :disabled="!jobId" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-row ref="log" :gutter="10" class="mb8">
<el-scrollbar style="height:500px;background-color: black;color: cornflowerblue;">
<ul
style="line-height: 25px;padding-top: 15px;padding-bottom: 15px;min-height: 500px; margin: 0;list-style-type: none;"
>
<li v-for="(item,index) in arrs" :key="index">
{{ item }}
</li>
</ul>
</el-scrollbar>
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice" role="alert">
<template #default><el-button v-if="jobId" link type="primary" @click="load">重新加载</el-button></template>
</el-alert>
<el-table v-loading="loading" :data="items" border stripe :empty-text="jobId ? '该任务尚无执行记录' : '请从定时任务列表选择一条任务'">
<el-table-column label="执行标识" prop="executionId" min-width="205" show-overflow-tooltip />
<el-table-column label="开始时间" min-width="175"><template #default="{ row }">{{ formatTime(row.startedAt) }}</template></el-table-column>
<el-table-column label="结束时间" min-width="175"><template #default="{ row }">{{ row.status === 'running' ? '执行中' : formatTime(row.finishedAt) }}</template></el-table-column>
<el-table-column label="状态" width="105"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
<el-table-column label="耗时" width="105"><template #default="{ row }">{{ row.status === 'running' ? '执行中' : formatDuration(row.durationMs) }}</template></el-table-column>
<el-table-column label="触发方式" width="110"><template #default="{ row }">{{ row.triggerType === 'scheduled' ? '定时调度' : row.triggerType }}</template></el-table-column>
<el-table-column label="错误摘要" min-width="260"><template #default="{ row }"><span v-if="row.errorCode" class="error-code">{{ row.errorCode }}</span>{{ row.errorMessage || '—' }}</template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
</template>
</BasicLayout>
</template>
<script>
import { ArrowLeft, RefreshLeft, Search } from '@element-plus/icons-vue'
import { listJobExecutionLogs } from '@/api/job/sys-job'
import { executionLogQuery, executionStatusMeta } from './execution-log'
import { unWsLogout } from '@/api/ws'
export default {
name: 'SysJobLogManage',
setup() { return { ArrowLeft, RefreshLeft, Search } },
data() {
return {
websock: null,
arrs: [],
id: undefined,
group: undefined
loading: false, loadError: '', job: {}, items: [], total: 0, dateRange: null, pollTimer: null,
query: { pageIndex: 1, pageSize: 20, status: '' },
statusOptions: [
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
]
}
},
computed: {
jobId() {
const value = Number(this.$route.query.jobId)
return Number.isInteger(value) && value > 0 ? value : 0
}
},
created() {
this.id = this.guid()
this.group = 'log'
this.initWebSocket()
},
unmounted() {
console.log('断开websocket连接')
this.websock.close() // 离开路由之后断开websocket连接
unWsLogout(this.id, this.group).then(response => {
console.log(response.data)
if (!this.jobId) {
this.loadError = '缺少有效的定时任务编号,请返回列表重新选择。'
return
}
)
this.load()
},
beforeUnmount() { this.stopPolling() },
methods: {
initWebSocket() { // 初始化weosocket
console.log(this.$store.state.user.token)
const wsuri = 'ws://127.0.0.1:8000/ws/' + this.id + '/' + this.group + '?token=' + this.$store.state.user.token
this.websock = new WebSocket(wsuri)
this.websock.onmessage = this.websocketonmessage
this.websock.onopen = this.websocketonopen
this.websock.onerror = this.websocketonerror
this.websock.onclose = this.websocketclose
statusMeta: executionStatusMeta,
formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
formatDuration(value) {
const ms = Number(value) || 0
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`
},
websocketonopen() { // 连接建立之后执行send方法发送数据
console.log('连接打开')
// const actions = { 'test': '12345' }
// this.websocketsend(JSON.stringify(actions))
},
websocketonerror() { // 连接建立失败重连
this.initWebSocket()
},
websocketonmessage(e) { // 数据接收
console.log(e.data)
// console.log(this.binaryAgent(e))
// const redata = JSON.parse(e.data)
// console.log(redata)
// this.$refs.log.innerText = e.data + '\n' + this.$refs.log.innerText
this.arrs.unshift(e.data)
},
websocketsend(Data) { // 数据发送
// this.websock.send(Data)
},
websocketclose(e) { // 关闭
unWsLogout(this.id, this.group).then(response => {
console.log(response.data)
async load() {
if (!this.jobId) return
this.loading = true
this.loadError = ''
try {
const response = await listJobExecutionLogs(this.jobId, executionLogQuery(this.query, this.dateRange))
this.job = response.data.job
this.items = response.data.items
this.total = response.data.total
this.items.some(item => item.status === 'running') ? this.startPolling() : this.stopPolling()
} catch (error) {
this.loadError = error?.response?.data?.msg || error?.message || '执行日志加载失败'
} finally {
this.loading = false
}
)
console.log('断开连接', e)
},
guid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0; var v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
search() { this.query.pageIndex = 1; this.load() },
reset() { this.dateRange = null; this.query = { pageIndex: 1, pageSize: 20, status: '' }; this.load() },
backToList() { this.$router.push('/schedule/manage') },
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(() => this.load(), 5000) },
stopPolling() { if (this.pollTimer) { window.clearInterval(this.pollTimer); this.pollTimer = null } }
}
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.page-heading h1{margin:4px 0 6px;color:var(--el-text-color-primary);font-size:24px}.page-heading p{margin:0;color:var(--el-text-color-regular);line-height:1.5}.eyebrow{color:var(--el-text-color-secondary);font-size:13px}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid var(--el-border-color);border-radius:8px;background:var(--el-fill-color-lighter)}.notice{margin-bottom:16px}.error-code{display:inline-block;margin-right:8px;color:var(--el-color-danger);font-family:Consolas,monospace;font-size:12px}@media(max-width:800px){.page-heading{flex-direction:column}.search-form :deep(.el-date-editor){width:100%}}
</style>
@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test'
async function authenticate(context: any) {
await context.addCookies([{ name: 'Admin-Token', value: 'job-log-test-token', domain: 'localhost', path: '/' }])
}
test('单选定时任务后进入对应的持久化执行日志', async ({ page, context }) => {
await authenticate(context)
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/getinfo')) {
return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: ['job:sysJob:log'] } } })
}
if (url.pathname.endsWith('/api/v1/menurole')) {
return route.fulfill({ json: { code: 200, data: [{ path: '/schedule', component: 'Layout', visible: '0', menuName: 'Schedule', title: '定时任务', icon: 'time', children: [{ path: 'manage', component: '/schedule/index', visible: '0', menuName: 'ScheduleManage', title: '定时任务' }, { path: 'log', component: '/schedule/log', visible: '1', menuName: 'JobLog', title: '执行日志' }] }] } })
}
if (url.pathname.endsWith('/api/v1/sysjob/5/execution-logs')) {
return route.fulfill({ json: { code: 200, data: { job: { jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', deleted: false }, items: [{ id: 21, executionId: '00000000-0000-4000-8000-000000000021', jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', triggerType: 'scheduled', status: 'failed', startedAt: '2026-09-02T01:00:00Z', finishedAt: '2026-09-02T01:00:03Z', durationMs: 3000, errorCode: 'JOB_EXECUTION_FAILED', errorMessage: '任务执行失败,请查看受控服务日志' }], total: 1, page: 1, pageSize: 20 } } })
}
if (url.pathname.endsWith('/api/v1/sysjob')) {
return route.fulfill({ json: { code: 200, data: { list: [{ jobId: 5, jobName: 'SYB 规格 AI 修复', jobGroup: 'GoAuto', cronExpression: '0 0 * * * *', invokeTarget: 'GoAutoSYBSpecAIParse', status: 2, entry_id: 0 }], count: 1 } } })
}
return route.fulfill({ json: { code: 200, data: [] } })
})
await page.goto('/#/schedule/manage')
const logButton = page.getByRole('button', { name: '日志', exact: true })
await expect(page.getByText('SYB 规格 AI 修复', { exact: true })).toBeVisible()
await expect(logButton).toBeDisabled()
await page.locator('.el-table__body-wrapper .el-checkbox').first().click()
await expect(logButton).toBeEnabled()
await logButton.click()
await expect(page).toHaveURL(/#\/schedule\/log\?jobId=5$/)
await expect(page.getByRole('heading', { name: 'SYB 规格 AI 修复(#5)' })).toBeVisible()
await expect(page.getByText('JOB_EXECUTION_FAILED')).toBeVisible()
await expect(page.getByText('任务执行失败,请查看受控服务日志')).toBeVisible()
})
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test'
const routes = [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/shopee-products', component: '/goauto/shopee-products/index', menuName: 'GoAutoShopeeProducts', title: '虾皮商品', visible: '0' }] }]
test('管理员可确认并手动启动规格自动匹配且查看摘要', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let started = false
let requestBody: Record<string, unknown> | null = null
await page.route('**/api/**', async route => {
const url = new URL(route.request().url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 }}})
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs/latest')) {
const run = started ? { id: 8, status: 'completed', processedCount: 12, confirmedCount: 9, unmatchedCount: 3 } : null
return route.fulfill({ json: { code: 200, data: { run }}})
}
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs')) {
requestBody = route.request().postDataJSON()
started = true
return route.fulfill({ status: 202, json: { code: 200, data: { run: { id: 8, status: 'running', processedCount: 0 }}}})
}
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/shopee-products')
await page.getByRole('button', { name: '执行规格自动匹配', exact: true }).click()
await expect(page.getByText('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '开始执行', exact: true }).click()
await expect(page.getByText('最近一次:完成,处理 12,确认 9,未匹配 3', { exact: true })).toBeVisible()
expect(String(requestBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
})
+42 -8
View File
@@ -4,6 +4,8 @@ async function authenticate(context: any) {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
}
const syncMenu = [{ path: '/syb-sync-runs', component: 'Layout', visible: '0', menuName: 'SybSync', title: 'SYB 同步', children: [{ path: 'index', component: '/goauto/syb-sync-runs/index', visible: '0', menuName: 'GoAutoSybSyncRuns', title: 'SYB 同步记录' }] }];
test('同步记录展示失败原因和按店铺统计', async ({ page, context }) => {
await authenticate(context);
await page.route('**/api/**', async route => {
@@ -12,12 +14,12 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs/51')) return route.fulfill({ json: { code: 200, data: { item: { id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, progressPercent: 66, orderCount: 12, detailCount: 8, acceptedCount: 10, shopSkipped: 2, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z', finishedAt: '2026-08-20T01:05:00Z', errorMessage: '第 3 天读取失败,等待下个调度周期重试', shopFilterHash: 'abc123', shopBreakdown: [{ shopName: '大碼臻選', accepted: 10, skipped: 0 }, { shopName: '未知店铺', accepted: 0, skipped: 2 }] } } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 51, dateFrom: '2026-08-18', dateTo: '2026-08-20', status: 'failed', daysProcessed: 2, daysTotal: 3, orderCount: 12, detailCount: 8, created: 5, updated: 3, operatorName: '系统定时同步', startedAt: '2026-08-20T01:00:00Z' }], total: 1, page: 1, pageSize: 20 } } });
return route.fulfill({ json: { code: 200, data: [] } });
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
});
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
await page.goto('/#/syb-sync-runs/index');
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
await expect(page.getByRole('button', { name: '立即同步' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即同步' })).toBeVisible();
await expect(page.getByText('2026-08-18 至 2026-08-20')).toBeVisible();
await page.getByRole('button', { name: '详情' }).click();
await expect(page.getByText('第 3 天读取失败,等待下个调度周期重试')).toBeVisible();
@@ -26,14 +28,15 @@ test('同步记录展示失败原因和按店铺统计', async ({ page, context
await expect(page.getByText('系统定时同步', { exact: true }).first()).toBeVisible();
});
test('管理员可立即同步今天和昨天,受理后按钮进入运行中状态', async ({ page, context }) => {
for (const role of ['admin', 'purchaser']) {
test(`${role} 可立即同步今天和昨天,受理后按钮进入运行中状态`, async ({ page, context }) => {
await authenticate(context);
let importBody: any = null;
let importAccepted = false;
await page.route('**/api/**', async route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/src/api/')) return route.continue();
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/import') && route.request().method() === 'POST') {
importBody = route.request().postDataJSON();
await new Promise(resolve => setTimeout(resolve, 200));
@@ -44,10 +47,10 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
const items = importAccepted ? [{ id: 88, dateFrom: importBody.dateFrom, dateTo: importBody.dateTo, status: 'running', progressPercent: 0, daysProcessed: 0, daysTotal: 2, orderCount: 0, detailCount: 0, created: 0, updated: 0, operatorName: '管理员', startedAt: '2026-08-24T08:00:00Z' }] : [];
return route.fulfill({ json: { code: 200, data: { items, total: items.length, page: 1, pageSize: 20 } } });
}
return route.fulfill({ json: { code: 200, data: [] } });
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
});
await page.goto('http://localhost:9527/#/syb-sync-runs/index');
await page.goto('/#/syb-sync-runs/index');
const button = page.getByRole('button', { name: '立即同步' });
await expect(button).toBeVisible();
await button.click();
@@ -60,6 +63,37 @@ test('管理员可立即同步今天和昨天,受理后按钮进入运行中
const formatDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
expect(importBody).toEqual({ dateFrom: formatDate(yesterday), dateTo: formatDate(now) });
});
}
for (const role of ['purchaser', 'viewer']) {
test(`${role} 同步权限及失败恢复`, async ({ page, context }) => {
await authenticate(context);
let requests = 0;
await page.route('**/api/**', async route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/src/api/')) return route.continue();
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: [role], name: role, avatar: '', permissions: [] } } });
if (url.pathname.endsWith('/api/admin/v1/syb-products/import')) {
requests++;
return route.fulfill({ status: 422, json: { code: 'INVALID_REQUEST', message: '已有同步正在执行,请稍后再试' } });
}
if (url.pathname.endsWith('/api/admin/v1/syb-products/sync-runs')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 } } });
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/api/v1/menurole') ? syncMenu : [] } });
});
await page.goto('/#/syb-sync-runs/index');
await expect(page.getByRole('heading', { name: 'SYB 同步记录' })).toBeVisible();
const button = page.getByRole('button', { name: '立即同步' });
if (role === 'viewer') {
await expect(button).toHaveCount(0);
expect(requests).toBe(0);
} else {
await button.click();
await expect(page.getByText('已有同步正在执行,请稍后再试')).toBeVisible();
await expect(button).toBeEnabled();
expect(requests).toBe(1);
}
});
}
test('SYB 定时任务可进入执行记录,其他任务不显示该入口', async ({ page, context }) => {
await authenticate(context);
@@ -73,7 +107,7 @@ test('SYB 定时任务可进入执行记录,其他任务不显示该入口', a
return route.fulfill({ json: { code: 200, data: [] } });
});
await page.goto('http://localhost:9527/#/schedule/index');
await page.goto('/#/schedule/index');
await expect(page.getByText('SYB 每小时自动同步', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '执行记录', exact: true })).toHaveCount(1);
await page.getByRole('button', { name: '执行记录', exact: true }).click();
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>#237 客户端密钥隔离测试</title></head><body><p>仅本地组件测试:全部请求由内存适配器处理,不连接业务服务,不创建真实密钥。</p><div id="app"></div><script type="module" src="./client-keys.js"></script></body></html>
+31
View File
@@ -0,0 +1,31 @@
// Manual browser fixture for the production component, not an offline prototype.
import { createApp, h } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Page from '../../src/views/goauto/client-keys/index.vue'
import request from '../../src/utils/request'
const definitions = [
['syb_products', 'SYB 商品', true, ['reparse']], ['syb_sync_runs', 'SYB 同步记录', false, ['sync']],
['syb_inner_codes', '档口入库码', false, ['import', 'match', 'writeback', 'delete']], ['shopee_products', '虾皮商品', true, ['match', 'delete']],
['pdd_products', 'PDD 商品', true, []], ['collection_tasks', '采集任务', false, ['collect', 'delete']], ['purchase_tasks', '采购管理', false, ['purchase']],
['syb_shops', 'SYB 店铺', true, ['delete']], ['collection_rules', '采集规则', true, ['delete']], ['purchase_rules', '采购规则', true, ['delete', 'activate']], ['devices', '设备列表', false, []], ['ai_matching', 'AI 规格匹配', false, ['match']]
]
const modules = definitions.map(([key, title, writable, actions], index) => ({ key, title, writable, actions, group: index < 7 ? '采集采购' : '采采管理' }))
let items = [{ id: 1, name: '商品同步工具(测试)', prefix: 'gak_DEMO', version: 1, enabled: true, updatedBy: 1, updatedAt: '2026-09-07T00:00:00Z', grants: [{ module: 'pdd_products', write: true, actions: [] }] }]
request.defaults.adapter = async config => {
let data
const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data
if (config.url.endsWith('/modules')) data = modules
else if (config.method === 'get') data = { items: structuredClone(items), total: items.length }
else if (config.url.endsWith('/grants')) { items[0].grants = body.grants; items[0].version++; data = items[0] } else if (config.url.endsWith('/disable')) { items[0].enabled = false; items[0].version++; data = items[0] } else if (config.method === 'post' && config.url === '/api/admin/v1/client-keys') {
const key = { id: 2, name: body.name, prefix: 'gak_DEMO_2', enabled: true, version: 1, grants: body.grants }
items = [key, ...items]; data = { key, secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }
} else throw new Error('Unexpected fixture request')
return { status: 200, statusText: 'OK', headers: {}, config, data: { code: 200, data }}
}
const app = createApp(Page)
app.use(ElementPlus)
app.config.globalProperties.$store = { getters: { roles: ['admin'] }}
app.component('BasicLayout', { setup(_, { slots }) { return () => h('main', { style: 'padding:24px;background:#f3f6fa;min-height:90vh;font-family:Arial,sans-serif' }, slots.wrapper?.()) } })
app.mount('#app')
+61
View File
@@ -0,0 +1,61 @@
import Page from '@/views/goauto/client-keys/index.vue'
import { createClientKey, editClientKey, listClientKeys, clientKeyModules } from '@/api/goauto/client-keys'
jest.mock('@/api/goauto/client-keys', () => ({ createClientKey: jest.fn(), editClientKey: jest.fn(), disableClientKey: jest.fn(), listClientKeys: jest.fn(), clientKeyModules: jest.fn() }))
jest.mock('element-plus', () => ({ ElMessage: { success: jest.fn(), error: jest.fn(), warning: jest.fn() }, ElMessageBox: { confirm: jest.fn() }}))
function vm() {
const value = { ...Page.data(), $store: { getters: { roles: ['admin'] }}}
for (const [name, fn] of Object.entries(Page.methods)) value[name] = fn.bind(value)
for (const [name, fn] of Object.entries(Page.computed)) Object.defineProperty(value, name, { get: fn.bind(value) })
return value
}
const row = () => ({ id: 12, version: 2, name: 'Tool', prefix: 'masked', enabled: true, grants: [{ module: 'pdd_products', write: false, actions: [] }] })
beforeEach(() => { jest.clearAllMocks(); listClientKeys.mockResolvedValue({ data: { items: [], total: 0 }}); clientKeyModules.mockResolvedValue({ data: [] }) })
test('edit prefill and cancel never change the existing row or create a key', () => {
const p = vm(); const original = row(); p.openEditor(original)
expect(p.changed).toBe(false)
p.grants.pdd_products.write = true; expect(p.changed).toBe(true)
p.closeEditor()
expect(original.grants[0].write).toBe(false)
expect(editClientKey).not.toHaveBeenCalled(); expect(createClientKey).not.toHaveBeenCalled()
})
test('module group selection defaults read-only and removing a module removes actions', () => {
const p = vm(); p.selectGroup({ modules: [{ key: 'one' }, { key: 'two' }] }, true)
expect(p.grants.one).toEqual({ module: 'one', write: false, actions: [] })
p.grants.one.actions.push('delete'); p.selectModule('one', false); p.selectModule('one', true)
expect(p.grants.one.actions).toEqual([])
})
test('edit saves same ID with version, does not issue a new key', async() => {
const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true
editClientKey.mockResolvedValue({ data: {}}); await p.save()
expect(editClientKey).toHaveBeenCalledWith(12, { version: 2, grants: [{ module: 'pdd_products', write: true, actions: [] }] })
expect(createClientKey).not.toHaveBeenCalled(); expect(p.secret).toBe(''); expect(p.editorOpen).toBe(false)
})
test('failure preserves changes and conflict prevents blind retry', async() => {
const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true
editClientKey.mockRejectedValue({ response: { status: 409, data: { message: 'conflict' }}})
await p.save(); expect(p.editorOpen).toBe(true); expect(p.grants.pdd_products.write).toBe(true); expect(p.conflicted).toBe(true)
await p.save(); expect(editClientKey).toHaveBeenCalledTimes(1)
})
test('disabled keys are read-only and no modules cannot be saved', async() => {
const p = vm(); p.openEditor({ ...row(), enabled: false }); expect(p.editorOpen).toBe(false)
p.openEditor({ ...row(), enabled: false }, true); expect(p.readonly).toBe(true)
p.openEditor(row()); p.selectModule('pdd_products', false); await p.save(); expect(editClientKey).not.toHaveBeenCalled()
})
test('secret is cleared when dialog or cached view closes', () => {
const p = vm(); p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.clearSecret(); expect(p.secret).toBe('')
p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.secretOpen = true; Page.deactivated.call(p); expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
})
test('ordinary users do not load or open management', async() => {
const p = vm(); p.$store.getters.roles = ['purchaser']; await p.load(); p.openEditor(); expect(listClientKeys).not.toHaveBeenCalled(); expect(p.editorOpen).toBe(false)
})
test('late creation response after navigation does not retain a secret', async() => {
const p = vm(); p.name = 'test'; p.selectModule('pdd_products', true)
let resolve
createClientKey.mockImplementation(() => new Promise(done => { resolve = done }))
const pending = p.save(); Page.deactivated.call(p)
resolve({ data: { secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }}); await pending
expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false)
})
@@ -0,0 +1,26 @@
import { executionLogQuery, executionStatusMeta, jobLogRoute } from '@/views/schedule/execution-log'
describe('scheduled job execution log helpers', () => {
test('only one valid selection can navigate to JobLog', () => {
expect(jobLogRoute([])).toBeNull()
expect(jobLogRoute([1, 2])).toBeNull()
expect(jobLogRoute(['invalid'])).toBeNull()
expect(jobLogRoute([5])).toEqual({ name: 'JobLog', query: { jobId: '5' }})
})
test('builds RFC3339 date filters without mutating paging', () => {
const from = new Date('2026-09-02T10:00:00+08:00')
const to = new Date('2026-09-02T11:00:00+08:00')
expect(executionLogQuery({ pageIndex: 2, pageSize: 20, status: 'failed' }, [from, to])).toEqual({
pageIndex: 2, pageSize: 20, status: 'failed',
startedFrom: from.toISOString(), startedTo: to.toISOString()
})
})
test('maps all persisted statuses', () => {
expect(executionStatusMeta('running').label).toBe('执行中')
expect(executionStatusMeta('succeeded').type).toBe('success')
expect(executionStatusMeta('failed').type).toBe('danger')
expect(executionStatusMeta('interrupted').label).toBe('已中断')
})
})