diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 62b8fe0..ec9566b 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting -wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9 -synchronized_at: 2026-09-21T08:14:54Z +wiki_revision: cc305694c6d7a6dfaa0e2eb150183fd36bb864a6 +synchronized_at: 2026-10-05T06:51:22Z # 故障排查 @@ -104,3 +104,11 @@ adb -s exec-out run-as cn.ilapage.goauto.agent cat files/purchas ``` 先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。 +## SYB 商品列表查询等待(#355) + +实现绑定 `a24c206`,仅 Web 客户端;已通过本地合成数据测试与构建,尚未合并 main 或发布线上。 + +- `web/src/api/goauto/syb-products.js` 的 `listSybProducts` 为 `GET /api/admin/v1/syb-products` 单独设置默认 `timeout: 60000`,覆盖 SYB 商品页搜索、翻页和修改每页数量。全局请求默认仍为 10000ms,其他页面及详情、AI 匹配、采购、采集、退货操作的原超时不变。 +- 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。 +- 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。 +- 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。 diff --git a/web/src/api/goauto/syb-products.js b/web/src/api/goauto/syb-products.js index 58fe336..1f5cbf3 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, options = {}) { - return request({ url: '/api/admin/v1/syb-products', method: 'get', params, paramsSerializer: { serialize(values) { + return request({ url: '/api/admin/v1/syb-products', method: 'get', params, timeout: 60000, paramsSerializer: { serialize(values) { const query = new URLSearchParams() for (const [key, value] of Object.entries(values)) { if (Array.isArray(value)) value.forEach(item => query.append(key, String(item))) diff --git a/web/tests/e2e/syb-network-retry.spec.ts b/web/tests/e2e/syb-network-retry.spec.ts index 845dadc..63675b9 100644 --- a/web/tests/e2e/syb-network-retry.spec.ts +++ b/web/tests/e2e/syb-network-retry.spec.ts @@ -2,6 +2,28 @@ import { expect, test } from '@playwright/test' const sybProductRoute = [{ path: '/goauto-order-workbench', component: 'Layout', menuName: 'GoAutoOrderWorkbench', title: '订单工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }] +test('SYB 商品列表超过 10 秒后仍正常显示,不重复请求', 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/v1/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }}) + if (url.pathname.endsWith('/api/admin/v1/syb-products')) { + listCalls++ + await new Promise(resolve => setTimeout(resolve, 12000)) + return route.fulfill({ json: { code: 200, data: { items: [{ id: 355, orderCode: 'TEST-355', productTitle: '延迟查询成功的测试商品', quantity: 1, parseStatus: 'success' }], total: 1, page: 1, pageSize: 200 }}}) + } + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 355, eligible: false, processStage: 'return_pending', processStageLabel: '退货待确认' }] }}}) + return route.fulfill({ json: { code: 200, data: [] }}) + }) + await page.goto('/#/syb-products') + await expect(page.getByText('延迟查询成功的测试商品')).toBeVisible({ timeout: 20000 }) + expect(listCalls).toBe(1) + await expect(page.getByText(/timeout of 10000ms exceeded/)).toHaveCount(0) +}) + test('SYB 商品首次网络失败时静默重试一次并恢复列表', async({ page, context }) => { await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) let listCalls = 0 diff --git a/web/tests/unit/syb-products-timeout.test.cjs b/web/tests/unit/syb-products-timeout.test.cjs new file mode 100644 index 0000000..b79ef9c --- /dev/null +++ b/web/tests/unit/syb-products-timeout.test.cjs @@ -0,0 +1,60 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const source = fs.readFileSync(path.join(__dirname, '../../src/api/goauto/syb-products.js'), 'utf8') +const names = [...source.matchAll(/export function (\w+)/g)].map(match => match[1]) +const load = request => new Function('request', source.replace(/^import .+\n/gm, '').replace(/export function /g, 'function ') + `\nreturn { ${names.join(',')} }`)(request) + +test('search, pagination and page-size changes use the list-only 60-second timeout', () => { + const api = load(config => config) + for (const params of [ + { page: 1, pageSize: 200, shopName: '', processStage: 'return_pending', purchaseType: 'all' }, + { page: 2, pageSize: 200 }, + { page: 1, pageSize: 500 } + ]) { + const config = api.listSybProducts(params) + assert.equal(config.timeout, 60000) + assert.equal(config.params, params) + assert.equal(config.url, '/api/admin/v1/syb-products') + assert.equal(config.method, 'get') + } +}) + +test('existing cancellation and error options are preserved', () => { + const controller = new AbortController() + const config = load(config => config).listSybProducts({}, { signal: controller.signal, suppressNetworkError: true }) + assert.equal(config.timeout, 60000) + assert.equal(config.signal, controller.signal) + assert.equal(config.suppressNetworkError, true) + controller.abort() + assert.equal(config.signal.aborted, true) +}) + +test('multi-shop serialization and explicit timeout override survive integration', () => { + const api = load(config => config) + const config = api.listSybProducts({ shopIds: [1, 2], page: 1 }) + assert.equal(config.timeout, 60000) + assert.equal(config.paramsSerializer.serialize(config.params), 'shopIds=1&shopIds=2&page=1') + assert.equal(api.listSybProducts({}, { timeout: 15000 }).timeout, 15000) +}) + +test('other SYB API functions do not acquire the list timeout; global timeout stays 10 seconds', () => { + const api = load(config => config) + for (const name of names.filter(name => name !== 'listSybProducts')) { + assert.equal(api[name](1, {}).timeout, undefined, name) + } + const globalRequest = fs.readFileSync(path.join(__dirname, '../../src/utils/request.js'), 'utf8') + assert.match(globalRequest, /timeout:\s*10000\b/) +}) + +test('timeout and cancellation failures propagate without new retries', async () => { + for (const code of ['ECONNABORTED', 'ERR_CANCELED']) { + let calls = 0 + const failure = Object.assign(new Error(code), { code }) + const api = load(() => { calls++; return Promise.reject(failure) }) + await assert.rejects(api.listSybProducts({}), error => error === failure) + assert.equal(calls, 1) + } +})