From 476ca64616367e7bea88789c8700c25d1ffb1b02 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 24 Aug 2026 11:02:47 +0800 Subject: [PATCH] fix(#72): wait for API and retry SYB load --- docs/04-local-development-and-verification.md | 8 ++-- scripts/start-web.ps1 | 28 +++++++++++++ web/src/api/goauto/purchase-tasks.js | 2 +- web/src/api/goauto/syb-products.js | 4 +- web/src/utils/request.js | 12 +++--- web/src/views/goauto/syb-products/index.vue | 17 +++++--- web/tests/e2e/syb-network-retry.spec.ts | 42 +++++++++++++++++++ 7 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 web/tests/e2e/syb-network-retry.spec.ts diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index bf7e1e7..05c487d 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.- -wiki_revision: fbd334cc639f9053388af09319f34faf1dc4a5ab -synchronized_at: 2026-08-24T02:35:18Z +wiki_revision: 2075bb8a28fa01dcdbbcf105f12a29afc9b2e6f3 +synchronized_at: 2026-08-24T02:59:16Z # 本地开发与验证 @@ -143,7 +143,7 @@ Remove-Item Env:GOAUTO_IMPORT_MYSQL_TEST_DSN 本机安装的 Go Supervisor 位于 `D:\supervisor`。GoAuto 的版本化配置源为 `scripts/supervisor/goauto.conf`,运行副本为 `D:\supervisor\programs\goauto.conf`。两个实例都读取仓库根目录已忽略的 `config.yaml`,Supervisor 配置不得保存数据库密码: - `goauto-admin-api`:调用 `scripts/start-server.ps1`,执行迁移后启动 Admin API。 -- `goauto-admin-ui`:调用 `scripts/start-web.ps1`,启动 Admin UI;脚本会解析 Node.js 并直接运行项目的 Vite CLI,避免 Supervisor 子进程缺少 Node PATH。 +- `goauto-admin-ui`:调用 `scripts/start-web.ps1`,先等待 `ports.server` 对应的 `/api/v1/health` 返回 200,再启动 Admin UI;脚本会解析 Node.js 并直接运行项目的 Vite CLI,避免 Supervisor 子进程缺少 Node PATH。API 在 60 秒内未就绪时,脚本明确失败并由 Supervisor 按重启策略处理。 - 日志:`D:\supervisor\logs\goauto-admin-api.log`、`D:\supervisor\logs\goauto-admin-ui.log`。 - Supervisor 管理界面:`http://127.0.0.1:9009`。 @@ -162,4 +162,4 @@ D:\supervisor\supervisord.exe /c D:\supervisor\supervisord.conf ctl restart goau D:\supervisor\supervisord.exe /c D:\supervisor\supervisord.conf ctl restart goauto-admin-ui ``` -Supervisor 托管期间不要再运行 `start-all.bat` 或重复启动对应单端脚本,否则会因 8010/9527 被占用而失败。 +Supervisor 托管期间不要再运行 `start-all.bat` 或重复启动对应单端脚本,否则会因 8010/9527 被占用而失败。两个 GoAuto 实例同时重启时,Admin UI 会等待 API HTTP 就绪后再监听 Web 端口;SYB 商品页对一次短暂网络断开做单次有界重试,不对认证、权限或业务错误重试。 diff --git a/scripts/start-web.ps1 b/scripts/start-web.ps1 index 9b22a49..d09c71c 100644 --- a/scripts/start-web.ps1 +++ b/scripts/start-web.ps1 @@ -77,6 +77,32 @@ function Read-PortConfig { return @{ Server = [int]$values.server; Web = [int]$values.web } } +function Wait-ApiReady { + param( + [int]$Port, + [int]$TimeoutSeconds = 60 + ) + + $healthUrl = "http://127.0.0.1:${Port}/api/v1/health" + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + Write-Host "Waiting for GoAuto API at $healthUrl ..." -ForegroundColor Cyan + while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + try { + $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2 + if ($response.StatusCode -eq 200) { + Write-Host "GoAuto API is ready." -ForegroundColor Green + return + } + } + catch { + # The API process may still be migrating or compiling. + } + Start-Sleep -Milliseconds 1000 + } + + throw "GoAuto API did not become ready within ${TimeoutSeconds}s: $healthUrl" +} + if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Join-Path $workspaceRoot "config.yaml" } @@ -94,6 +120,8 @@ if ($ValidateConfigOnly) { return } +Wait-ApiReady -Port $ports.Server + $node = Get-Command node.exe -ErrorAction SilentlyContinue if ($node) { $nodePath = $node.Source diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js index f10a629..1d49629 100644 --- a/web/src/api/goauto/purchase-tasks.js +++ b/web/src/api/goauto/purchase-tasks.js @@ -2,7 +2,7 @@ import request from '@/utils/request' export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) } export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) } -export function previewPurchaseTasks(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data }) } +export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) } export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data }) } export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data }) } export function authorizeRepurchase(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/authorize-repurchase`, method: 'post', data }) } diff --git a/web/src/api/goauto/syb-products.js b/web/src/api/goauto/syb-products.js index 053f39a..486c873 100644 --- a/web/src/api/goauto/syb-products.js +++ b/web/src/api/goauto/syb-products.js @@ -1,7 +1,7 @@ import request from '@/utils/request' -export function listSybProducts(params) { - return request({ url: '/api/admin/v1/syb-products', method: 'get', params }) +export function listSybProducts(params, options = {}) { + return request({ url: '/api/admin/v1/syb-products', method: 'get', params, ...options }) } export function getSybProduct(productId) { diff --git a/web/src/utils/request.js b/web/src/utils/request.js index e160fea..2d7b0db 100644 --- a/web/src/utils/request.js +++ b/web/src/utils/request.js @@ -99,11 +99,13 @@ service.interceptors.response.use( }, error => { if (error.message === 'Network Error') { - ElMessage({ - message: '服务器连接异常,请检查服务器!', - type: 'error', - duration: 5 * 1000 - }) + if (!error.config?.suppressNetworkError) { + ElMessage({ + message: '服务器连接异常,请检查服务器!', + type: 'error', + duration: 5 * 1000 + }) + } return Promise.reject(error) } console.log('err' + error) // for debug diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 4a7a4e2..652127a 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -184,15 +184,22 @@ export default { created() { this.load() }, beforeUnmount() { this.stopSyncPolling() }, methods: { - async load() { + async load(allowNetworkRetry = true) { this.loading = true this.selectedProducts = [] this.$refs.productTable?.clearSelection() + const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {} try { - const r = await listSybProducts(this.query) + const r = await listSybProducts(this.query, requestOptions) this.products = r.data.items this.total = r.data.total - await this.loadPurchaseReadiness() + await this.loadPurchaseReadiness(requestOptions) + } catch (error) { + if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) { + await new Promise(resolve => setTimeout(resolve, 1000)) + return this.load(false) + } + throw error } finally { this.loading = false } @@ -204,12 +211,12 @@ export default { purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } }, purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` }, purchaseActionLabel(item) { return { open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' }, - async loadPurchaseReadiness() { + async loadPurchaseReadiness(requestOptions = {}) { this.purchaseReadiness = {} if (!this.canPurchase || !this.products.length) return this.purchaseReadinessLoading = true try { - const r = await previewPurchaseTasks({ sybProductIds: this.products.map(item => item.id) }) + const r = await previewPurchaseTasks({ sybProductIds: this.products.map(item => item.id) }, requestOptions) this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item])) } finally { this.purchaseReadinessLoading = false } }, diff --git a/web/tests/e2e/syb-network-retry.spec.ts b/web/tests/e2e/syb-network-retry.spec.ts new file mode 100644 index 0000000..1a6bd5b --- /dev/null +++ b/web/tests/e2e/syb-network-retry.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test' + +test('SYB 商品首次网络失败时静默重试一次并恢复列表', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + let listCalls = 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: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}}) + if (url.pathname.endsWith('/api/admin/v1/syb-products')) { + listCalls += 1 + if (listCalls === 1) return route.abort('connectionrefused') + return route.fulfill({ json: { code: 200, data: { items: [{ id: 72, orderCode: 'RETRY-72', shopeeItemId: '10072', productTitle: '网络恢复后的商品', quantity: 1, parseStatus: 'success' }], total: 1, page: 1, pageSize: 20 }}}) + } + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 72, eligible: false, reason: '测试数据未关联商品' }], eligibleCount: 0, skippedCount: 1 }}}) + return route.fulfill({ json: { code: 200, data: [] }}) + }) + + await page.goto('http://localhost:9527/#/syb-products/index') + await expect(page.getByText('网络恢复后的商品')).toBeVisible() + expect(listCalls).toBe(2) + await expect(page.getByText('服务器连接异常,请检查服务器!')).toHaveCount(0) +}) + +test('SYB 商品业务错误不重试', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + let listCalls = 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: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}}) + if (url.pathname.endsWith('/api/admin/v1/syb-products')) { + listCalls += 1 + return route.fulfill({ json: { code: 500, msg: '测试业务错误' }}) + } + return route.fulfill({ json: { code: 200, data: [] }}) + }) + + await page.goto('http://localhost:9527/#/syb-products/index') + await expect(page.getByText('测试业务错误')).toBeVisible() + expect(listCalls).toBe(1) +})