From c069bd5ad8d2d2fe70603ae022494492fe081029 Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 12 Mar 2026 21:48:47 +0800 Subject: [PATCH] =?UTF-8?q?perf(P22PROD1-3,P22PROD1-4):=20=E8=B5=84?= =?UTF-8?q?=E4=BA=A7exact-first=E6=90=9C=E7=B4=A2=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E4=B8=89=E6=9D=A1benchmark=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/scripts/benchmark-assets.sh | 89 ++++++++++++ .../benchmark-travel-reconciliation.sh | 86 ++++++++++++ server/scripts/benchmark-workflow.sh | 86 ++++++++++++ server/src/routes/assets.js | 70 +++++++--- .../tests/unit/routes/assets.search.test.js | 132 ++++++++++++++++-- 5 files changed, 426 insertions(+), 37 deletions(-) create mode 100755 server/scripts/benchmark-assets.sh create mode 100755 server/scripts/benchmark-travel-reconciliation.sh create mode 100755 server/scripts/benchmark-workflow.sh diff --git a/server/scripts/benchmark-assets.sh b/server/scripts/benchmark-assets.sh new file mode 100755 index 00000000..7efd1fc5 --- /dev/null +++ b/server/scripts/benchmark-assets.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" +DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-benchmark-assets}" +ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-finance_mgr@hyinda.com}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-FinanceMgrPassword1234}" +ITERATIONS="${ITERATIONS:-10}" +ASSET_EXACT_ENDPOINT="${ASSET_EXACT_ENDPOINT:-/api/assets?q=ZC-001&mine=0}" +ASSET_FUZZY_ENDPOINT="${ASSET_FUZZY_ENDPOINT:-/api/assets?q=%E6%B5%8B%E8%AF%95&mine=0}" + +if ! command -v curl >/dev/null 2>&1; then + echo "[ERROR] curl not found" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq not found" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +login() { + local out_file="$1" + local status + status="$(curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ + -d "{\"identifier\":\"$ADMIN_IDENTIFIER\",\"password\":\"$ADMIN_PASSWORD\"}")" + + if [[ "$status" != "200" ]]; then + echo "[ERROR] login failed: HTTP $status" >&2 + cat "$out_file" >&2 + exit 1 + fi + + jq -er '.data.accessToken' "$out_file" +} + +benchmark_endpoint() { + local label="$1" + local endpoint="$2" + local token="$3" + + local total_ms=0 + local max_ms=0 + local min_ms=999999 + + for _ in $(seq 1 "$ITERATIONS"); do + local body_file="$TMP_DIR/assets.json" + local elapsed + elapsed="$(curl -sS -o "$body_file" -w "%{time_total}" \ + -H "authorization: Bearer $token" \ + "$BASE_URL$endpoint")" + + local elapsed_ms + elapsed_ms="$(python3 - < max_ms )); then + max_ms=$elapsed_ms + fi + if (( elapsed_ms < min_ms )); then + min_ms=$elapsed_ms + fi + done + + local avg_ms=$((total_ms / ITERATIONS)) + echo "$label $endpoint avg=${avg_ms}ms min=${min_ms}ms max=${max_ms}ms iterations=$ITERATIONS" +} + +echo "[1/2] Login" +TOKEN="$(login "$TMP_DIR/login.json")" + +echo "[2/2] Assets search benchmark" +benchmark_endpoint "exact" "$ASSET_EXACT_ENDPOINT" "$TOKEN" +benchmark_endpoint "fuzzy" "$ASSET_FUZZY_ENDPOINT" "$TOKEN" + +echo "[OK] Assets benchmark completed" diff --git a/server/scripts/benchmark-travel-reconciliation.sh b/server/scripts/benchmark-travel-reconciliation.sh new file mode 100755 index 00000000..3e7d9282 --- /dev/null +++ b/server/scripts/benchmark-travel-reconciliation.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" +DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-benchmark-travel-reconciliation}" +ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-finance_mgr@hyinda.com}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-FinanceMgrPassword1234}" +ITERATIONS="${ITERATIONS:-10}" +TRAVEL_RECON_ENDPOINT="${TRAVEL_RECON_ENDPOINT:-/api/travel/reconciliation/batches?page=1&pageSize=20}" + +if ! command -v curl >/dev/null 2>&1; then + echo "[ERROR] curl not found" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq not found" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +login() { + local out_file="$1" + local status + status="$(curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ + -d "{\"identifier\":\"$ADMIN_IDENTIFIER\",\"password\":\"$ADMIN_PASSWORD\"}")" + + if [[ "$status" != "200" ]]; then + echo "[ERROR] login failed: HTTP $status" >&2 + cat "$out_file" >&2 + exit 1 + fi + + jq -er '.data.accessToken' "$out_file" +} + +benchmark_endpoint() { + local endpoint="$1" + local token="$2" + + local total_ms=0 + local max_ms=0 + local min_ms=999999 + + for _ in $(seq 1 "$ITERATIONS"); do + local body_file="$TMP_DIR/travel-reconciliation.json" + local elapsed + elapsed="$(curl -sS -o "$body_file" -w "%{time_total}" \ + -H "authorization: Bearer $token" \ + "$BASE_URL$endpoint")" + + local elapsed_ms + elapsed_ms="$(python3 - < max_ms )); then + max_ms=$elapsed_ms + fi + if (( elapsed_ms < min_ms )); then + min_ms=$elapsed_ms + fi + done + + local avg_ms=$((total_ms / ITERATIONS)) + echo "$endpoint avg=${avg_ms}ms min=${min_ms}ms max=${max_ms}ms iterations=$ITERATIONS" +} + +echo "[1/2] Login" +TOKEN="$(login "$TMP_DIR/login.json")" + +echo "[2/2] Travel reconciliation benchmark" +benchmark_endpoint "$TRAVEL_RECON_ENDPOINT" "$TOKEN" + +echo "[OK] Travel reconciliation benchmark completed" diff --git a/server/scripts/benchmark-workflow.sh b/server/scripts/benchmark-workflow.sh new file mode 100755 index 00000000..c8f22a9a --- /dev/null +++ b/server/scripts/benchmark-workflow.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" +DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-benchmark-workflow}" +ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-manager@hyinda.com}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-ManagerPassword1234}" +ITERATIONS="${ITERATIONS:-10}" +WORKFLOW_ENDPOINT="${WORKFLOW_ENDPOINT:-/api/workflow/tasks?status=pending&page=1&pageSize=20}" + +if ! command -v curl >/dev/null 2>&1; then + echo "[ERROR] curl not found" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[ERROR] jq not found" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +login() { + local out_file="$1" + local status + status="$(curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ + -d "{\"identifier\":\"$ADMIN_IDENTIFIER\",\"password\":\"$ADMIN_PASSWORD\"}")" + + if [[ "$status" != "200" ]]; then + echo "[ERROR] login failed: HTTP $status" >&2 + cat "$out_file" >&2 + exit 1 + fi + + jq -er '.data.accessToken' "$out_file" +} + +benchmark_endpoint() { + local endpoint="$1" + local token="$2" + + local total_ms=0 + local max_ms=0 + local min_ms=999999 + + for _ in $(seq 1 "$ITERATIONS"); do + local body_file="$TMP_DIR/workflow.json" + local elapsed + elapsed="$(curl -sS -o "$body_file" -w "%{time_total}" \ + -H "authorization: Bearer $token" \ + "$BASE_URL$endpoint")" + + local elapsed_ms + elapsed_ms="$(python3 - < max_ms )); then + max_ms=$elapsed_ms + fi + if (( elapsed_ms < min_ms )); then + min_ms=$elapsed_ms + fi + done + + local avg_ms=$((total_ms / ITERATIONS)) + echo "$endpoint avg=${avg_ms}ms min=${min_ms}ms max=${max_ms}ms iterations=$ITERATIONS" +} + +echo "[1/2] Login" +TOKEN="$(login "$TMP_DIR/login.json")" + +echo "[2/2] Workflow tasks benchmark" +benchmark_endpoint "$WORKFLOW_ENDPOINT" "$TOKEN" + +echo "[OK] Workflow benchmark completed" diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 5de3bfc7..b6d833f5 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -837,42 +837,68 @@ async function assetsRoutes(fastify) { ensureAssetReadPermission(fastify, request); const query = parseAssetListQuery(request.query); const mine = !canViewAllAssets(fastify, request.employee) || query.mine === true; - const where = {}; + const baseWhere = {}; if (query.status) { - where.status = query.status; + baseWhere.status = query.status; } if (query.categoryId) { - where.category_id = query.categoryId; + baseWhere.category_id = query.categoryId; } if (query.legalEntityId) { - where.legal_entity_id = query.legalEntityId; + baseWhere.legal_entity_id = query.legalEntityId; } if (query.costCenterId) { - where.cost_center_id = query.costCenterId; - } - if (query.q) { - where.OR = [ - { asset_no: query.q }, - { serial_no: query.q }, - { - name: { - contains: query.q, - mode: "insensitive", - }, - }, - ]; + baseWhere.cost_center_id = query.costCenterId; } if (mine) { - where.assignee_employee_id = request.employee.id; + baseWhere.assignee_employee_id = request.employee.id; } else if (query.assigneeId) { - where.assignee_employee_id = query.assigneeId; + baseWhere.assignee_employee_id = query.assigneeId; } - const items = await fastify.prisma.assetCard.findMany({ - where, + const listOptions = { orderBy: [{ updated_at: "desc" }, { created_at: "desc" }], select: ASSET_CARD_SELECT, - }); + }; + + let items; + if (query.q) { + items = await fastify.prisma.assetCard.findMany({ + where: { + ...baseWhere, + asset_no: query.q, + }, + ...listOptions, + }); + + if (!items.length) { + items = await fastify.prisma.assetCard.findMany({ + where: { + ...baseWhere, + serial_no: query.q, + }, + ...listOptions, + }); + } + + if (!items.length) { + items = await fastify.prisma.assetCard.findMany({ + where: { + ...baseWhere, + name: { + contains: query.q, + mode: "insensitive", + }, + }, + ...listOptions, + }); + } + } else { + items = await fastify.prisma.assetCard.findMany({ + where: baseWhere, + ...listOptions, + }); + } return sendData(reply, request, { mine, diff --git a/server/tests/unit/routes/assets.search.test.js b/server/tests/unit/routes/assets.search.test.js index 61f3b5e4..d975af04 100644 --- a/server/tests/unit/routes/assets.search.test.js +++ b/server/tests/unit/routes/assets.search.test.js @@ -109,7 +109,7 @@ async function buildApp({ employee, prisma }) { } describe('assets search route', () => { - it('adds q matcher for assetNo serialNo and name fallback', async () => { + it('prefers exact assetNo matching before serial or fuzzy lookup', async () => { const prisma = buildPrisma({ items: [buildAssetRecord()], }); @@ -131,14 +131,104 @@ describe('assets search route', () => { serialNo: 'SN-001', }), ); + expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(1); expect(prisma.assetCard.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ - OR: [ - { asset_no: 'ZC-001' }, - { serial_no: 'ZC-001' }, - { name: { contains: 'ZC-001', mode: 'insensitive' } }, - ], + asset_no: 'ZC-001', + }), + }), + ); + + await app.close(); + }); + + it('falls back to serialNo exact lookup when assetNo misses', async () => { + const prisma = { + assetCard: { + findMany: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([buildAssetRecord({ id: 'asset-2', assetNo: 'ZC-003', serialNo: 'SN-003' })]), + }, + }; + const app = await buildApp({ + employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] }, + prisma, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/assets?q=SN-003', + headers: { authorization: 'Bearer token' }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2); + expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: expect.objectContaining({ + asset_no: 'SN-003', + }), + }), + ); + expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: expect.objectContaining({ + serial_no: 'SN-003', + }), + }), + ); + + await app.close(); + }); + + it('falls back to fuzzy name search only after both exact lookups miss', async () => { + const prisma = { + assetCard: { + findMany: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([buildAssetRecord({ id: 'asset-3', assetNo: 'ZC-004', serialNo: 'SN-004', name: '研发测试笔记本' })]), + }, + }; + const app = await buildApp({ + employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] }, + prisma, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/assets?q=测试笔记本', + headers: { authorization: 'Bearer token' }, + }); + + expect(response.statusCode).toBe(200); + expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(3); + expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: expect.objectContaining({ + asset_no: '测试笔记本', + }), + }), + ); + expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: expect.objectContaining({ + serial_no: '测试笔记本', + }), + }), + ); + expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + where: expect.objectContaining({ + name: { contains: '测试笔记本', mode: 'insensitive' }, }), }), ); @@ -147,9 +237,14 @@ describe('assets search route', () => { }); it('forces own-scope lookup even when caller passes mine=0', async () => { - const prisma = buildPrisma({ - items: [buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'ZC-002', serialNo: 'SN-002' })], - }); + const prisma = { + assetCard: { + findMany: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'ZC-002', serialNo: 'SN-002' })]), + }, + }; const app = await buildApp({ employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] }, prisma, @@ -163,15 +258,22 @@ describe('assets search route', () => { expect(response.statusCode).toBe(200); expect(response.json().data.mine).toBe(true); - expect(prisma.assetCard.findMany).toHaveBeenCalledWith( + expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2); + expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ where: expect.objectContaining({ assignee_employee_id: 'employee-1', - OR: [ - { asset_no: 'SN-002' }, - { serial_no: 'SN-002' }, - { name: { contains: 'SN-002', mode: 'insensitive' } }, - ], + asset_no: 'SN-002', + }), + }), + ); + expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: expect.objectContaining({ + assignee_employee_id: 'employee-1', + serial_no: 'SN-002', }), }), );