perf(P22PROD1-3,P22PROD1-4): 资产exact-first搜索优化与三条benchmark脚本

这个提交包含在:
2026-03-12 21:48:47 +08:00
父节点 c846bb203c
当前提交 c069bd5ad8
共修改 5 个文件,包含 426 行新增和 37 行删除
+89
查看文件
@@ -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 - <<PY
value = float("$elapsed")
print(int(round(value * 1000)))
PY
)"
total_ms=$((total_ms + elapsed_ms))
if (( elapsed_ms > 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"
+86
查看文件
@@ -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 - <<PY
value = float("$elapsed")
print(int(round(value * 1000)))
PY
)"
total_ms=$((total_ms + elapsed_ms))
if (( elapsed_ms > 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"
+86
查看文件
@@ -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 - <<PY
value = float("$elapsed")
print(int(round(value * 1000)))
PY
)"
total_ms=$((total_ms + elapsed_ms))
if (( elapsed_ms > 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"
+48 -22
查看文件
@@ -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,
+117 -15
查看文件
@@ -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',
}),
}),
);