From dce4a4fc2a46d577b7b3fc2a692469668e0c268d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 1 Sep 2026 00:51:29 +0800 Subject: [PATCH] fix(web): support request IDs over HTTP (#182) --- web/src/utils/request-id.js | 34 +++++++++++++++++++ .../views/goauto/collection-rules/index.vue | 5 +-- .../views/goauto/collection-tasks/index.vue | 7 ++-- web/src/views/goauto/devices/index.vue | 3 +- web/src/views/goauto/pdd-products/index.vue | 11 +++--- web/src/views/goauto/purchase-rules/index.vue | 7 ++-- web/src/views/goauto/purchase-tasks/index.vue | 3 +- .../views/goauto/shopee-products/index.vue | 23 +++++++------ .../views/goauto/syb-inner-codes/index.vue | 9 ++--- web/src/views/goauto/syb-products/index.vue | 5 +-- web/tests/unit/utils/request-id.spec.js | 28 +++++++++++++++ 11 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 web/src/utils/request-id.js create mode 100644 web/tests/unit/utils/request-id.spec.js diff --git a/web/src/utils/request-id.js b/web/src/utils/request-id.js new file mode 100644 index 0000000..ecf5631 --- /dev/null +++ b/web/src/utils/request-id.js @@ -0,0 +1,34 @@ +function fillWithPseudoRandomBytes(bytes) { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256) + } + return bytes +} + +function formatUuidV4(bytes) { + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +function browserCrypto() { + return typeof window === 'undefined' ? undefined : window.crypto +} + +export function createRequestId(cryptoApi = browserCrypto()) { + if (typeof cryptoApi?.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + + const bytes = new Uint8Array(16) + if (typeof cryptoApi?.getRandomValues === 'function') { + cryptoApi.getRandomValues(bytes) + } else { + // requestId is an idempotency key, not a secret. Public HTTP contexts can + // expose neither Web Crypto method, so retain a UUID-shaped local fallback. + fillWithPseudoRandomBytes(bytes) + } + return formatUuidV4(bytes) +} diff --git a/web/src/views/goauto/collection-rules/index.vue b/web/src/views/goauto/collection-rules/index.vue index 58a337f..13e01ab 100644 --- a/web/src/views/goauto/collection-rules/index.vue +++ b/web/src/views/goauto/collection-rules/index.vue @@ -99,6 +99,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue' import { createCollectionRule, deleteCollectionRule, getCollectionRuleTemplate, listCollectionRules, updateCollectionRule } from '@/api/goauto/collection-rules' +import { createRequestId } from '@/utils/request-id' const defaultConstraints = { browserPackages: ['com.heytap.browser', 'com.android.chrome', 'com.android.browser'], hookDirections: ['up', 'down', 'left', 'right'], @@ -162,8 +163,8 @@ export default { summary(content) { if (content.schemaVersion !== 2) { const text = JSON.stringify(content); return text.length > 100 ? `${text.slice(0, 100)}…` : text } const aliases = content.collector?.dimensionAliases; const hookCount = content.hooks?.afterSpecPanelOpen?.[0]?.count || 0; const recovery = content.pageRecovery?.transientSoldOut?.enabled ? '假售罄恢复已启用' : '假售罄恢复关闭'; const navigationRecovery = content.navigationRecovery?.reopenBrowser?.enabled ? '入口恢复已启用' : '入口恢复关闭'; return `颜色别名 ${aliases?.color?.length || 0} · 尺码别名 ${aliases?.size?.length || 0} · 附加滑动 ${hookCount} 次 · ${recovery} · ${navigationRecovery}` }, async openCreate() { this.dialog = { open: true, loading: true, saving: false, ruleId: null }; try { await this.ensureTemplate(); this.fillV2(this.serverTemplate, 'PDD 商品详情采集') } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } }, async openEdit(row) { this.dialog = { open: true, loading: true, saving: false, ruleId: row.id }; try { if (row.content.schemaVersion === 2) { await this.ensureTemplate(); this.fillV2(row.content, row.name) } else { this.contentBase = null; this.form = { ...this.emptyForm(), mode: 'v1', name: row.name, contentText: JSON.stringify(row.content, null, 2) } } } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } }, - async save() { const valid = await this.$refs.ruleForm.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const content = this.form.mode === 'v2' ? this.buildV2Content() : JSON.parse(this.form.contentText); const payload = { requestId: window.crypto.randomUUID(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updateCollectionRule(this.dialog.ruleId, payload); else await createCollectionRule(payload); ElMessage.success(this.dialog.ruleId ? '规则已更新,新任务将使用新内容' : '规则已创建并立即生效'); this.dialog.open = false; await this.getList() } finally { this.dialog.saving = false } }, - async remove(row) { await ElMessageBox.confirm(`删除“${row.name}”后不能再用于创建新任务,已有任务不受影响。`, '确认删除规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deleteCollectionRule(row.id, { requestId: window.crypto.randomUUID() }); ElMessage.success('规则已删除'); await this.getList() } + async save() { const valid = await this.$refs.ruleForm.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const content = this.form.mode === 'v2' ? this.buildV2Content() : JSON.parse(this.form.contentText); const payload = { requestId: createRequestId(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updateCollectionRule(this.dialog.ruleId, payload); else await createCollectionRule(payload); ElMessage.success(this.dialog.ruleId ? '规则已更新,新任务将使用新内容' : '规则已创建并立即生效'); this.dialog.open = false; await this.getList() } finally { this.dialog.saving = false } }, + async remove(row) { await ElMessageBox.confirm(`删除“${row.name}”后不能再用于创建新任务,已有任务不受影响。`, '确认删除规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deleteCollectionRule(row.id, { requestId: createRequestId() }); ElMessage.success('规则已删除'); await this.getList() } } } diff --git a/web/src/views/goauto/collection-tasks/index.vue b/web/src/views/goauto/collection-tasks/index.vue index d79ba0b..b99841f 100644 --- a/web/src/views/goauto/collection-tasks/index.vue +++ b/web/src/views/goauto/collection-tasks/index.vue @@ -76,6 +76,7 @@ import { createCollectionTask, deleteCollectionTask, getCollectionTask, listColl import { listPddProducts } from '@/api/goauto/pdd-products' import { listCollectionRules } from '@/api/goauto/collection-rules' import { listDevices } from '@/api/goauto/devices' +import { createRequestId } from '@/utils/request-id' export default { name: 'GoAutoCollectionTasks', setup() { return { Plus, RefreshLeft, Search } }, @@ -88,10 +89,10 @@ export default { isTerminal(value) { return ['completed', 'completed_partial', 'failed'].includes(value) }, specsText(specs) { return Object.entries(specs || {}).map(([key, value]) => `${key}: ${value}`).join(' / ') }, formatRuleSnapshot(value) { try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2) } catch (_) { return String(value || '') } }, async openCreate() { this.form = { pddProductId: null, ruleId: null, deviceId: null }; this.createDialog.open = true; const [p, r, d] = await Promise.all([listPddProducts({ page: 1, pageSize: 100 }), listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })]); this.options = { products: p.data.items, rules: r.data.items, devices: d.data.items }; this.$nextTick(() => this.$refs.taskForm?.clearValidate()) }, - async createTask() { if (!await this.$refs.taskForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { await createCollectionTask({ requestId: window.crypto.randomUUID(), pddProductId: this.form.pddProductId, ruleId: this.form.ruleId, deviceId: this.form.deviceId || null }); ElMessage.success('任务已创建'); this.createDialog.open = false; await this.getList() } finally { this.createDialog.saving = false } }, + async createTask() { if (!await this.$refs.taskForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { await createCollectionTask({ requestId: createRequestId(), pddProductId: this.form.pddProductId, ruleId: this.form.ruleId, deviceId: this.form.deviceId || null }); ElMessage.success('任务已创建'); this.createDialog.open = false; await this.getList() } finally { this.createDialog.saving = false } }, async showDetail(row) { this.detail = { open: true, loading: true, data: null }; try { const r = await getCollectionTask(row.id); this.detail.data = r.data } finally { this.detail.loading = false } }, - async confirmReset(row) { await ElMessageBox.confirm('将复用原任务并按最新有效规则重新采集;当前结果会归档到历史执行记录,不会创建新任务。是否继续?', '重新采集', { type: 'warning' }); const response = await resetCollectionTask(row.id, { requestId: window.crypto.randomUUID() }); ElMessage.success(`任务已进入第 ${response.data.attemptNumber || 1} 次采集`); await this.getList() }, - async confirmDelete(row) { await ElMessageBox.confirm('仅删除这条失败任务,是否继续?', '删除失败任务', { type: 'warning' }); await deleteCollectionTask(row.id, { requestId: window.crypto.randomUUID() }); ElMessage.success('失败任务已删除'); await this.getList() } + async confirmReset(row) { await ElMessageBox.confirm('将复用原任务并按最新有效规则重新采集;当前结果会归档到历史执行记录,不会创建新任务。是否继续?', '重新采集', { type: 'warning' }); const response = await resetCollectionTask(row.id, { requestId: createRequestId() }); ElMessage.success(`任务已进入第 ${response.data.attemptNumber || 1} 次采集`); await this.getList() }, + async confirmDelete(row) { await ElMessageBox.confirm('仅删除这条失败任务,是否继续?', '删除失败任务', { type: 'warning' }); await deleteCollectionTask(row.id, { requestId: createRequestId() }); ElMessage.success('失败任务已删除'); await this.getList() } } } diff --git a/web/src/views/goauto/devices/index.vue b/web/src/views/goauto/devices/index.vue index a0e9990..23889d3 100644 --- a/web/src/views/goauto/devices/index.vue +++ b/web/src/views/goauto/devices/index.vue @@ -118,6 +118,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue' import { disableDevice, listDevices, revokeDeviceToken } from '@/api/goauto/devices' import { downloadAgentAppRelease, listAgentAppReleases, setCurrentAgentAppRelease, uploadAgentAppRelease } from '@/api/goauto/agent-app-releases' +import { createRequestId } from '@/utils/request-id' export default { name: 'GoAutoDeviceList', @@ -168,7 +169,7 @@ export default { async loadReleases() { this.releaseDrawer.loading = true; try { const response = await listAgentAppReleases({ page: 1, pageSize: 100 }); this.releaseDrawer.items = response.data.items } finally { this.releaseDrawer.loading = false } }, onAPKChange(file) { this.releaseDrawer.file = file.raw }, onAPKRemove() { this.releaseDrawer.file = null }, async uploadRelease() { if (!this.releaseDrawer.file) return; const form = new FormData(); form.append('file', this.releaseDrawer.file); form.append('releaseNotes', this.releaseDrawer.notes.trim()); this.releaseDrawer.uploading = true; this.releaseDrawer.progress = 0; try { await uploadAgentAppRelease(form, event => { if (event.total) this.releaseDrawer.progress = Math.round(event.loaded * 100 / event.total) }); ElMessage.success('Agent APK 已上传,请核对后设为当前版本'); this.releaseDrawer.file = null; this.releaseDrawer.notes = ''; await this.loadReleases() } finally { this.releaseDrawer.uploading = false } }, - async activateRelease(row) { await ElMessageBox.confirm(`设为当前版本 ${row.versionName}(${row.versionCode})?Agent 检查更新后将看到此版本。`, '发布 Agent 版本', { type: 'warning', confirmButtonText: '设为当前', cancelButtonText: '取消' }); await setCurrentAgentAppRelease({ requestId: crypto.randomUUID(), releaseId: row.id }); ElMessage.success('当前 Agent 版本已更新'); await this.loadReleases() }, + async activateRelease(row) { await ElMessageBox.confirm(`设为当前版本 ${row.versionName}(${row.versionCode})?Agent 检查更新后将看到此版本。`, '发布 Agent 版本', { type: 'warning', confirmButtonText: '设为当前', cancelButtonText: '取消' }); await setCurrentAgentAppRelease({ requestId: createRequestId(), releaseId: row.id }); ElMessage.success('当前 Agent 版本已更新'); await this.loadReleases() }, async downloadRelease(row) { const response = await downloadAgentAppRelease(row.id); const url = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = url; link.download = `agent-${row.versionCode}.apk`; link.click(); URL.revokeObjectURL(url) }, formatBytes(bytes) { return bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB` }, async confirmDisable(row) { diff --git a/web/src/views/goauto/pdd-products/index.vue b/web/src/views/goauto/pdd-products/index.vue index ff4ad35..8c0e25f 100644 --- a/web/src/views/goauto/pdd-products/index.vue +++ b/web/src/views/goauto/pdd-products/index.vue @@ -134,6 +134,7 @@ import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks' import { listCollectionRules } from '@/api/goauto/collection-rules' import { listDevices } from '@/api/goauto/devices' import { createPurchaseTasksBatch, createStockPurchaseTask, previewPurchaseTasks } from '@/api/goauto/purchase-tasks' +import { createRequestId } from '@/utils/request-id' let uid = 0 export default { @@ -232,7 +233,7 @@ export default { if (!await this.$refs.batchForm.validate().catch(() => false)) return this.batch.saving = true try { - const response = await batchCreateCollectionTasks({ requestId: crypto.randomUUID(), pddProductIds: this.batch.products.map(item => item.id), ruleId: this.batchData.ruleId, deviceId: this.batchData.deviceId || null }) + const response = await batchCreateCollectionTasks({ requestId: createRequestId(), pddProductIds: this.batch.products.map(item => item.id), ruleId: this.batchData.ruleId, deviceId: this.batchData.deviceId || null }) const byID = new Map(this.batch.products.map(item => [item.id, item])) this.batch.results = response.data.items.map(item => ({ ...byID.get(item.pddProductId), ...item })) this.batch.successCount = response.data.successCount @@ -249,11 +250,11 @@ export default { } }, openCreate() { this.createDialog = { open: true, saving: false }; this.createData = { url: '' }; this.$nextTick(() => this.$refs.createForm?.clearValidate()) }, - async createProduct() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const r = await createPddProduct({ requestId: crypto.randomUUID(), url: this.createData.url.trim() }); ElMessage.success('PDD 商品已添加'); this.createDialog.open = false; await this.load(); await this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } }, + async createProduct() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const r = await createPddProduct({ requestId: createRequestId(), url: this.createData.url.trim() }); ElMessage.success('PDD 商品已添加'); this.createDialog.open = false; await this.load(); await this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } }, async openDetail(id) { this.detail = { open: true, loading: true, product: null, relatedShopee: [] }; this.related = this.emptyRelated(); try { const r = await getPddProduct(id); this.detail.product = r.data.product; this.detail.relatedShopee = r.data.relatedShopeeProducts || []; await this.loadRelated() } finally { this.detail.loading = false } }, async openRelatedPurchase() { const candidates = this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)); if (!candidates.length) return; const ids = candidates.map(row => row.sybProductId); this.relatedPurchase = { ...this.emptyRelatedPurchase(), open: true, loading: true, ids }; try { const [preview, devices] = await Promise.all([previewPurchaseTasks({ sybProductIds: ids }), listDevices({ page: 1, pageSize: 100, status: 'online' })]); this.relatedPurchase.items = preview.data.items; this.relatedPurchase.eligibleCount = preview.data.eligibleCount; this.relatedPurchase.devices = devices.data.items.filter(item => item.selectable) } finally { this.relatedPurchase.loading = false } }, async refreshRelatedPurchase() { this.relatedPurchase.loading = true; try { const r = await previewPurchaseTasks({ sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); this.relatedPurchase.items = r.data.items; this.relatedPurchase.eligibleCount = r.data.eligibleCount } finally { this.relatedPurchase.loading = false } }, - async submitRelatedPurchase() { this.relatedPurchase.saving = true; try { const r = await createPurchaseTasksBatch({ requestId: crypto.randomUUID(), sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); const failed = r.data.failedCount || 0; if (failed) ElMessage.warning(`已创建 ${r.data.createdCount} 个,${failed} 个未创建`); else ElMessage.success(`已创建 ${r.data.createdCount} 个采购任务`); this.relatedPurchase.open = false; await this.loadRelated() } finally { this.relatedPurchase.saving = false } }, + async submitRelatedPurchase() { this.relatedPurchase.saving = true; try { const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.relatedPurchase.ids, deviceId: this.relatedPurchase.deviceId || undefined }); const failed = r.data.failedCount || 0; if (failed) ElMessage.warning(`已创建 ${r.data.createdCount} 个,${failed} 个未创建`); else ElMessage.success(`已创建 ${r.data.createdCount} 个采购任务`); this.relatedPurchase.open = false; await this.loadRelated() } finally { this.relatedPurchase.saving = false } }, openStockPurchase() { if (!this.canCreateStockPurchase) return; const color = this.stockColorOptions[0]; const price = color.priceCent / 100; this.stock = { open: true, saving: false, error: '' }; this.stockData = { color: color.name, size: this.stockSizeOptions[0]?.name || '', quantity: 1, minPriceYuan: price, maxPriceYuan: price, executionMode: 'live' }; this.$nextTick(() => this.$refs.stockForm?.clearValidate()) }, resetStockPurchase() { this.stock = this.emptyStock(); this.stockData = this.emptyStockData() }, onStockColorChange(name) { const color = this.stockColorOptions.find(item => item.name === name); if (!color) return; const price = color.priceCent / 100; this.stockData.minPriceYuan = price; this.stockData.maxPriceYuan = price; this.stock.error = '' }, @@ -263,7 +264,7 @@ export default { if (minUnitPriceCent < 0 || maxUnitPriceCent < minUnitPriceCent) { this.stock.error = '允许单价上限不能低于下限'; return } this.stock.saving = true; this.stock.error = '' try { - const response = await createStockPurchaseTask({ requestId: crypto.randomUUID(), executionMode: this.stockData.executionMode, pddProductId: this.detail.product.id, color: this.stockData.color, size: this.stockData.size || undefined, quantity: this.stockData.quantity, minUnitPriceCent, maxUnitPriceCent }, { suppressErrorMessage: true }) + const response = await createStockPurchaseTask({ requestId: createRequestId(), executionMode: this.stockData.executionMode, pddProductId: this.detail.product.id, color: this.stockData.color, size: this.stockData.size || undefined, quantity: this.stockData.quantity, minUnitPriceCent, maxUnitPriceCent }, { suppressErrorMessage: true }) ElMessage.success('备货采购任务已创建'); this.stock.open = false; this.detail.open = false await this.$router.push({ path: '/purchase-tasks/index', query: { taskId: response.data.id }}) } catch (error) { this.stock.error = error?.response?.data?.message || error?.response?.data?.msg || error?.message || '创建失败,请稍后重试' } finally { this.stock.saving = false } @@ -272,7 +273,7 @@ export default { addDimension() { this.editData.specs.push({ localId: ++uid, name: '', role: 'other', values: [] }) }, addValue(d) { d.values.push({ localId: ++uid, name: '', selectable: true, priceYuan: null }) }, move(items, index, offset) { const target = index + offset; if (target < 0 || target >= items.length) return; const [item] = items.splice(index, 1); items.splice(target, 0, item) }, specError() { const dimensions = new Set(); for (const d of this.editData.specs) { const name = d.name.trim(); if (!name) return '请填写规格维度名称'; if (dimensions.has(name)) return `规格维度“${name}”重复`; dimensions.add(name); if (!d.values.length) return `请为“${name}”添加规格值`; const values = new Set(); for (const v of d.values) { const n = v.name.trim(); if (!n) return `请填写“${name}”中的规格值名称`; if (values.has(n)) return `“${name}”中存在重复规格值“${n}”`; values.add(n) } } return '' }, - async saveEdit() { if (!await this.$refs.editForm.validate().catch(() => false)) return; const error = this.specError(); if (error) { ElMessage.warning(error); return } this.edit.saving = true; const id = this.edit.productId; try { const data = this.editData; await updatePddProduct(id, { requestId: crypto.randomUUID(), url: data.url.trim(), title: data.title.trim(), shopName: data.shopName.trim(), salesCount: data.salesCount, reviewCount: data.reviewCount, status: data.status, specs: data.specs.map(d => ({ name: d.name.trim(), role: d.role, values: d.values.map(v => ({ name: v.name.trim(), selectable: v.selectable, ...(d.role === 'color' && v.priceYuan !== null ? { priceCent: Math.round(v.priceYuan * 100) } : {}) })) })) }); ElMessage.success('PDD 商品资料已保存'); this.edit.open = false; await this.load(); await this.openDetail(id) } finally { this.edit.saving = false } } + async saveEdit() { if (!await this.$refs.editForm.validate().catch(() => false)) return; const error = this.specError(); if (error) { ElMessage.warning(error); return } this.edit.saving = true; const id = this.edit.productId; try { const data = this.editData; await updatePddProduct(id, { requestId: createRequestId(), url: data.url.trim(), title: data.title.trim(), shopName: data.shopName.trim(), salesCount: data.salesCount, reviewCount: data.reviewCount, status: data.status, specs: data.specs.map(d => ({ name: d.name.trim(), role: d.role, values: d.values.map(v => ({ name: v.name.trim(), selectable: v.selectable, ...(d.role === 'color' && v.priceYuan !== null ? { priceCent: Math.round(v.priceYuan * 100) } : {}) })) })) }); ElMessage.success('PDD 商品资料已保存'); this.edit.open = false; await this.load(); await this.openDetail(id) } finally { this.edit.saving = false } } } } diff --git a/web/src/views/goauto/purchase-rules/index.vue b/web/src/views/goauto/purchase-rules/index.vue index 256aab5..4c55ed3 100644 --- a/web/src/views/goauto/purchase-rules/index.vue +++ b/web/src/views/goauto/purchase-rules/index.vue @@ -50,6 +50,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue' import { createPurchaseRule, deletePurchaseRule, listPurchaseRules, setCurrentPurchaseRule, updatePurchaseRule } from '@/api/goauto/purchase-rules' +import { createRequestId } from '@/utils/request-id' const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], priceGuard: { enabled: true, minRatio: 0.2, maxRatio: 1.5 }, actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] } const defaultForm = () => ({ name: '', priceGuardEnabled: true, minRatio: 0.2, maxRatio: 1.5, absoluteMaxUnitPriceYuan: 50, contentText: '' }) @@ -82,14 +83,14 @@ export default { this.dialog.saving = true const content = JSON.parse(this.form.contentText) content.priceGuard = this.form.priceGuardEnabled ? { enabled: true, minRatio: this.form.minRatio, maxRatio: this.form.maxRatio } : { enabled: false, absoluteMaxUnitPriceCent: absoluteCent } - const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content } + const payload = { requestId: createRequestId(), name: this.form.name.trim(), content } try { if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload) ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load() } finally { this.dialog.saving = false } }, - async setCurrent(row) { const guard = row.content.priceGuard; const risk = guard?.enabled === false ? `该规则已关闭参考价倍率保护,单件绝对最高价为 ¥${this.formatYuan(guard.absoluteMaxUnitPriceCent)}。` : ''; await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。${risk}`, '切换当前采购规则', { type: guard?.enabled === false ? 'error' : 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: crypto.randomUUID(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } }, - async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: crypto.randomUUID() }); ElMessage.success('采购规则已删除'); await this.load() } + async setCurrent(row) { const guard = row.content.priceGuard; const risk = guard?.enabled === false ? `该规则已关闭参考价倍率保护,单件绝对最高价为 ¥${this.formatYuan(guard.absoluteMaxUnitPriceCent)}。` : ''; await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。${risk}`, '切换当前采购规则', { type: guard?.enabled === false ? 'error' : 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: createRequestId(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } }, + async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: createRequestId() }); ElMessage.success('采购规则已删除'); await this.load() } } } diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index f34cf48..8643a0d 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -134,6 +134,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { RefreshLeft, Search } from '@element-plus/icons-vue' import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks' +import { createRequestId } from '@/utils/request-id' const statusOptions = [ ['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'], @@ -211,7 +212,7 @@ export default { specText(color, size) { return [color, size].filter(Boolean).join(' / ') || '未记录' }, priceText(cents, currency) { if (cents === null || cents === undefined) return '—'; return `${currency || ''} ${(cents / 100).toFixed(2)}` }, formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) }, - requestId() { return window.crypto.randomUUID() }, + requestId() { return createRequestId() }, isRetrySelectable(row) { return row.taskType !== 'stock' && row.status === 'failed' && row.retryable === true }, retryRowClass({ row }) { return row.status === 'failed' && !row.retryable ? 'retry-disabled-row' : '' }, handleRetrySelection(rows) { this.retrySelection = rows }, diff --git a/web/src/views/goauto/shopee-products/index.vue b/web/src/views/goauto/shopee-products/index.vue index ec44290..2b0331f 100644 --- a/web/src/views/goauto/shopee-products/index.vue +++ b/web/src/views/goauto/shopee-products/index.vue @@ -155,6 +155,7 @@ import { suggestShopeeColorMappings, suggestShopeeSizeMappings, batchDeleteShopeeProducts } from '@/api/goauto/shopee-products' import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products' +import { createRequestId } from '@/utils/request-id' export default { name: 'GoAutoShopeeProducts', @@ -242,7 +243,7 @@ export default { if (this.colorValues.length) specs.push({ name: '颜色', role: 'color', values: this.colorValues.map(name => ({ name, source: 'manual' })) }) if (this.sizeValues.length) specs.push({ name: '尺码', role: 'size', values: this.sizeValues.map(name => ({ name, source: 'manual' })) }) const r = await createShopeeProduct({ - requestId: crypto.randomUUID(), shopeeItemId: this.createData.shopeeItemId.trim(), + requestId: createRequestId(), shopeeItemId: this.createData.shopeeItemId.trim(), title: this.createData.title.trim(), shopName: this.createData.shopName.trim(), pddProductId: this.createData.pddProductId || undefined, specs }) @@ -275,7 +276,7 @@ export default { return } try { - const r = await linkShopeeProductPdd(this.detail.product.id, { requestId: crypto.randomUUID(), pddProductId: row.id }) + const r = await linkShopeeProductPdd(this.detail.product.id, { requestId: createRequestId(), pddProductId: row.id }) this.detail.product = this.withDraftMappings(r.data.product) await this.loadLinkedPdd(row.id) ElMessage.success('已关联 PDD 商品') @@ -399,7 +400,7 @@ export default { } }, async confirmPendingMapping(row) { - await confirmShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name }) + await confirmShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) await this.refreshDetail() }, async saveAllMappings() { @@ -411,11 +412,11 @@ export default { try { for (const row of changes) { if (!row.pddValueDraft) { - if (row.originalPddValue) await clearShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name }) + if (row.originalPddValue) await clearShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual' - const payload = { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source } + const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source } if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence if (row.previewReason) payload.reason = row.previewReason @@ -424,7 +425,7 @@ export default { // AI-sourced mappings are always written as pending by the server // (#40, #46) and must stay that way until an operator explicitly // confirms them, separately from the act of saving the draft. - if (source === 'exact_match') await confirmShopeeSpecMapping(this.detail.product.id, { requestId: crypto.randomUUID(), dimension: row.dimensionName, valueName: row.name }) + if (source === 'exact_match') await confirmShopeeSpecMapping(this.detail.product.id, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存') await this.refreshDetail() @@ -441,12 +442,12 @@ export default { const dimension = this.addValueForm.dimension.trim() const name = this.addValueForm.name.trim() if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } - await addShopeeSpecValue(this.detail.product.id, { requestId: crypto.randomUUID(), dimension, role: this.addValueForm.role, name }) + await addShopeeSpecValue(this.detail.product.id, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }) this.addValueForm = { dimension: '', role: 'other', name: '' } await this.refreshDetail() }, async removeValue(dimension, row) { - await removeShopeeSpecValue(this.detail.product.id, { requestId: crypto.randomUUID(), dimension, name: row.name }) + await removeShopeeSpecValue(this.detail.product.id, { requestId: createRequestId(), dimension, name: row.name }) await this.refreshDetail() }, // ---------------- 编辑档案 ---------------- @@ -458,7 +459,7 @@ export default { async saveEdit() { this.edit.saving = true try { - await updateShopeeProduct(this.edit.productId, { requestId: crypto.randomUUID(), ...this.editData }) + await updateShopeeProduct(this.edit.productId, { requestId: createRequestId(), ...this.editData }) ElMessage.success('虾皮商品资料已保存') this.edit.open = false await this.refreshDetail() @@ -472,7 +473,7 @@ export default { async submitBatchDelete() { this.batchDelete.saving = true try { - const r = await batchDeleteShopeeProducts({ requestId: crypto.randomUUID(), ids: this.batchDelete.products.map(item => item.id) }) + const r = await batchDeleteShopeeProducts({ requestId: createRequestId(), ids: this.batchDelete.products.map(item => item.id) }) this.batchDelete.results = r.data.results this.batchDelete.deletedCount = r.data.results.filter(x => x.status === 'deleted').length this.batchDelete.skippedCount = r.data.results.filter(x => x.status === 'skipped').length @@ -482,7 +483,7 @@ export default { } }, async restore(row) { - await restoreShopeeProduct(row.id, { requestId: crypto.randomUUID() }) + await restoreShopeeProduct(row.id, { requestId: createRequestId() }) ElMessage.success('已恢复') await this.load() } diff --git a/web/src/views/goauto/syb-inner-codes/index.vue b/web/src/views/goauto/syb-inner-codes/index.vue index 0173e16..a400603 100644 --- a/web/src/views/goauto/syb-inner-codes/index.vue +++ b/web/src/views/goauto/syb-inner-codes/index.vue @@ -61,6 +61,7 @@ import { FolderOpened, RefreshLeft, Search, Upload } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import { applySybInnerCodes, deleteSybInnerCodes, getSybInnerCode, getSybInnerCodeApplyBatch, getSybInnerCodeMatchJob, importSybInnerCodes, listSybInnerCodes, previewSybInnerCodeApply, recheckSybInnerCode, rematchSybInnerCodes } from '@/api/goauto/syb-inner-codes' +import { createRequestId } from '@/utils/request-id' export default { name: 'GoAutoSybInnerCodes', @@ -83,12 +84,12 @@ export default { selectFile(file) { this.selectedFile = file.raw }, replaceFile(files) { this.$refs.upload.clearFiles(); this.$refs.upload.handleStart(files[0]) }, selectionChanged(rows) { this.selected = rows }, async load() { this.loading = true; this.loadError = ''; try { const r = await listSybInnerCodes(this.query); this.items = r.data.items; this.total = r.data.total } catch (error) { this.loadError = error?.response?.data?.message || error?.message || '列表加载失败' } finally { this.loading = false } }, search() { [this.query.dateFrom, this.query.dateTo] = this.dateRange || ['', '']; this.query.page = 1; this.load() }, clearFilters() { this.dateRange = null; Object.assign(this.query, { page: 1, dateFrom: '', dateTo: '', keyword: '' }); this.load() }, - async submitImport() { if (!this.selectedFile) return; this.importing = true; try { const r = await importSybInnerCodes(this.selectedFile, crypto.randomUUID()); ElMessage.success(`导入 ${r.data.recordCount} 条业务记录,已自动开始匹配`); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.recordCount, processed: 0, ready: 0, failed: 0 }; this.startPolling(); await this.load() } finally { this.importing = false } }, + async submitImport() { if (!this.selectedFile) return; this.importing = true; try { const r = await importSybInnerCodes(this.selectedFile, createRequestId()); ElMessage.success(`导入 ${r.data.recordCount} 条业务记录,已自动开始匹配`); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: r.data.recordCount, processed: 0, ready: 0, failed: 0 }; this.startPolling(); await this.load() } finally { this.importing = false } }, async openApply() { this.applyDialog = { open: true, loading: true, saving: false, preview: {}}; try { const r = await previewSybInnerCodeApply(this.selected.map(item => item.id)); this.applyDialog.preview = r.data } finally { this.applyDialog.loading = false } }, - async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: crypto.randomUUID(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } }, - openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: crypto.randomUUID(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } }, + async confirmApply() { this.applyDialog.saving = true; this.busy = true; try { const r = await applySybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); this.activeBatch = { id: r.data.batchId, status: 'queued', requested: r.data.queued, processed: 0 }; this.applyDialog.open = false; ElMessage.success('回写已提交,页面可以继续使用'); this.startPolling(); await this.load() } finally { this.applyDialog.saving = false; this.busy = false } }, + openDelete() { this.deleteDialog.open = true }, async confirmDelete() { this.deleteDialog.saving = true; this.busy = true; try { const r = await deleteSybInnerCodes({ requestId: createRequestId(), ids: this.selected.map(item => item.id) }); ElMessage.success(`已物理删除 ${r.data.deleted} 条数据`); this.deleteDialog.open = false; await this.load() } finally { this.deleteDialog.saving = false; this.busy = false } }, async recheck(row) { this.rowActionId = row.id; try { await recheckSybInnerCode(row.id); ElMessage.success('只读复核完成'); await this.load() } finally { this.rowActionId = 0 } }, - async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: crypto.randomUUID(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } }, + async rematch(row) { this.rowActionId = row.id; try { const r = await rematchSybInnerCodes({ requestId: createRequestId(), ids: [row.id] }); this.activeJob = { id: r.data.matchJobId, status: 'pending', total: 1, processed: 0, ready: 0, failed: 0 }; ElMessage.success('已开始重新匹配'); this.startPolling(); await this.load() } finally { this.rowActionId = 0 } }, async openDetail(id) { this.detail = { open: true, loading: true, item: null }; try { const r = await getSybInnerCode(id); this.detail.item = r.data.item } finally { this.detail.loading = false } }, startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(this.poll, 2000); this.poll() }, stopPolling() { if (this.pollTimer) window.clearInterval(this.pollTimer); this.pollTimer = null }, async poll() { let active = false; if (this.activeJob && ['pending', 'running'].includes(this.activeJob.status)) { const r = await getSybInnerCodeMatchJob(this.activeJob.id); this.activeJob = r.data.item; active = ['pending', 'running'].includes(this.activeJob.status) || active } if (this.activeBatch && ['queued', 'running'].includes(this.activeBatch.status)) { const r = await getSybInnerCodeApplyBatch(this.activeBatch.id); this.activeBatch = r.data.batch; active = ['queued', 'running'].includes(this.activeBatch.status) || active } await this.load(); if (!active) this.stopPolling() } diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 63734f0..8f371dd 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -151,6 +151,7 @@ import { listDevices } from '@/api/goauto/devices' import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks' import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks' import { listCollectionRules } from '@/api/goauto/collection-rules' +import { createRequestId } from '@/utils/request-id' export default { name: 'GoAutoSybProducts', @@ -322,7 +323,7 @@ export default { async submitPurchaseBatch() { this.purchaseDialog.saving = true try { - const r = await createPurchaseTasksBatch({ requestId: crypto.randomUUID(), sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined }) + const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined }) this.purchaseResult = { open: true, items: r.data.items, createdCount: r.data.createdCount, failedCount: r.data.failedCount } this.purchaseDialog.open = false await this.load() @@ -359,7 +360,7 @@ export default { if (!await this.$refs.collectionBatchForm.validate().catch(() => false)) return this.collectionBatch.saving = true try { - const response = await batchCreateCollectionTasks({ requestId: crypto.randomUUID(), pddProductIds: this.collectionBatch.products.map(item => item.pddProductId), ruleId: this.collectionBatchData.ruleId, deviceId: this.collectionBatchData.deviceId || null }) + const response = await batchCreateCollectionTasks({ requestId: createRequestId(), pddProductIds: this.collectionBatch.products.map(item => item.pddProductId), ruleId: this.collectionBatchData.ruleId, deviceId: this.collectionBatchData.deviceId || null }) const byID = new Map(this.collectionBatch.products.map(item => [item.pddProductId, item])) this.collectionBatch.results = response.data.items.map(item => ({ ...byID.get(item.pddProductId), ...item })) this.collectionBatch.successCount = response.data.successCount diff --git a/web/tests/unit/utils/request-id.spec.js b/web/tests/unit/utils/request-id.spec.js new file mode 100644 index 0000000..95937a0 --- /dev/null +++ b/web/tests/unit/utils/request-id.spec.js @@ -0,0 +1,28 @@ +import { createRequestId } from '@/utils/request-id' + +describe('request ID generation', () => { + it('uses the browser native UUID when available', () => { + const randomUUID = jest.fn(() => 'native-request-id') + + expect(createRequestId({ randomUUID })).toBe('native-request-id') + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it('formats secure random bytes as an RFC 4122 version 4 UUID', () => { + const getRandomValues = jest.fn(bytes => { + bytes.fill(0xff) + return bytes + }) + + expect(createRequestId({ getRandomValues })).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff') + expect(getRandomValues).toHaveBeenCalledTimes(1) + }) + + it('supports public HTTP contexts without Web Crypto', () => { + jest.spyOn(Math, 'random').mockReturnValue(0) + + expect(createRequestId(null)).toBe('00000000-0000-4000-8000-000000000000') + + Math.random.mockRestore() + }) +})