From 874b093a4ba29e215b6f6079921732b70c4d2a34 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 21 Aug 2026 14:02:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=A7=E5=8E=85=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=AE=B9=E9=87=8F=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/config/app-config-keys.ts | 1 + .../db/migrations/051_worker_hall_queue.sql | 14 +++ .../repositories/worker-platform-repo.test.ts | 18 +++ .../src/repositories/worker-platform/types.ts | 3 + .../worker-platform/work-order-repo.ts | 106 ++++++++++++++++-- .../src/routes/admin/worker-platform.ts | 30 ++++- .../realtime/realtime-event-service.ts | 38 +++---- .../services/worker-platform/admin-service.ts | 45 +++++++- .../src/services/worker-platform/index.ts | 1 + .../publish-voucher-service.test.ts | 1 + .../worker-hall-config-service.test.ts | 23 ++++ .../worker-hall-config-service.ts | 39 +++++++ .../worker-platform-service.test.ts | 1 + .../worker-platform/worker-service.ts | 24 +++- apps/frontend/src/layouts/WorkerLayout.tsx | 4 + .../pages/admin/AdminWorkerPlatformPage.tsx | 10 +- .../pages/admin/panels/HallConfigPanel.tsx | 67 +++++++++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 8 +- .../src/services/admin/worker-platform.ts | 12 ++ apps/frontend/src/types/worker-platform.ts | 6 + 20 files changed, 407 insertions(+), 44 deletions(-) create mode 100644 apps/backend/src/db/migrations/051_worker_hall_queue.sql create mode 100644 apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/worker-hall-config-service.ts create mode 100644 apps/frontend/src/pages/admin/panels/HallConfigPanel.tsx diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 19068944..aee955d0 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -6,6 +6,7 @@ export const APP_CONFIG_KEYS = { workerPlatformNotifications: 'worker_platform_notifications', workerProductMatch: 'worker_product_match', workerAnnouncement: 'worker_announcement', + workerHall: 'worker_hall', cloudtentaclesSources: 'cloudtentacles_sources', cloudtentaclesSession: 'cloudtentacles_session', cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', diff --git a/apps/backend/src/db/migrations/051_worker_hall_queue.sql b/apps/backend/src/db/migrations/051_worker_hall_queue.sql new file mode 100644 index 00000000..95f10d2a --- /dev/null +++ b/apps/backend/src/db/migrations/051_worker_hall_queue.sql @@ -0,0 +1,14 @@ +-- 051_worker_hall_queue.sql —— 抢单大厅展示队列与容量查询索引。 + +ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS hall_queued_at TIMESTAMPTZ NULL; + +UPDATE work_orders +SET hall_queued_at = COALESCE(published_at, updated_at, created_at) +WHERE status = 'open' + AND hall_queued_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_work_orders_hall_queue + ON work_orders(hall_queued_at ASC, id ASC) + WHERE status = 'open'; + +COMMENT ON COLUMN work_orders.hall_queued_at IS '进入抢单大厅候选队列的时间;用于按先进先出限制大厅展示数量'; diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index 9fb00175..8410d684 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -85,3 +85,21 @@ test('未指定打手的运营状态统计使用一次全表拼单汇总', () => ) assert.doesNotMatch(query.joins, /worker_visible/) }) + +test('大厅容量先确定全局候选集合,再应用页面筛选条件', () => { + const query = buildWorkOrderWhere({ + statuses: ['open'], + keyword: '测试商品', + categoryId: 3, + hallCandidateLimit: 50, + visibleAfterIso: '2026-08-21T00:00:00.000Z', + excludeFilledSharing: true, + }) + + assert.match(query.whereClause, /candidate\.status = 'open'/) + assert.match(query.whereClause, /ORDER BY candidate\.hall_queued_at ASC NULLS LAST/) + assert.match(query.whereClause, /LIMIT \$4/) + const candidateSql = query.whereClause.slice(query.whereClause.indexOf('candidate.status')) + assert.doesNotMatch(candidateSql, /candidate\.category_id|candidate.*测试商品/) + assert.deepEqual(query.params, [['open'], '%测试商品%', 3, 50, '2026-08-21T00:00:00.000Z']) +}) diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 8b555690..e89e5c06 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -294,6 +294,7 @@ export type WorkOrderRow = { sharing_total_quantity: number sharing_unit_reward: number published_at: string | null + hall_queued_at: string | null pinned_at: string | null assigned_at: string | null submitted_at: string | null @@ -345,6 +346,8 @@ export type ListInput = { workerLevelIds?: number[] categoryId?: number workerSharingId?: number + /** 大厅候选集合的总展示上限;筛选与分页在候选集合内执行。 */ + hallCandidateLimit?: number visibleAfterIso?: string /** 抢单大厅隐藏已被拼完的工单。 */ excludeFilledSharing?: boolean diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index d9d1c962..7e695d99 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -271,6 +271,8 @@ export async function joinWorkOrderShare(input: { /** 立即抢单时在事务锁内承接全部剩余份数。 */ takeRemaining?: boolean maxActiveOrders: number + hallCandidateLimit?: number + visibleAfterIso?: string now: string }): Promise<{ share: WorkOrderShareRow | null @@ -299,6 +301,16 @@ export async function joinWorkOrderShare(input: { if (!workOrder || workOrder.status !== 'open') { return { share: null, failureReason: 'work_order_not_open' } } + if ( + input.hallCandidateLimit && + !(await isWorkOrderWithinHallCapacityWithClient(client, { + workOrderId: input.workOrderId, + hallCandidateLimit: input.hallCandidateLimit, + visibleAfterIso: input.visibleAfterIso, + })) + ) { + return { share: null, failureReason: 'work_order_not_open' } + } if (workOrder.sharing_enabled !== true) { return { share: null, failureReason: 'sharing_disabled' } } @@ -1291,6 +1303,7 @@ export async function listWorkOrders({ workerLevelIds = [], categoryId = 0, workerSharingId = 0, + hallCandidateLimit = 0, visibleAfterIso = '', excludeFilledSharing = false, vipEvidencePending = false, @@ -1307,6 +1320,7 @@ export async function listWorkOrders({ workerLevelIds, categoryId, workerSharingId, + hallCandidateLimit, visibleAfterIso, excludeFilledSharing, vipEvidencePending, @@ -1520,6 +1534,7 @@ export async function updateWorkOrder( | 'sharing_total_quantity' | 'sharing_unit_reward' | 'published_at' + | 'hall_queued_at' | 'pinned_at' | 'assigned_at' | 'submitted_at' @@ -1546,12 +1561,13 @@ export async function updateWorkOrder( sharing_total_quantity = $9, sharing_unit_reward = $10, published_at = $11, - pinned_at = $12, - assigned_at = $13, - submitted_at = $14, - accepted_at = $15, - updated_at = $16 - WHERE id = $17 + hall_queued_at = $12, + pinned_at = $13, + assigned_at = $14, + submitted_at = $15, + accepted_at = $16, + updated_at = $17 + WHERE id = $18 `, [ next.status, @@ -1565,6 +1581,7 @@ export async function updateWorkOrder( toPositiveInteger(next.sharing_total_quantity, 1), toPositiveInteger(next.sharing_unit_reward, 0), next.published_at || null, + next.hall_queued_at || null, next.pinned_at || null, next.assigned_at || null, next.submitted_at || null, @@ -1642,6 +1659,8 @@ export async function grabWorkOrder(input: { workerId: number depositAmount: number maxActiveOrders: number + hallCandidateLimit?: number + visibleAfterIso?: string deadlineAt: string | null now: string }): Promise { @@ -1664,6 +1683,20 @@ export async function grabWorkOrder(input: { } } + if ( + input.hallCandidateLimit && + !(await isWorkOrderWithinHallCapacityWithClient(client, { + workOrderId: input.workOrderId, + hallCandidateLimit: input.hallCandidateLimit, + visibleAfterIso: input.visibleAfterIso, + })) + ) { + return { + order: null, + failureReason: 'work_order_not_open', + } + } + const result = await client.query<{ id: number }>( ` UPDATE work_orders @@ -1887,6 +1920,7 @@ export async function cancelWorkerWorkOrder(input: { assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, + hall_queued_at = $1, updated_at = $1 WHERE id = $2 `, @@ -2370,7 +2404,8 @@ export async function reviewWorkerCancelRequest(input: { ) await client.query( `UPDATE work_orders - SET status = 'open', submitted_at = NULL, published_at = COALESCE(published_at, $1), updated_at = $1 + SET status = 'open', submitted_at = NULL, published_at = COALESCE(published_at, $1), + hall_queued_at = COALESCE(hall_queued_at, $1), updated_at = $1 WHERE id = $2`, [input.now, workOrder.id], ) @@ -2401,7 +2436,7 @@ export async function reviewWorkerCancelRequest(input: { await client.query( `UPDATE work_orders SET status = 'open', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, - published_at = $1, updated_at = $1 + published_at = $1, hall_queued_at = $1, updated_at = $1 WHERE id = $2`, [input.now, workOrder.id], ) @@ -2608,6 +2643,7 @@ export async function returnAssignedWorkOrderToHall(input: { assigned_at = NULL, deadline_at = NULL, published_at = $1, + hall_queued_at = $1, updated_at = $1 WHERE id = $2 `, @@ -3713,6 +3749,7 @@ export async function settleOverdueWorkOrder(input: { submitted_at = CASE WHEN $1 = 'open' THEN NULL ELSE submitted_at END, problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END, published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, + hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END, updated_at = $2 WHERE id = $3 `, @@ -4379,6 +4416,7 @@ export function buildWorkOrderWhere({ workerLevelIds = [], categoryId = 0, workerSharingId = 0, + hallCandidateLimit = 0, visibleAfterIso = '', excludeFilledSharing = false, vipEvidencePending = false, @@ -4531,9 +4569,13 @@ export function buildWorkOrderWhere({ params.push(categoryId) filters.push(`wo.category_id = $${params.length}`) } + if (hallCandidateLimit > 0) { + params.push(hallCandidateLimit) + filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`) + } if (visibleAfterIso) { params.push(visibleAfterIso) - filters.push(`(wo.published_at IS NULL OR wo.published_at <= $${params.length})`) + filters.push(`(wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $${params.length})`) } if (excludeFilledSharing) { filters.push(`( @@ -4548,6 +4590,52 @@ export function buildWorkOrderWhere({ } } +/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ +function buildHallCandidateIdsSql(limitPlaceholder: string) { + return ` + SELECT candidate.id + FROM work_orders candidate + LEFT JOIN ( + SELECT + work_order_id, + SUM(quantity) FILTER (WHERE status != 'cancelled') AS active_share_quantity + FROM work_order_shares + GROUP BY work_order_id + ) candidate_shares ON candidate_shares.work_order_id = candidate.id + WHERE candidate.status = 'open' + AND ( + candidate.sharing_enabled IS NOT TRUE + OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity + ) + ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC + LIMIT ${limitPlaceholder} + ` +} + +/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */ +async function isWorkOrderWithinHallCapacityWithClient( + client: PoolClient, + input: { workOrderId: number; hallCandidateLimit: number; visibleAfterIso?: string | undefined }, +) { + const result = await client.query<{ visible: boolean }>( + ` + SELECT EXISTS( + SELECT 1 + FROM work_orders wo + WHERE wo.id = $1 + AND wo.id IN (${buildHallCandidateIdsSql('$2')}) + AND ($3::timestamptz IS NULL OR wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $3) + ) AS visible + `, + [ + input.workOrderId, + input.hallCandidateLimit, + input.visibleAfterIso ? input.visibleAfterIso : null, + ], + ) + return result.rows[0]?.visible === true +} + function buildWorkProductRuleWhere({ enabled = null, keyword = '', diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index c49f3a80..376bbaf3 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -16,6 +16,7 @@ import { deleteAdminWorkOrder, deleteAdminWorkerLevel, getAdminWorkerFinanceConfig, + getAdminWorkerHallConfig, getAdminWorkerProductMatchConfig, getAdminWorkerAnnouncementConfig, getAdminWorkerPlatformNotificationConfig, @@ -46,6 +47,7 @@ import { reviewAdminWorkerUser, resetAdminWorkerPassword, saveAdminWorkerFinanceConfig, + saveAdminWorkerHallConfig, saveAdminWorkerProductMatchConfig, saveAdminWorkerAnnouncementConfig, saveAdminWorkerPlatformNotificationConfig, @@ -285,6 +287,32 @@ router.get( }), ) +router.get( + '/worker-platform/hall-config', + requireAdminRoles(['admin', 'operator']), + createJsonHandler(() => getAdminWorkerHallConfig(), { + successMessage: 'ok', + errorMessage: '读取大厅配置失败', + scope: '[admin/worker-platform/hall-config]', + }), +) + +router.post( + '/worker-platform/hall-config', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => saveAdminWorkerHallConfig(req.body || {}), { + successMessage: '大厅配置已保存', + errorMessage: '保存大厅配置失败', + scope: '[admin/worker-platform/hall-config]', + audit: (_req, data) => ({ + action: 'worker_hall_config_saved', + targetType: 'worker_hall_config', + targetId: 'worker_hall', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.post( '/worker-platform/announcement', requireAdminRoles(['admin']), @@ -800,7 +828,7 @@ router.post( (req) => publishAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''), { - successMessage: '订单已发布到抢单大厅', + successMessage: '订单已进入大厅队列', errorMessage: '发布接单订单失败', scope: '[admin/worker-platform/orders/:workOrderId/publish]', audit: (req, data) => ({ diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index e12aacb8..64a59619 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -169,6 +169,16 @@ export function publishWorkOrderRealtimeChange(input: { }) } +/** 大厅全局配置变化时刷新所有打手的候选集合。 */ +export function publishWorkerHallRealtimeRefresh() { + publishRealtimeEvent({ + type: 'work_order.changed', + scopes: ['worker_hall'], + operation: 'refresh', + broadcastWorkers: true, + }) +} + /** 资金申请变更会同步后台资金页与申请所属打手的钱包页面。 */ export function publishWorkerFinanceRealtimeChange(input: { requestId: number @@ -282,17 +292,13 @@ async function publishWorkOrderSnapshots(input: { }) if (input.hallChanged) { - const sharingFilled = isWorkOrderSharingFilled( - workOrder.sharing_enabled === true, - Number(workOrder.sharing_total_quantity || 1), - joinedQuantity, - ) publishRealtimeEvent({ type: 'work_order.changed', entityId: workOrderId, scopes: ['worker_hall'], - operation: workOrder.status === 'open' && !sharingFilled ? 'upsert' : 'remove', - data: mapWorkerHallWorkOrder(workOrder, shares), + // 队首补位可能影响多张订单,客户端需要重新读取大厅快照。 + operation: 'refresh', + data: { workOrderId }, broadcastWorkers: true, }) } @@ -329,24 +335,6 @@ async function publishWorkOrderSnapshots(input: { } } -function mapWorkerHallWorkOrder( - workOrder: NonNullable>>, - shares: Awaited>, -) { - const mapped = mapWorkOrderAdmin(workOrder) - const { worker: _worker, orderId: _orderId, orderItemId: _orderItemId, ...visibleOrder } = mapped - const joinedQuantity = shares - .filter((share) => share.status !== 'cancelled') - .reduce((sum, share) => sum + Number(share.quantity || 0), 0) - const pendingSubmissionCount = shares.filter((share) => share.status === 'joined').length - return { - ...visibleOrder, - // 大厅接收端根据自身等级重新计算实际冻结金额,避免不同等级看到同一笔错误押金。 - requiredDepositAmount: mapped.requiredDepositAmount, - sharingProgress: { joinedQuantity, pendingSubmissionCount }, - } -} - async function publishWorkerFinanceSnapshots(input: { requestId: number workerId: number diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index cc036c92..94bf4697 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -91,6 +91,7 @@ import { getWorkerAnnouncementConfig, saveWorkerAnnouncementConfig, } from './worker-announcement-config-service.js' +import { getWorkerHallConfig, saveWorkerHallConfig } from './worker-hall-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkOrderMaterialAdminNotification, @@ -98,6 +99,7 @@ import { } from '../admin/admin-notification-service.js' import { publishWorkerFinanceRealtimeChange, + publishWorkerHallRealtimeRefresh, publishWorkerWalletRealtimeChange, publishWorkOrderRealtimeChange, } from '../realtime/realtime-event-service.js' @@ -2235,6 +2237,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN const updated = await updateWorkOrder(workOrder.id, { status: WORK_ORDER_STATUS.OPEN, published_at: now, + hall_queued_at: now, updated_at: now, }) if (!updated) { @@ -2260,7 +2263,20 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN now, }) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true }) - return { order: mapWorkOrderAdmin(updated), voucherConsume } + const hallConfig = getWorkerHallConfig() + const hallEntry = await listWorkOrders({ + page: 1, + pageSize: 1, + workOrderId: Number(workOrder.id), + statuses: [WORK_ORDER_STATUS.OPEN], + hallCandidateLimit: hallConfig.maxVisibleOrders, + excludeFilledSharing: true, + }) + return { + order: mapWorkOrderAdmin(updated), + voucherConsume, + hallVisible: hallEntry.total > 0, + } } export async function unpublishAdminWorkOrder(workOrderId: number | string, actorName = '') { @@ -2698,6 +2714,7 @@ async function resolveWorkOrderRealtimeWorkerIds(workOrder: WorkOrderRow) { } export async function getAdminWorkerPlatformSummary() { + const hallConfig = getWorkerHallConfig() const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([ listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }), listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.PENDING_MATERIAL] }), @@ -2717,11 +2734,35 @@ export async function getAdminWorkerPlatformSummary() { return { pendingWorkerCount: pendingWorkers.total, pendingMaterialCount: pendingMaterial.total, - openOrderCount: openOrders.total, + openOrderCount: Math.min(openOrders.total, hallConfig.maxVisibleOrders), + queuedHallOrderCount: Math.max(0, openOrders.total - hallConfig.maxVisibleOrders), + hallMaxVisibleOrders: hallConfig.maxVisibleOrders, inProgressOrderCount: inProgressOrders.total, } } +export async function getAdminWorkerHallConfig() { + const config = getWorkerHallConfig() + const openOrders = await listWorkOrders({ + page: 1, + pageSize: 1, + statuses: [WORK_ORDER_STATUS.OPEN], + excludeFilledSharing: true, + }) + return { + ...config, + visibleOrderCount: Math.min(openOrders.total, config.maxVisibleOrders), + queuedOrderCount: Math.max(0, openOrders.total - config.maxVisibleOrders), + } +} + +export async function saveAdminWorkerHallConfig(rawValue: unknown) { + await saveWorkerHallConfig(rawValue) + const config = await getAdminWorkerHallConfig() + publishWorkerHallRealtimeRefresh() + return config +} + export async function syncWorkerOrdersForSourceOrder( order: OrderRow, orderItems: OrderItemRow[], diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index fafdfd01..fcd678cb 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -3,3 +3,4 @@ export * from './worker-service.js' export * from './admin-service.js' export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' +export * from './worker-hall-config-service.js' diff --git a/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts b/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts index a5234b4a..4dda5335 100644 --- a/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts +++ b/apps/backend/src/services/worker-platform/publish-voucher-service.test.ts @@ -34,6 +34,7 @@ function buildWorkOrder(overrides: Partial = {}): WorkOrderRow { sharing_total_quantity: 1, sharing_unit_reward: 0, published_at: null, + hall_queued_at: null, pinned_at: null, assigned_at: null, submitted_at: null, diff --git a/apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts b/apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts new file mode 100644 index 00000000..838cf591 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-hall-config-service.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + createDefaultWorkerHallConfig, + normalizeWorkerHallConfig, +} from './worker-hall-config-service.js' + +test('大厅配置默认展示 50 个订单', () => { + assert.deepEqual(createDefaultWorkerHallConfig(), { maxVisibleOrders: 50 }) + assert.deepEqual(normalizeWorkerHallConfig({}), { maxVisibleOrders: 50 }) +}) + +test('大厅配置只接受 1 到 500 的整数', () => { + assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), { maxVisibleOrders: 88 }) + assert.deepEqual(normalizeWorkerHallConfig({ max_visible_orders: 999 }), { + maxVisibleOrders: 500, + }) + assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), { maxVisibleOrders: 1 }) + assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 1.5 }), { + maxVisibleOrders: 50, + }) +}) diff --git a/apps/backend/src/services/worker-platform/worker-hall-config-service.ts b/apps/backend/src/services/worker-platform/worker-hall-config-service.ts new file mode 100644 index 00000000..c6cc958a --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-hall-config-service.ts @@ -0,0 +1,39 @@ +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import type { JsonObject } from '../../types/json.js' +import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' + +export type WorkerHallConfig = { + maxVisibleOrders: number +} + +export function getWorkerHallConfig(): WorkerHallConfig { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerHall, + fallback: createDefaultWorkerHallConfig, + normalize: normalizeWorkerHallConfig, + }) +} + +export async function saveWorkerHallConfig(rawValue: unknown): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerHall, + value: rawValue, + normalize: normalizeWorkerHallConfig, + }) +} + +export function createDefaultWorkerHallConfig(): WorkerHallConfig { + return { maxVisibleOrders: 50 } +} + +export function normalizeWorkerHallConfig(rawValue: unknown): WorkerHallConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const value = Number(source.maxVisibleOrders ?? source.max_visible_orders) + return { + maxVisibleOrders: Number.isInteger(value) ? Math.min(500, Math.max(1, value)) : 50, + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index a52cf23d..f84ed977 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -831,6 +831,7 @@ function buildWorkOrderRow(overrides: Partial = {}): WorkOrderRow acceptance_json: '{}', problem_note: '', published_at: null, + hall_queued_at: null, assigned_at: null, submitted_at: null, accepted_at: null, diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 4fe73906..2c84c6ae 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -86,6 +86,7 @@ import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin- import { getSmsProvider } from '../sms/index.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js' +import { getWorkerHallConfig } from './worker-hall-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkerAcceptanceAdminNotification, @@ -1470,12 +1471,14 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) const visibleAfterIso = visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : '' + const hallConfig = getWorkerHallConfig() const { items, total } = await listWorkOrders({ page, pageSize, statuses: [WORK_ORDER_STATUS.OPEN], keyword: String(query.keyword || '').trim(), categoryId: categoryId || 0, + hallCandidateLimit: hallConfig.maxVisibleOrders, visibleAfterIso, excludeFilledSharing: true, pinnedFirst: true, @@ -1555,6 +1558,11 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) const permissions = resolveWorkerPermissions(worker) + const hallConfig = getWorkerHallConfig() + const now = nowIso() + const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) + const visibleAfterIso = + visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : '' const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { throw createHttpError('该订单已不可抢', { @@ -1570,7 +1578,9 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: quantity: 0, takeRemaining: true, maxActiveOrders: permissions.maxActiveOrders, - now: nowIso(), + hallCandidateLimit: hallConfig.maxVisibleOrders, + visibleAfterIso, + now, }) if (!result.share) { throw resolveWorkOrderShareJoinError(result.failureReason) @@ -1604,8 +1614,10 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: workerId: Number(worker.id), depositAmount: freezeAmount, maxActiveOrders: permissions.maxActiveOrders, - deadlineAt: resolveWorkOrderDeadlineAt(workOrder, nowIso()), - now: nowIso(), + hallCandidateLimit: hallConfig.maxVisibleOrders, + visibleAfterIso, + deadlineAt: resolveWorkOrderDeadlineAt(workOrder, now), + now, }) if (!grabbed.order) { throwGrabWorkOrderFailure(grabbed.failureReason) @@ -2036,6 +2048,10 @@ export async function joinWorkerSharingOrder( requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) const permissions = resolveWorkerPermissions(worker) + const hallConfig = getWorkerHallConfig() + const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) + const visibleAfterIso = + visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : '' const quantity = normalizePositiveInteger(payload.quantity, 1) const now = nowIso() const result = await joinWorkOrderShare({ @@ -2043,6 +2059,8 @@ export async function joinWorkerSharingOrder( workerId: Number(worker.id), quantity, maxActiveOrders: permissions.maxActiveOrders, + hallCandidateLimit: hallConfig.maxVisibleOrders, + visibleAfterIso, now, }) if (!result.share) { diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 08b6e6fe..12b6b242 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -224,6 +224,10 @@ function patchWorkerHallCaches( queryClient: ReturnType, event: RealtimeEvent, ) { + if (event.operation === 'refresh') { + void queryClient.resetQueries({ queryKey: ['worker-hall-orders'] }) + return + } const workerSnapshot = applyWorkerDepositRule(queryClient, event.data) const item = workerSnapshot as { status?: unknown diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index cf892be5..1e9dedef 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -11,6 +11,7 @@ import AnnouncementPanel from './panels/AnnouncementPanel' import CancelRequestsPanel from './panels/CancelRequestsPanel' import FinancePanel from './panels/FinancePanel' import FeedbacksPanel from './panels/FeedbacksPanel' +import HallConfigPanel from './panels/HallConfigPanel' import LevelsPanel from './panels/LevelsPanel' import NotificationsPanel from './panels/NotificationsPanel' import ProductRulesPanel from './panels/ProductRulesPanel' @@ -66,6 +67,7 @@ export default function AdminWorkerPlatformPage() { { key: 'workers', label: '打手', children: }, { key: 'finance', label: '资金', children: }, { key: 'levels', label: '等级权限', children: }, + { key: 'hall', label: '大厅配置', children: }, ...(isAdmin ? [{ key: 'notifications', label: '通知配置', children: }] : []), @@ -90,6 +92,7 @@ function loadActiveTabPreference(): string { saved === 'workers' || saved === 'finance' || saved === 'levels' || + saved === 'hall' || saved === 'notifications' ? saved : 'orders' @@ -111,6 +114,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string { 'workers', 'finance', 'levels', + 'hall', 'notifications', ].includes(String(tab || '')) } @@ -125,7 +129,11 @@ function SummaryCards() { const items = [ { label: '待处理打手', value: summary?.pendingWorkerCount || 0 }, { label: '待完善订单', value: summary?.pendingMaterialCount || 0 }, - { label: '大厅订单', value: summary?.openOrderCount || 0 }, + { + label: '大厅展示', + value: `${summary?.openOrderCount || 0}/${summary?.hallMaxVisibleOrders || 0}`, + }, + { label: '等待入厅', value: summary?.queuedHallOrderCount || 0 }, { label: '代练中', value: summary?.inProgressOrderCount || 0 }, ] diff --git a/apps/frontend/src/pages/admin/panels/HallConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/HallConfigPanel.tsx new file mode 100644 index 00000000..e5a8c78b --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/HallConfigPanel.tsx @@ -0,0 +1,67 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { App, Button, Card, Descriptions, Form, InputNumber, Space } from 'antd' +import { useEffect, useState } from 'react' + +import { fetchAdminWorkerHallConfig, saveAdminWorkerHallConfig } from '@/services/admin' + +type HallConfigFormValues = { + maxVisibleOrders: number +} + +export default function HallConfigPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [saving, setSaving] = useState(false) + const configQuery = useQuery({ + queryKey: ['admin-worker-hall-config'], + queryFn: fetchAdminWorkerHallConfig, + }) + const config = configQuery.data?.data + + useEffect(() => { + if (config) form.setFieldsValue({ maxVisibleOrders: config.maxVisibleOrders }) + }, [config, form]) + + async function saveConfig(values: HallConfigFormValues) { + setSaving(true) + try { + await saveAdminWorkerHallConfig({ maxVisibleOrders: Number(values.maxVisibleOrders) }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-worker-hall-config'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }), + ]) + message.success('大厅配置已保存') + } catch (error) { + message.error(error instanceof Error ? error.message : '保存大厅配置失败') + } finally { + setSaving(false) + } + } + + return ( + + + + {config?.visibleOrderCount || 0} + {config?.queuedOrderCount || 0} + {config?.maxVisibleOrders || '-'} + +
+ + + + + + +
+
+
+ ) +} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 192bb4e8..db97e9e5 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -438,9 +438,11 @@ export default function WorkOrdersPanel() { return runAction( () => publishAdminWorkOrder(row.workOrderId), (response) => - response.data.voucherConsume.voucherCount > 0 - ? '电子凭证已自动核销,订单已发布到大厅' - : '订单已发布到大厅', + response.data.hallVisible + ? response.data.voucherConsume.voucherCount > 0 + ? '电子凭证已自动核销,订单已展示在大厅' + : '订单已展示在大厅' + : '订单已进入大厅等待队列', ) } diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index e31023f4..04bc7ffb 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -12,6 +12,7 @@ import type { KuaishouMatchSource, WorkProductMatchLog, WorkerFinanceConfig, + WorkerHallConfig, WorkerFinanceRequest, WorkerCancelRequest, WorkerOrderFeedback, @@ -38,10 +39,20 @@ export function fetchAdminWorkerPlatformSummary() { pendingWorkerCount: number pendingMaterialCount: number openOrderCount: number + queuedHallOrderCount: number + hallMaxVisibleOrders: number inProgressOrderCount: number }>('/api/v1/admin/worker-platform/summary') } +export function fetchAdminWorkerHallConfig() { + return apiGet('/api/v1/admin/worker-platform/hall-config') +} + +export function saveAdminWorkerHallConfig(payload: Pick) { + return apiPost('/api/v1/admin/worker-platform/hall-config', payload) +} + export function fetchAdminWorkerAnnouncement() { return apiGet('/api/v1/admin/worker-platform/announcement') } @@ -454,6 +465,7 @@ export function publishAdminWorkOrder(workOrderId: number) { return apiPost<{ order: WorkOrder voucherConsume: WorkOrderVoucherConsumeResult + hallVisible: boolean }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`) } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index fa9b9753..b5cc79f7 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -199,6 +199,12 @@ export type WorkerAnnouncementConfig = { content: string } +export type WorkerHallConfig = { + maxVisibleOrders: number + visibleOrderCount: number + queuedOrderCount: number +} + export type WorkerWithdrawalAccount = { accountChannel: string accountName: string