perf(worker-hall): 优化百单大厅加载与渲染性能

This commit is contained in:
yml2213
2026-10-24 20:41:58 +08:00
parent 72a758fd7c
commit 7fdef88ba6
11 changed files with 617 additions and 321 deletions
@@ -219,15 +219,15 @@ export function getMaxUploadSizeBytes() {
* 之后任何不重签的地方过一会儿必然打不开(客服看到裂图),要每一处读取都记得重签才不会出错。
* 现在链接就是稳定的普通链接(对象 key 带随机段,不可枚举),存下来过多久都能看。
*/
export function buildFileUrl(objectKey: string) {
return buildUnsignedFileUrl(normalizeObjectKey(objectKey))
export function buildFileUrl(objectKey: string, variant = '') {
return buildUnsignedFileUrl(normalizeObjectKey(objectKey), variant)
}
/** 把库里历史存的(带签名/带有效期的)链接归一化成普通链接;非文件链接原样返回。 */
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') {
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '', variant = '') {
const rawUrl = String(value || '').trim()
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
return objectKey ? buildFileUrl(objectKey) : rawUrl
return objectKey ? buildFileUrl(objectKey, variant) : rawUrl
}
/** @deprecated 用 refreshStoredFileUrl;两者现在完全等价(都只做归一化,不再重签)。 */
@@ -299,8 +299,10 @@ function mapUploadedFile(
}
}
function buildUnsignedFileUrl(objectKey: string) {
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}`
function buildUnsignedFileUrl(objectKey: string, variant = '') {
const normalizedVariant = normalizeVariant(variant)
const variantQuery = normalizedVariant ? `&variant=${encodeURIComponent(normalizedVariant)}` : ''
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}${variantQuery}`
}
export function extractObjectKeyFromFileUrl(value: string) {
@@ -25,6 +25,7 @@ import {
isWorkOrderSharingFilled,
mapFinanceRequest,
mapWorkOrderAdmin,
mapWorkOrderForHall,
mapWorkOrderForWorker,
mapWorkerUser,
resolveWorkerPermissions,
@@ -324,13 +325,14 @@ async function publishWorkOrderSnapshots(input: {
scopes: ['worker_hall'],
operation: hallVisible ? 'upsert' : 'remove',
data: hallVisible
? {
...mapWorkOrderForWorker(workOrder, { depositFreeAmount: 0 }),
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
// 广播无法按打手等级过滤可见延迟,携带入厅时间由客户端按各自延迟推迟展示。
hallQueuedAt: workOrder.hall_queued_at || null,
sharingProgress: { joinedQuantity, pendingSubmissionCount },
}
? mapWorkOrderForHall(
workOrder,
{ depositFreeAmount: 0 },
{
// 广播无法按打手等级过滤可见延迟,携带入厅时间由客户端按各自延迟推迟展示。
sharingProgress: { joinedQuantity, pendingSubmissionCount },
},
)
: { workOrderId },
broadcastWorkers: true,
})
@@ -68,7 +68,7 @@ export async function listAdminWorkerLevels() {
export async function listAdminWorkCategories() {
await ensureWorkerPlatformDefaults()
return { items: (await listAllWorkCategories()).map(mapWorkCategory) }
return { items: (await listAllWorkCategories()).map((category) => mapWorkCategory(category)) }
}
async function generateUniqueCategoryKey(): Promise<string> {
@@ -86,6 +86,7 @@ export {
mapCollectLookupOrder,
mapWorkCategory,
mapWorkOrderAdmin,
mapWorkOrderForHall,
mapWorkOrderForWorker,
mapWorkOrderMaterial,
mapWorkOrderPublic,
@@ -32,14 +32,19 @@ import {
} from './work-order-material-mappers.js'
import { resolveRequirementFields } from './work-order-requirement-fields.js'
export function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
export function mapWorkCategory(
category: WorkCategoryRow | null | undefined,
options: { iconVariant?: string } = {},
) {
if (!category) return null
return {
categoryId: Number(category.id),
categoryKey: category.category_key,
name: category.name,
parentId: category.parent_id ? Number(category.parent_id) : null,
iconUrl: category.icon_url ? refreshStoredFileUrl(category.icon_url) : null,
iconUrl: category.icon_url
? refreshStoredFileUrl(category.icon_url, '', options.iconVariant || '')
: null,
sortOrder: Number(category.sort_order || 100),
status: category.status,
createdAt: category.created_at,
@@ -495,6 +500,95 @@ export function mapWorkOrderForWorker(
}
}
/**
* 抢单大厅专用轻量映射。
*
* 大厅一次会加载 100+ 单,详情字段(material 原图、requirement、acceptance、
* capabilities、取消事件等)在卡片上完全用不到,却占了单条 4KB 中的 3KB 以上。
* 这里只保留卡片、搜索和实时增量所需的字段,并保留最小 material 供关键词匹配。
*/
export function mapWorkOrderForHall(
workOrder: WorkOrderRow,
permissions: { depositFreeAmount: number },
options: {
myShare?: WorkOrderShareRow | null
sharingProgress?: { joinedQuantity: number; pendingSubmissionCount: number }
} = {},
) {
// 大厅卡片展示的是母单剩余份数对应的金额/标题,不能按当前打手个人份额重算;
// myShare 只用于「已拼 N 份」和按钮状态。
const mapped = mapWorkOrderForWorker(workOrder, permissions)
const material = isPlainRecord(mapped.material) ? mapped.material : {}
const collect = isPlainRecord(material.collect) ? material.collect : {}
const fields = isPlainRecord(collect.fields) ? collect.fields : {}
const keywordFields: Record<string, string> = {}
for (const key of ['gameId', 'gameNickname', 'gameAccount', 'roleName']) {
const value = String(fields[key] ?? '').trim()
if (value) keywordFields[key] = value
}
const gift = 'gift' in mapped && mapped.gift ? mapped.gift : null
const myShare = options.myShare
return {
workOrderId: mapped.workOrderId,
workOrderNo: mapped.workOrderNo,
platformOrderId: mapped.platformOrderId,
productName: mapped.productName,
categoryId: mapped.categoryId,
categoryName: mapped.categoryName,
parentCategoryId: workOrder.parent_category_id ? Number(workOrder.parent_category_id) : null,
parentCategoryName: String(workOrder.parent_category_name || ''),
// 大厅展示的是「还能不能抢」,用库里的生命周期状态,不能把已有人拼单的母单映射成 in_progress。
status: String(workOrder.status || ''),
acceptanceMode: mapped.acceptanceMode,
...(gift
? {
gift: {
phase: gift.phase,
rawPhase: gift.rawPhase,
cooldownHours: gift.cooldownHours,
cooldownMinutes: gift.cooldownMinutes,
friendAddedAt: gift.friendAddedAt,
giftAvailableAt: gift.giftAvailableAt,
},
}
: {}),
sharing: mapped.sharing,
sharingProgress: options.sharingProgress || mapped.sharingProgress,
rewardAmount: mapped.rewardAmount,
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
depositFreeAmount: mapped.depositFreeAmount,
freezeDepositAmount: mapped.freezeDepositAmount,
timeoutMinutes: mapped.timeoutMinutes,
timeoutPolicy: mapped.timeoutPolicy,
deadlineAt: mapped.deadlineAt,
publishedAt: workOrder.published_at || workOrder.hall_queued_at || workOrder.created_at || null,
pinnedAt: workOrder.pinned_at,
hallQueuedAt: workOrder.hall_queued_at || null,
myShare: myShare
? {
shareId: Number(myShare.id),
workOrderId: Number(myShare.work_order_id),
workerId: Number(myShare.worker_id),
quantity: Number(myShare.quantity || 0),
unitReward: Number(myShare.unit_reward || 0),
shareReward: Number(myShare.share_reward || 0),
shareDeposit: Number(myShare.share_deposit || 0),
status: myShare.status,
}
: null,
// 关键词搜索依赖这些字段;不返回完整 material,避免把 1~2KB 的 JSON 和图片数组带进大厅。
material: {
collect: { fields: keywordFields },
gameId: String(material.gameId ?? ''),
gameNickname: String(material.gameNickname ?? ''),
},
}
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */
export function resolveWorkerShareProductName(productName: unknown, quantity: number): string {
const text = String(productName || '').trim()
@@ -11,8 +11,7 @@ import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
import {
mapWorkCategory,
mapWorkOrderForWorker,
mapWorkOrderShare,
mapWorkOrderForHall,
normalizeOptionalId,
resolveWorkerPermissions,
} from './mappers.js'
@@ -53,6 +52,8 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
const { items, total } = await listWorkOrders({
page,
pageSize,
// 无限滚动只需要第一页的总数;后续页再 COUNT 一遍候选集纯属重复开销。
includeTotal: page === 1,
statuses: [WORK_ORDER_STATUS.OPEN],
keyword,
categoryId: categoryId || 0,
@@ -62,9 +63,18 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
excludeFilledSharing: true,
pinnedFirst: true,
})
const categories = (await listWorkCategories()).map(mapWorkCategory)
// 分类是全量配置,只在第一页返回;前端另有 /hall/categories 接口。
// 图标走 thumb 变体(缺失时服务端自动回退原图),避免 42px 图标下载 200~700KB 原图。
const categories =
page === 1
? (await listWorkCategories()).map((category) =>
mapWorkCategory(category, { iconVariant: 'thumb' }),
)
: []
// 非拼单页不必再查拼单表。
const hasSharingOrders = items.some((item) => item.sharing_enabled === true)
const orderIds = items.map((item) => Number(item.id))
const shares = await listWorkOrderSharesByOrderIds(orderIds)
const shares = hasSharingOrders ? await listWorkOrderSharesByOrderIds(orderIds) : []
const myShareByOrderId = new Map(
shares
.filter(
@@ -87,27 +97,23 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
if (share.status === 'joined') progress.pendingSubmissionCount += 1
sharingProgressByOrderId.set(orderId, progress)
}
const isInternalWorker = String(worker.worker_type || '').trim() === 'internal'
return {
items: items.map((item) => {
const myShare = myShareByOrderId.get(Number(item.id))
const mappedOrder = mapWorkOrderForWorker(item, permissions)
return {
...mappedOrder,
publishedAt: isInternalWorker
? item.published_at || item.hall_queued_at || item.created_at || null
: null,
myShare: myShare ? mapWorkOrderShare(myShare) : null,
sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || {
const orderId = Number(item.id)
return mapWorkOrderForHall(item, permissions, {
myShare: myShareByOrderId.get(orderId) || null,
sharingProgress: sharingProgressByOrderId.get(orderId) || {
joinedQuantity: 0,
pendingSubmissionCount: 0,
},
}
})
}),
pagination: { page, pageSize, total },
categories,
showSubcategories: hallConfig.showSubcategories,
allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null,
allIconUrl: hallConfig.allIconUrl
? refreshStoredFileUrl(hallConfig.allIconUrl, '', 'thumb')
: null,
visibleDelaySeconds,
}
})
@@ -116,11 +122,15 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
export async function getWorkerHallCategories(session: WorkerSession) {
requireActiveWorkerSession(session)
const hallConfig = getWorkerHallConfig()
const categories = (await listWorkCategories()).map(mapWorkCategory)
const categories = (await listWorkCategories()).map((category) =>
mapWorkCategory(category, { iconVariant: 'thumb' }),
)
return {
categories,
showSubcategories: hallConfig.showSubcategories,
allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null,
allIconUrl: hallConfig.allIconUrl
? refreshStoredFileUrl(hallConfig.allIconUrl, '', 'thumb')
: null,
}
}
@@ -37,6 +37,7 @@ import {
normalizeRequirementFields,
normalizeWorkerFreezeReason,
mapWorkOrderForWorker,
mapWorkOrderForHall,
mapWorkOrderAdmin,
mapWorkOrderSharingWorkers,
mapFinanceRequest,
@@ -1720,6 +1721,53 @@ test('mapWorkOrderForWorker reflects the personal first-view state', () => {
assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined)
})
test('mapWorkOrderForHall 只返回大厅卡片字段,并保留大厅生命周期状态', () => {
const workOrder = buildWorkOrderRow({
status: 'open',
product_name: '指挥官秘钥15个',
reward_amount: 10_500,
required_deposit_amount: 2_000,
sharing_enabled: true,
sharing_total_quantity: 15,
sharing_unit_reward: 700,
active_share_quantity: 1,
material_json: {
source: { orderId: 1, extra: 'x'.repeat(500) },
collect: {
fields: {
gameId: '123456',
gameNickname: '测试昵称',
gameAccount: 'acc',
roleName: 'role',
},
screenshots: [{ url: 'https://example.com/a.png' }],
},
},
requirement_json: { fields: [{ key: 'gameId', label: '游戏编号' }] },
acceptance_json: { files: [{ url: 'https://example.com/b.png' }] },
})
const share = buildWorkOrderShareRow()
const mapped = mapWorkOrderForHall(workOrder, resolveWorkerPermissions(buildWorkerUserRow()), {
myShare: share,
sharingProgress: { joinedQuantity: 1, pendingSubmissionCount: 0 },
})
assert.equal(mapped.status, 'open')
assert.equal(mapped.productName, '指挥官秘钥15个', '大厅标题不能按个人份额改成 2 个')
assert.equal(mapped.rewardAmount, 10_500, '大厅报酬按母单剩余总额展示')
assert.equal(mapped.myShare?.quantity, 2)
assert.deepEqual(mapped.material.collect.fields, {
gameId: '123456',
gameNickname: '测试昵称',
gameAccount: 'acc',
roleName: 'role',
})
assert.equal('requirement' in mapped, false)
assert.equal('acceptance' in mapped, false)
assert.equal('capabilities' in mapped, false)
assert.ok(JSON.stringify(mapped).length < 1500, '大厅单条快照应保持轻量')
})
test('mapWorkOrderForWorker 使用个人拼单份数、金额、状态和接单时间', () => {
const workOrder = buildWorkOrderRow({
product_name: '指挥官秘钥15个',