perf: reduce worker order list query load

This commit is contained in:
yml2213
2026-08-30 16:40:09 +08:00
parent a45d7597fb
commit d24c69141a
8 changed files with 223 additions and 117 deletions
@@ -110,6 +110,12 @@ test('后台接单工单支持按接单分类筛选', () => {
assert.deepEqual(query.params, [7]) assert.deepEqual(query.params, [7])
}) })
test('空白搜索词不生成全表 LIKE 条件', () => {
const query = buildWorkOrderWhere({ keyword: ' ' })
assert.doesNotMatch(query.whereClause, /LIKE LOWER/)
assert.deepEqual(query.params, [])
})
test('大厅候选按分类上限取队首后再受全局上限限制', () => { test('大厅候选按分类上限取队首后再受全局上限限制', () => {
const query = buildWorkOrderWhere({ const query = buildWorkOrderWhere({
hallCandidateLimit: 20, hallCandidateLimit: 20,
@@ -58,6 +58,13 @@ export const WORK_ORDER_SELECT = `
) share_summary ON TRUE ) share_summary ON TRUE
` `
// Keep the page and total in one filtered query. This avoids running the same
// joins and share aggregation twice for every list request.
const LIST_WORK_ORDER_SELECT = WORK_ORDER_SELECT.replace(
' SELECT\n',
' SELECT\n COUNT(*) OVER()::int AS total_count,\n',
)
type WorkerOrderOverviewRow = { type WorkerOrderOverviewRow = {
status: string status: string
order_count: number order_count: number
@@ -268,15 +275,6 @@ export async function listWorkOrders({
createdFrom, createdFrom,
createdTo, createdTo,
}) })
const totalResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM work_orders wo
${joins}
${whereClause}
`,
params,
)
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
const orderBy = const orderBy =
sort === 'worker_claimed_at_desc' && workerSharingId sort === 'worker_claimed_at_desc' && workerSharingId
@@ -292,14 +290,30 @@ export async function listWorkOrders({
const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy
params.push(pageSize, offset) params.push(pageSize, offset)
const itemsResult = await query<WorkOrderRow>( const itemsResult = await query<WorkOrderRow>(
`${WORK_ORDER_SELECT} `${LIST_WORK_ORDER_SELECT}
${joins} ${joins}
${whereClause} ${whereClause}
ORDER BY ${effectiveOrderBy} ORDER BY ${effectiveOrderBy}
LIMIT $${params.length - 1} OFFSET $${params.length}`, LIMIT $${params.length - 1} OFFSET $${params.length}`,
params, params,
) )
return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } // A page beyond the end has no row from which to read the window count.
// Keep that uncommon pagination edge case correct without restoring a
// second query for normal requests.
let total = Number(
(itemsResult.rows[0] as WorkOrderRow & { total_count?: number })?.total_count || 0,
)
if (itemsResult.rows.length === 0 && page > 1) {
const totalResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM work_orders wo ${joins} ${whereClause}`,
params.slice(0, -2),
)
total = Number(totalResult.rows[0]?.total || 0)
}
return {
items: itemsResult.rows,
total,
}
} }
/** 按筛选条件汇总工单,统计范围不受当前分页影响。 */ /** 按筛选条件汇总工单,统计范围不受当前分页影响。 */
@@ -20,6 +20,10 @@ import {
requireActiveWorkerSession, requireActiveWorkerSession,
type WorkerSession, type WorkerSession,
} from './worker-session-context-service.js' } from './worker-session-context-service.js'
import { WorkerListCache } from './worker-list-cache.js'
const HALL_LIST_CACHE_TTL_MS = 2_000
const hallListCache = new WorkerListCache(HALL_LIST_CACHE_TTL_MS)
export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) { export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) {
requireActiveWorkerSession(session) requireActiveWorkerSession(session)
@@ -29,62 +33,66 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
const worker = await getRequiredWorker(session.workerId) const worker = await getRequiredWorker(session.workerId)
const permissions = resolveWorkerPermissions(worker) const permissions = resolveWorkerPermissions(worker)
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
const visibleAfterIso =
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
const hallConfig = getWorkerHallConfig() const hallConfig = getWorkerHallConfig()
const { items, total } = await listWorkOrders({ const keyword = String(query.keyword || '').trim()
page, const cacheKey = JSON.stringify([worker.id, page, pageSize, keyword, categoryId || 0])
pageSize, return hallListCache.getOrSet(cacheKey, async () => {
statuses: [WORK_ORDER_STATUS.OPEN], const visibleAfterIso =
keyword: String(query.keyword || '').trim(), visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
categoryId: categoryId || 0, const { items, total } = await listWorkOrders({
hallCandidateLimit: hallConfig.maxVisibleOrders, page,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders, pageSize,
visibleAfterIso, statuses: [WORK_ORDER_STATUS.OPEN],
excludeFilledSharing: true, keyword,
pinnedFirst: true, categoryId: categoryId || 0,
}) hallCandidateLimit: hallConfig.maxVisibleOrders,
const categories = (await listWorkCategories()).map(mapWorkCategory) hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
const orderIds = items.map((item) => Number(item.id)) visibleAfterIso,
const shares = await listWorkOrderSharesByOrderIds(orderIds) excludeFilledSharing: true,
const myShareByOrderId = new Map( pinnedFirst: true,
shares })
.filter( const categories = (await listWorkCategories()).map(mapWorkCategory)
(share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id), const orderIds = items.map((item) => Number(item.id))
) const shares = await listWorkOrderSharesByOrderIds(orderIds)
.map((share) => [Number(share.work_order_id), share]), const myShareByOrderId = new Map(
) shares
const sharingProgressByOrderId = new Map< .filter(
number, (share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id),
{ joinedQuantity: number; pendingSubmissionCount: number } )
>() .map((share) => [Number(share.work_order_id), share]),
for (const share of shares) { )
if (share.status === 'cancelled') continue const sharingProgressByOrderId = new Map<
const orderId = Number(share.work_order_id) number,
const progress = sharingProgressByOrderId.get(orderId) || { { joinedQuantity: number; pendingSubmissionCount: number }
joinedQuantity: 0, >()
pendingSubmissionCount: 0, for (const share of shares) {
} if (share.status === 'cancelled') continue
progress.joinedQuantity += Number(share.quantity || 0) const orderId = Number(share.work_order_id)
if (share.status === 'joined') progress.pendingSubmissionCount += 1 const progress = sharingProgressByOrderId.get(orderId) || {
sharingProgressByOrderId.set(orderId, progress) joinedQuantity: 0,
} pendingSubmissionCount: 0,
return {
items: items.map((item) => {
const myShare = myShareByOrderId.get(Number(item.id))
return {
...mapWorkOrderForWorker(item, permissions),
myShare: myShare ? mapWorkOrderShare(myShare) : null,
sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || {
joinedQuantity: 0,
pendingSubmissionCount: 0,
},
} }
}), progress.joinedQuantity += Number(share.quantity || 0)
pagination: { page, pageSize, total }, if (share.status === 'joined') progress.pendingSubmissionCount += 1
categories, sharingProgressByOrderId.set(orderId, progress)
visibleDelaySeconds, }
} return {
items: items.map((item) => {
const myShare = myShareByOrderId.get(Number(item.id))
return {
...mapWorkOrderForWorker(item, permissions),
myShare: myShare ? mapWorkOrderShare(myShare) : null,
sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || {
joinedQuantity: 0,
pendingSubmissionCount: 0,
},
}
}),
pagination: { page, pageSize, total },
categories,
visibleDelaySeconds,
}
})
} }
export async function getWorkerHallLeaderboard(session: WorkerSession, period: unknown = 'all') { export async function getWorkerHallLeaderboard(session: WorkerSession, period: unknown = 'all') {
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { WorkerListCache } from './worker-list-cache.js'
test('WorkerListCache coalesces concurrent reads and expires entries', async () => {
const cache = new WorkerListCache(15)
let calls = 0
const factory = async () => {
calls += 1
await new Promise((resolve) => setTimeout(resolve, 1))
return { calls }
}
const [first, second] = await Promise.all([
cache.getOrSet('same', factory),
cache.getOrSet('same', factory),
])
assert.deepEqual(first, { calls: 1 })
assert.deepEqual(second, { calls: 1 })
assert.equal(calls, 1)
await new Promise((resolve) => setTimeout(resolve, 20))
assert.deepEqual(await cache.getOrSet('same', factory), { calls: 2 })
assert.equal(calls, 2)
})
@@ -0,0 +1,32 @@
type CacheEntry = {
expiresAt: number
value: Promise<unknown>
}
/** Short-lived read cache that also coalesces concurrent requests. */
export class WorkerListCache {
private readonly entries = new Map<string, CacheEntry>()
constructor(
private readonly ttlMs: number,
private readonly maxEntries = 256,
) {}
getOrSet<T>(key: string, factory: () => Promise<T>): Promise<T> {
const now = Date.now()
const cached = this.entries.get(key)
if (cached && cached.expiresAt > now) return cached.value as Promise<T>
if (cached) this.entries.delete(key)
const value = factory().catch((error) => {
this.entries.delete(key)
throw error
})
if (this.entries.size >= this.maxEntries) {
const oldestKey = this.entries.keys().next().value
if (oldestKey) this.entries.delete(oldestKey)
}
this.entries.set(key, { expiresAt: now + this.ttlMs, value })
return value
}
}
@@ -44,6 +44,10 @@ import {
type WorkerSession, type WorkerSession,
} from './worker-session-context-service.js' } from './worker-session-context-service.js'
import { settleOverdueWorkOrders } from './worker-order-timeout-service.js' import { settleOverdueWorkOrders } from './worker-order-timeout-service.js'
import { WorkerListCache } from './worker-list-cache.js'
const MY_ORDER_LIST_CACHE_TTL_MS = 2_000
const myOrderListCache = new WorkerListCache(MY_ORDER_LIST_CACHE_TTL_MS)
export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) { export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) {
requireActiveWorkerSession(session) requireActiveWorkerSession(session)
@@ -51,62 +55,74 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const page = normalizePage(query.page) const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize) const pageSize = normalizePageSize(query.pageSize)
const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false) const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false)
const { items, total } = await listWorkOrders({ const permissions = resolveWorkerPermissions(worker)
const statuses = normalizeStatuses(query.status)
const keyword = String(query.keyword || '').trim()
const cacheKey = JSON.stringify([
worker.id,
page, page,
pageSize, pageSize,
statuses: normalizeStatuses(query.status), statuses,
keyword: String(query.keyword || '').trim(), keyword,
workerSharingId: worker.id,
vipEvidencePending, vipEvidencePending,
sort: 'worker_claimed_at_desc', ])
return myOrderListCache.getOrSet(cacheKey, async () => {
const { items, total } = await listWorkOrders({
page,
pageSize,
statuses,
keyword,
workerSharingId: worker.id,
vipEvidencePending,
sort: 'worker_claimed_at_desc',
})
const workOrderIds = items.map((item) => Number(item.id))
const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] =
await Promise.all([
listWorkerWorkOrderNotes(worker.id, workOrderIds),
listWorkerWorkOrderViews(worker.id, workOrderIds),
getPendingCancelRequestOrderIds(worker.id, workOrderIds),
getPendingFeedbackOrderIds(worker.id, workOrderIds),
getLatestMaterialEventAtByOrderIds(workOrderIds),
])
const noteByOrderId = new Map(
notes.map((item) => [Number(item.work_order_id), String(item.note || '')]),
)
const viewedAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.viewed_at || '')]),
)
const materialSeenAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.material_seen_at || '')]),
)
return {
items: items.map((item) => {
const workOrderId = Number(item.id)
const materialEventAt = materialEventAtByOrderId.get(workOrderId) || ''
const materialSeenAt = materialSeenAtByOrderId.get(workOrderId) || ''
const materialUpdated =
Boolean(materialEventAt) &&
(!materialSeenAt ||
new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime())
return {
...mapWorkOrderForWorker(
item,
permissions,
myShareByOrderId.get(workOrderId),
noteByOrderId.get(workOrderId) || '',
viewedAtByOrderId.get(workOrderId) || null,
),
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
problemMarked: item.status === 'problem',
pendingCancelRequest: pendingCancelOrderIds.has(workOrderId),
pendingFeedback: pendingFeedbackOrderIds.has(workOrderId),
materialUpdated,
}
}),
pagination: { page, pageSize, total },
}
}) })
const permissions = resolveWorkerPermissions(worker)
const workOrderIds = items.map((item) => Number(item.id))
const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, materialEventAtByOrderId] =
await Promise.all([
listWorkerWorkOrderNotes(worker.id, workOrderIds),
listWorkerWorkOrderViews(worker.id, workOrderIds),
getPendingCancelRequestOrderIds(worker.id, workOrderIds),
getPendingFeedbackOrderIds(worker.id, workOrderIds),
getLatestMaterialEventAtByOrderIds(workOrderIds),
])
const noteByOrderId = new Map(
notes.map((item) => [Number(item.work_order_id), String(item.note || '')]),
)
const viewedAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.viewed_at || '')]),
)
const materialSeenAtByOrderId = new Map(
views.map((item) => [Number(item.work_order_id), String(item.material_seen_at || '')]),
)
return {
items: items.map((item) => {
const workOrderId = Number(item.id)
const materialEventAt = materialEventAtByOrderId.get(workOrderId) || ''
const materialSeenAt = materialSeenAtByOrderId.get(workOrderId) || ''
const materialUpdated =
Boolean(materialEventAt) &&
(!materialSeenAt ||
new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime())
return {
...mapWorkOrderForWorker(
item,
permissions,
myShareByOrderId.get(workOrderId),
noteByOrderId.get(workOrderId) || '',
viewedAtByOrderId.get(workOrderId) || null,
),
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
problemMarked: item.status === 'problem',
pendingCancelRequest: pendingCancelOrderIds.has(workOrderId),
pendingFeedback: pendingFeedbackOrderIds.has(workOrderId),
materialUpdated,
}
}),
pagination: { page, pageSize, total },
}
} }
/** 打手订单标签概览:订单数与预计或已结算金额。 */ /** 打手订单标签概览:订单数与预计或已结算金额。 */
@@ -89,6 +89,8 @@ export default function WorkerHallPage() {
? pagination.page + 1 ? pagination.page + 1
: undefined : undefined
}, },
staleTime: 2_000,
refetchOnWindowFocus: false,
retry: false, retry: false,
}) })
@@ -179,6 +179,8 @@ export default function WorkerOrdersPage() {
const ordersQuery = useQuery({ const ordersQuery = useQuery({
queryKey: ['worker-my-orders', status, keyword, page, pageSize], queryKey: ['worker-my-orders', status, keyword, page, pageSize],
queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)), queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)),
staleTime: 2_000,
refetchOnWindowFocus: false,
retry: false, retry: false,
}) })
const pagination = ordersQuery.data?.data.pagination const pagination = ordersQuery.data?.data.pagination