From 81edf530dba0c76f6ebc895e048200c9f13593c9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 25 Oct 2026 13:08:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(worker-platform):=20=E6=8B=BC=E5=8D=95?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=90=8E=E5=8F=AF=E9=87=8D=E5=85=A5=EF=BC=8C?= =?UTF-8?q?=E5=A4=A7=E5=8E=85=E7=BD=AE=E9=A1=B6=E5=A7=8B=E7=BB=88=E4=BC=98?= =?UTF-8?q?=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositories/worker-platform-repo.test.ts | 5 +- .../worker-platform/work-order-query-repo.ts | 10 +- .../worker-platform/work-order-share-repo.ts | 134 +++++++++++++----- .../worker-hall-action-service.ts | 4 + .../worker-platform-service.test.ts | 74 ++++++++++ .../src/utils/worker-realtime-work-orders.ts | 58 +++++++- .../tests/worker-realtime-work-orders.test.ts | 78 ++++++++++ 7 files changed, 315 insertions(+), 48 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index be70e943..b6f2fe0b 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -120,7 +120,10 @@ test('大厅容量先确定全局候选集合,再应用页面筛选条件', () }) assert.match(query.whereClause, /candidate\.status = 'open'/) - assert.match(query.whereClause, /ORDER BY candidate\.hall_queued_at ASC NULLS LAST/) + assert.match( + query.whereClause, + /ORDER BY candidate\.pinned_at DESC NULLS LAST, 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.*测试商品/) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 60d7f2d5..49ef1738 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -1464,7 +1464,7 @@ export function buildWorkOrderWhere({ } } -/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ +/** 大厅容量先选出全局队首(置顶单优先占位),分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) { return ` SELECT ranked.id @@ -1473,14 +1473,15 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh candidate.id, candidate.category_id, candidate.hall_queued_at, + candidate.pinned_at, COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id, ROW_NUMBER() OVER ( PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id) - ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC + ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC ) AS primary_category_rank, ROW_NUMBER() OVER ( PARTITION BY candidate.category_id - ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC + ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC ) AS sub_category_rank FROM work_orders candidate LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id @@ -1509,7 +1510,8 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh OR ranked.category_id = ranked.primary_category_id OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int ) - ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC + -- 置顶单必须占住最终候选集的席位,不能被后面的新单挤出 limit。 + ORDER BY ranked.pinned_at DESC NULLS LAST, ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC LIMIT ${limitPlaceholder} ` } diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 417be135..6e56b837 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -111,6 +111,24 @@ export function resolveWorkOrderShareJoinQuantity(input: { return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity } +/** + * 同一打手再次参与同一拼单工单时的处理方式。 + * 份额表有 UNIQUE(work_order_id, worker_id),取消的份额会保留用于追溯, + * 所以重新拼单必须复用该行并重置为一条全新份额,不能直接插入。 + */ +export function resolveWorkOrderShareJoinMode(input: { + existingShareStatus?: string | null + takeRemaining?: boolean +}): 'create' | 'append' | 'rejoin' | 'reject' { + const status = String(input.existingShareStatus || '') + if (!status) return 'create' + // 取消即结束该次参与,允许打手把同一行重新启用为份额。 + if (status === 'cancelled') return 'rejoin' + // 立即抢单承接剩余份数时才允许在未提交的份额上追加。 + if (status === 'joined' && input.takeRemaining === true) return 'append' + return 'reject' +} + /** * 计算打手当前累计拼单份额实际需要冻结的押金。 * 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。 @@ -155,6 +173,8 @@ export async function joinWorkOrderShare(input: { | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' + | 'worker_share_exists' + | 'worker_share_locked' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null @@ -213,6 +233,7 @@ export async function joinWorkOrderShare(input: { return { share: null, failureReason: 'sharing_quantity_full' } } + // 取消的份额也一并查出:UNIQUE(work_order_id, worker_id) 下只能复用该行重新加入。 const existingShareResult = await client.query( `SELECT * FROM work_order_shares @@ -221,10 +242,19 @@ export async function joinWorkOrderShare(input: { [input.workOrderId, input.workerId], ) const existingShare = existingShareResult.rows[0] || null - if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) { - return { share: null, failureReason: 'sharing_quantity_full' } + const joinMode = resolveWorkOrderShareJoinMode({ + existingShareStatus: existingShare?.status ?? null, + takeRemaining: input.takeRemaining === true, + }) + if (joinMode === 'reject') { + return { + share: null, + failureReason: + existingShare?.status === 'joined' ? 'worker_share_exists' : 'worker_share_locked', + } } - if (!existingShare) { + const revivingCancelledShare = joinMode === 'rejoin' + if (joinMode === 'create' || revivingCancelledShare) { const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) if (activeOrderCount >= input.maxActiveOrders) { return { share: null, failureReason: 'worker_active_order_limit' } @@ -232,7 +262,12 @@ export async function joinWorkOrderShare(input: { } const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0) - const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity + // 取消的份额已释放份数与押金,重新加入时按一条全新份额计算。 + const currentQuantity = revivingCancelledShare ? 0 : Number(existingShare?.quantity || 0) + const currentFrozenDeposit = revivingCancelledShare + ? 0 + : Number(existingShare?.share_deposit || 0) + const nextQuantity = currentQuantity + requestedQuantity const shareReward = nextQuantity * unitReward const nextShareDeposit = resolveWorkOrderShareDepositAmount({ requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), @@ -241,12 +276,9 @@ export async function joinWorkOrderShare(input: { ...(input.depositFreeAmount === undefined ? {} : { depositFreeAmount: input.depositFreeAmount }), - currentFrozenDeposit: Number(existingShare?.share_deposit || 0), + currentFrozenDeposit, }) - const depositToFreeze = Math.max( - 0, - nextShareDeposit - Number(existingShare?.share_deposit || 0), - ) + const depositToFreeze = Math.max(0, nextShareDeposit - currentFrozenDeposit) const wallet = await getWorkerWalletWithClient(client, input.workerId) const available = Number(wallet?.available_amount || 0) @@ -254,39 +286,66 @@ export async function joinWorkOrderShare(input: { return { share: null, failureReason: 'worker_deposit_insufficient' } } - const shareResult = existingShare - ? await client.query<{ id: number }>( - ` - UPDATE work_order_shares - SET - quantity = $1, - unit_reward = $2, - share_reward = $3, - share_deposit = $4, - updated_at = $5 - WHERE id = $6 - RETURNING id - `, - [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], - ) - : await client.query<{ id: number }>( - ` + let shareId = 0 + if (!existingShare) { + const inserted = await client.query<{ id: number }>( + ` INSERT INTO work_order_shares ( work_order_id, worker_id, quantity, unit_reward, share_reward, share_deposit, status, acceptance_json, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7) RETURNING id `, - [ - input.workOrderId, - input.workerId, - requestedQuantity, - unitReward, - shareReward, - nextShareDeposit, - input.now, - ], - ) + [ + input.workOrderId, + input.workerId, + requestedQuantity, + unitReward, + shareReward, + nextShareDeposit, + input.now, + ], + ) + shareId = Number(inserted.rows[0]?.id || 0) + } else if (revivingCancelledShare) { + const revived = await client.query<{ id: number }>( + ` + UPDATE work_order_shares + SET + status = 'joined', + quantity = $1, + unit_reward = $2, + share_reward = $3, + share_deposit = $4, + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + created_at = $5, + updated_at = $5 + WHERE id = $6 + RETURNING id + `, + [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], + ) + shareId = Number(revived.rows[0]?.id || 0) + } else { + const appended = await client.query<{ id: number }>( + ` + UPDATE work_order_shares + SET + quantity = $1, + unit_reward = $2, + share_reward = $3, + share_deposit = $4, + updated_at = $5 + WHERE id = $6 + RETURNING id + `, + [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], + ) + shareId = Number(appended.rows[0]?.id || 0) + } if (depositToFreeze > 0) { const nextAvailable = available - depositToFreeze @@ -330,13 +389,14 @@ export async function joinWorkOrderShare(input: { workerId: input.workerId, quantity: requestedQuantity, takeRemaining: input.takeRemaining === true, + rejoined: revivingCancelledShare, }), now: input.now, }) const share = await client.query( `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [Number(shareResult.rows[0]?.id || 0)], + [shareId], ) return { share: share.rows[0] || null, failureReason: null } }) diff --git a/apps/backend/src/services/worker-platform/worker-hall-action-service.ts b/apps/backend/src/services/worker-platform/worker-hall-action-service.ts index 4c59b43f..5e3e879e 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-action-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-action-service.ts @@ -174,6 +174,8 @@ function resolveWorkOrderShareJoinError( | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' + | 'worker_share_exists' + | 'worker_share_locked' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null, @@ -183,6 +185,8 @@ function resolveWorkOrderShareJoinError( sharing_disabled: '该订单未启用拼单', work_order_owner_conflict: '你已整单接取该订单,无需拼单', sharing_quantity_full: '拼单数量已满或超出剩余数量', + worker_share_exists: '你已参与该拼单,剩余份数可用「立即抢单」追加', + worker_share_locked: '你的拼单份额已提交或已验收,不能再追加份数', worker_active_order_limit: '已达到当前等级最大同时接单量', worker_deposit_insufficient: '余额不足,无法冻结所需押金', } 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 e2d839e3..b64880f3 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 @@ -20,6 +20,7 @@ import { resolveWorkOrderShareCancellationStatus, resolveWorkOrderShareDepositAmount, resolveWorkOrderSharingCancellationPlan, + resolveWorkOrderShareJoinMode, resolveWorkOrderShareJoinQuantity, resolveOpenSharingSyncReward, } from '../../repositories/worker-platform/index.js' @@ -1660,6 +1661,60 @@ test('拼单份数全部承接后不再显示在抢单大厅', () => { assert.equal(isWorkOrderSharingFilled(false, 15, 15), false) }) +test('拼单份额取消后允许原打手重新加入', () => { + assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled' }), 'rejoin') + assert.equal( + resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled', takeRemaining: true }), + 'rejoin', + ) +}) + +test('拼单份额未提交时只有立即抢单可以追加份数', () => { + assert.equal( + resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined', takeRemaining: true }), + 'append', + ) + assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined' }), 'reject') +}) + +test('拼单份额已提交或已验收后不再允许追加份数', () => { + assert.equal( + resolveWorkOrderShareJoinMode({ existingShareStatus: 'submitted', takeRemaining: true }), + 'reject', + ) + assert.equal( + resolveWorkOrderShareJoinMode({ existingShareStatus: 'accepted', takeRemaining: true }), + 'reject', + ) +}) + +test('打手首次参与拼单时新建份额', () => { + assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: null }), 'create') +}) + +test('取消的拼单份额重新加入必须复用原行并重置超时与验收状态', async () => { + const { readFile } = await import('node:fs/promises') + const shareRepoSrc = await readFile( + new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + shareRepoSrc.includes('resolveWorkOrderShareJoinMode'), + 'joinWorkOrderShare 必须按份额状态区分新建、追加、重入与拒绝', + ) + assert.ok( + shareRepoSrc.includes("status = 'joined',") && + shareRepoSrc.includes('created_at = $5') && + shareRepoSrc.includes('submitted_at = NULL') && + shareRepoSrc.includes('accepted_at = NULL'), + '复用 cancelled 份额时必须重置 created_at(超时时钟)与验收时间,避免重入后立即被判超时', + ) + assert.ok( + shareRepoSrc.includes('const currentFrozenDeposit = revivingCancelledShare'), + '取消份额的押金已退还,重新加入时必须按已有押金为 0 重新计算', + ) +}) + function buildWorkOrderRow(overrides: Partial = {}): WorkOrderRow { return { id: 1, @@ -3092,6 +3147,25 @@ test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分 assert.ok(src.includes('rowsById.get(id)'), 'ANY() 不保证顺序,必须按 id 顺序还原分页结果') }) +test('大厅候选集必须优先保留置顶单,不能被容量上限挤出', async () => { + const { readFile } = await import('node:fs/promises') + const src = await readFile( + new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url), + 'utf8', + ) + const start = src.indexOf('function buildHallCandidateIdsSql') + const hallSql = src.slice(start, start + 2600) + assert.equal( + (hallSql.match(/candidate\.pinned_at DESC NULLS LAST/g) || []).length, + 2, + '主分类与子分类的窗口排名都必须优先置顶单', + ) + assert.ok( + hallSql.includes('ranked.pinned_at DESC NULLS LAST'), + '最终候选集 LIMIT 前必须优先置顶单,否则置顶单会被新单挤出大厅', + ) +}) + test('打手等级筛选必须先把等级解析成打手 id,并用 UNION 形式(不能用 OR + IN 子查询)', async () => { const { readFile } = await import('node:fs/promises') const src = await readFile( diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 01c414f3..34ede87c 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -163,17 +163,63 @@ export function patchWorkerWorkOrderListEnvelope( } } -/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新。 */ +function readWorkOrderId(item: unknown): number { + return isRecord(item) ? toNumber(item.workOrderId) : 0 +} + +function readPinnedAtMs(item: unknown): number | null { + if (!isRecord(item)) return null + const pinnedAt = String(item.pinnedAt || '').trim() + if (!pinnedAt) return null + const parsed = Date.parse(pinnedAt) + // 有置顶标记但时间不可解析时仍算置顶,宁可排前面也不能被普通单挤下去。 + return Number.isFinite(parsed) ? parsed : 0 +} + +/** + * 大厅排序必须与服务端一致:置顶单永远在最前(pinned_at DESC),其余按 id DESC。 + * 实时事件把新单插入第 1 页后必须重排,否则新单会插到置顶单上面。 + */ +export function sortWorkerHallItems(items: unknown[]): unknown[] { + return items.slice().sort((left, right) => { + const leftPinnedAt = readPinnedAtMs(left) + const rightPinnedAt = readPinnedAtMs(right) + if (leftPinnedAt !== null && rightPinnedAt === null) return -1 + if (leftPinnedAt === null && rightPinnedAt !== null) return 1 + if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) { + return rightPinnedAt - leftPinnedAt + } + return readWorkOrderId(right) - readWorkOrderId(left) + }) +} + +/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新;最终按置顶优先重排。 */ export function patchWorkerWorkOrderInfiniteEnvelope( current: unknown, event: WorkerListEvent, insert = false, ): unknown { if (!isRecord(current) || !Array.isArray(current.pages)) return current - return { - ...current, - pages: current.pages.map((page: unknown, index: number) => - patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0), - ), + const pages = current.pages.map((page: unknown, index: number) => + patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0), + ) + + const lists: unknown[][] = [] + for (const page of pages) { + if (!isRecord(page) || !isRecord(page.data) || !Array.isArray(page.data.items)) { + return { ...current, pages } + } + lists.push(page.data.items) } + + // 第 2 页之后被置顶的单也要提到第 1 页最前,所以跨页统一排序再按原页长度切回。 + const sorted = sortWorkerHallItems(lists.flat()) + let cursor = 0 + const sortedPages = pages.map((page, index) => { + const length = lists[index]?.length || 0 + const items = sorted.slice(cursor, cursor + length) + cursor += length + return isRecord(page) && isRecord(page.data) ? { ...page, data: { ...page.data, items } } : page + }) + return { ...current, pages: sortedPages } } diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 41cade1e..f6c54b1d 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -140,6 +140,84 @@ test('无限下拉:只有第 1 页允许插入,其余页只就地更新', () assert.equal(itemsOf(patched.pages[1]).length, 2, '第 2 页不得插入') }) +test('大厅新单插入后,置顶单必须仍在最前方', () => { + const current = { + pages: [ + buildEnvelope([ + buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T10:00:00+08:00' }), + buildRow({ workOrderId: 200, status: 'open' }), + ]), + ], + pageParams: [1], + } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + current, + { operation: 'upsert', data: buildRow({ workOrderId: 300, status: 'open' }) }, + true, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [100, 300, 200], + '新单必须排在置顶单之后,未置顶部分仍按 id 倒序', + ) +}) + +test('多个置顶单之间按置顶时间倒序,且都排在普通单之前', () => { + const current = { + pages: [ + buildEnvelope([ + buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T09:00:00+08:00' }), + buildRow({ workOrderId: 200, status: 'open' }), + buildRow({ workOrderId: 300, status: 'open', pinnedAt: '2026-10-25T11:00:00+08:00' }), + ]), + ], + pageParams: [1], + } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + current, + { operation: 'upsert', data: buildRow({ workOrderId: 400, status: 'open' }) }, + true, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [300, 100, 400, 200], + ) +}) + +test('第 2 页的工单被置顶后必须提到第 1 页最前', () => { + const current = { + pages: [ + buildEnvelope([ + buildRow({ workOrderId: 300, status: 'open' }), + buildRow({ workOrderId: 299, status: 'open' }), + ]), + buildEnvelope([buildRow({ workOrderId: 100, status: 'open' })]), + ], + pageParams: [1, 2], + } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + current, + { + operation: 'upsert', + data: buildRow({ + workOrderId: 100, + status: 'open', + pinnedAt: '2026-10-25T12:00:00+08:00', + }), + }, + false, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [100, 300], + '置顶单要在第 1 页最前,被顶下去的普通单顺延到第 2 页', + ) + assert.deepEqual( + itemsOf(patched.pages[1]).map((item) => item.workOrderId), + [299], + ) +}) + /** * 「我的订单」状态页签的实时删行判定。 * From 2fb7aae13ffdcffb40ec939a2c002c3c70fa32d3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 25 Oct 2026 13:28:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=BD=95=E6=80=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=BB=91=E5=8A=A8=E7=BB=AD=E6=9C=9F=EF=BC=88?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=2024h/7d=EF=BC=8C=E6=89=93=E6=89=8B=207d/30d?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.server.example | 5 ++ apps/backend/src/config/defaults.ts | 7 +- apps/backend/src/config/env-overrides.test.ts | 5 +- apps/backend/src/config/env-overrides.ts | 7 +- .../src/config/runtime-validation.test.ts | 5 +- apps/backend/src/config/runtime-validation.ts | 11 ++- apps/backend/src/middleware/cors.ts | 5 ++ .../src/repositories/admin-session-repo.ts | 45 ++++++++-- .../worker-platform/worker-session-repo.ts | 45 ++++++---- apps/backend/src/routes/admin/auth.ts | 2 +- apps/backend/src/routes/admin/session.ts | 2 + apps/backend/src/routes/worker/auth-public.ts | 2 +- apps/backend/src/routes/worker/session.ts | 2 + .../src/services/admin/admin-auth-service.ts | 54 +++++++++--- .../worker-platform/worker-auth-utils.ts | 15 +++- .../worker-platform/worker-login-service.ts | 1 + .../worker-platform-service.test.ts | 8 +- .../worker-session-auth-service.ts | 38 +++++++-- .../worker-session-context-service.ts | 1 + apps/backend/src/types/runtime-config.ts | 9 +- apps/backend/src/utils/session-headers.ts | 21 +++++ apps/backend/src/utils/session-window.test.ts | 82 +++++++++++++++++++ apps/backend/src/utils/session-window.ts | 41 ++++++++++ apps/frontend/src/layouts/AdminLayout.tsx | 44 +++++++++- apps/frontend/src/layouts/WorkerLayout.tsx | 19 ++++- apps/frontend/src/lib/http.ts | 39 ++++++++- apps/frontend/src/services/worker.ts | 1 + apps/frontend/src/types/admin/auth.ts | 2 + apps/frontend/src/types/worker-platform.ts | 1 + apps/frontend/src/utils/admin-auth.ts | 41 ++++++++++ apps/frontend/src/utils/worker-auth.ts | 14 ++++ 31 files changed, 510 insertions(+), 64 deletions(-) create mode 100644 apps/backend/src/utils/session-headers.ts create mode 100644 apps/backend/src/utils/session-window.test.ts create mode 100644 apps/backend/src/utils/session-window.ts diff --git a/.env.server.example b/.env.server.example index e4110f1e..3e785dec 100644 --- a/.env.server.example +++ b/.env.server.example @@ -113,6 +113,11 @@ DB_BACKUP_KEEP=2 # Admin ADMIN_SESSION_SECRET=change-me-long-random-session-secret +# 会话滑动续期:不配置时后台 24h 空闲 / 7d 绝对上限,打手 7d 空闲 / 30d 绝对上限。 +# ADMIN_SESSION_IDLE_TTL_HOURS=24 +# ADMIN_SESSION_ABSOLUTE_TTL_HOURS=168 +# WORKER_SESSION_IDLE_TTL_HOURS=168 +# WORKER_SESSION_ABSOLUTE_TTL_HOURS=720 ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}] # 生产环境必须使用显式来源白名单,多个来源用逗号分隔。 diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 761c2382..2c6fd8cf 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -67,7 +67,9 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { admin: { sessionSecret: '', - sessionTtlHours: 12, + // 后台:24h 空闲过期,登录后最多 7 天必须重新登录。 + sessionIdleTtlHours: 24, + sessionAbsoluteTtlHours: 168, defaultUsers: [], }, @@ -122,6 +124,9 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { dailyLimit: 10, }, orderTimelineVisible: false, + // 打手:7 天空闲过期,登录后最多 30 天必须重新登录。 + sessionIdleTtlHours: 168, + sessionAbsoluteTtlHours: 720, }, } } diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index 1e905708..7952588b 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -128,7 +128,8 @@ function createRuntimeConfig(): RuntimeConfig { }, admin: { sessionSecret: '', - sessionTtlHours: 12, + sessionIdleTtlHours: 24, + sessionAbsoluteTtlHours: 168, defaultUsers: [], }, platforms: { @@ -177,6 +178,8 @@ function createRuntimeConfig(): RuntimeConfig { dailyLimit: 10, }, orderTimelineVisible: false, + sessionIdleTtlHours: 168, + sessionAbsoluteTtlHours: 720, }, cors: { allowedOrigins: ['*'], diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index a46557fa..7957e163 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -56,7 +56,12 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']), integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']), stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']), - integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']), + // 兼容旧变量:固定 TTL 时代按“绝对上限”理解,空闲窗口仍用新默认值并被绝对上限钳制。 + integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']), + integerEnv('ADMIN_SESSION_IDLE_TTL_HOURS', ['admin', 'sessionIdleTtlHours']), + integerEnv('ADMIN_SESSION_ABSOLUTE_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']), + integerEnv('WORKER_SESSION_IDLE_TTL_HOURS', ['worker', 'sessionIdleTtlHours']), + integerEnv('WORKER_SESSION_ABSOLUTE_TTL_HOURS', ['worker', 'sessionAbsoluteTtlHours']), adminUsersJsonEnv('ADMIN_DEFAULT_USERS_JSON', ['admin', 'defaultUsers']), stringEnv('KAQUAN91_USER_ID', ['platforms', 'ninetyone', 'userId']), stringEnv('KAQUAN91_SECRET', ['platforms', 'ninetyone', 'secret']), diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index 6e677212..b462f28f 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -192,7 +192,8 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo }, admin: { sessionSecret: '0123456789abcdef0123456789abcdef', - sessionTtlHours: 12, + sessionIdleTtlHours: 24, + sessionAbsoluteTtlHours: 168, defaultUsers: [ { username: 'admin', @@ -247,6 +248,8 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo dailyLimit: 10, }, orderTimelineVisible: false, + sessionIdleTtlHours: 168, + sessionAbsoluteTtlHours: 720, }, }, overrides, diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 0bff6af6..2defbc07 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -97,7 +97,16 @@ export function validateRuntimeConfig( }) } requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 }) - requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) + requireInteger(issues, 'admin.sessionIdleTtlHours', config.admin?.sessionIdleTtlHours, { min: 1 }) + requireInteger(issues, 'admin.sessionAbsoluteTtlHours', config.admin?.sessionAbsoluteTtlHours, { + min: 1, + }) + requireInteger(issues, 'worker.sessionIdleTtlHours', config.worker?.sessionIdleTtlHours, { + min: 1, + }) + requireInteger(issues, 'worker.sessionAbsoluteTtlHours', config.worker?.sessionAbsoluteTtlHours, { + min: 1, + }) validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) diff --git a/apps/backend/src/middleware/cors.ts b/apps/backend/src/middleware/cors.ts index 9c817a20..6d5036e3 100644 --- a/apps/backend/src/middleware/cors.ts +++ b/apps/backend/src/middleware/cors.ts @@ -24,6 +24,11 @@ export function createCorsMiddleware( 'Content-Type, Authorization, X-Shop-Token, X-Poll-Token', ) res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + // 前端靠这两个响应头同步滑动续期后的到期时间。 + res.setHeader( + 'Access-Control-Expose-Headers', + 'X-Session-Expires-At, X-Session-Absolute-Expires-At', + ) res.setHeader('Access-Control-Max-Age', '86400') if (req.method === 'OPTIONS') { diff --git a/apps/backend/src/repositories/admin-session-repo.ts b/apps/backend/src/repositories/admin-session-repo.ts index 245eade7..5ce4f227 100644 --- a/apps/backend/src/repositories/admin-session-repo.ts +++ b/apps/backend/src/repositories/admin-session-repo.ts @@ -60,16 +60,47 @@ export async function getActiveAdminSession( return result.rows[0] || null } -export async function touchAdminSession(sessionId: string, now: string): Promise { - const result = await query( +/** 活跃请求的写入节流:last_seen_at 与滑动过期最多 5 分钟刷新一次。 */ +const ADMIN_SESSION_TOUCH_MIN_INTERVAL = '5 minutes' + +/** + * 校验会话有效并滑动续期。 + * 返回续期后的空闲到期时间;会话无效/已过期返回 null。 + */ +export async function touchAdminSession(input: { + sessionId: string + now: string + idleExpiresAt: string + absoluteExpiresAt: string +}): Promise { + const result = await query<{ expires_at: string | Date }>( ` - UPDATE admin_sessions - SET last_seen_at = $2, updated_at = $2 - WHERE session_id = $1 AND status = 'active' AND expires_at > $2 + WITH target AS ( + SELECT session_id, expires_at + FROM admin_sessions + WHERE session_id = $1 AND status = 'active' AND expires_at > $2 + ), touched AS ( + UPDATE admin_sessions s + SET last_seen_at = $2, + updated_at = $2, + expires_at = LEAST($3::timestamptz, $4::timestamptz) + FROM target + WHERE s.session_id = target.session_id + AND s.last_seen_at < ($2::timestamptz - INTERVAL '${ADMIN_SESSION_TOUCH_MIN_INTERVAL}') + AND s.expires_at < LEAST($3::timestamptz, $4::timestamptz) + RETURNING s.expires_at + ) + SELECT COALESCE( + (SELECT expires_at FROM touched), + (SELECT expires_at FROM target) + ) AS expires_at `, - [sessionId, now], + [input.sessionId, input.now, input.idleExpiresAt, input.absoluteExpiresAt], ) - return result.rowCount === 1 + const expiresAt = result.rows[0]?.expires_at + if (!expiresAt) return null + const parsed = new Date(expiresAt).getTime() + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null } export async function revokeAdminSession(sessionId: string, now: string): Promise { diff --git a/apps/backend/src/repositories/worker-platform/worker-session-repo.ts b/apps/backend/src/repositories/worker-platform/worker-session-repo.ts index 2177392a..5f5c5927 100644 --- a/apps/backend/src/repositories/worker-platform/worker-session-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-session-repo.ts @@ -84,40 +84,49 @@ export async function getWorkerSessionBySessionId( } /** - * 校验会话仍然有效,并顺带刷新 last_seen_at。 + * 校验会话仍然有效,并滑动续期空闲窗口。 * - * 这里必须同时满足两个约束,所以不能用"直接跳过"的写法: - * 1. 返回值是**鉴权有效性**——上层 `!(await touchWorkerSession(...))` 为真就直接 401 - * 「登录设备已被删除」,跳过查询会把所有正常请求打成未登录; - * 2. 也不能像以前那样每个请求都真写一次:32 天里 last_seen_at 被写了 3300 万次, - * 占数据库总执行时间的 2.8%,还在 worker_sessions 上堆出 14% 的死元组。 - * - * 因此改成「一条语句拿到有效性 + 只在超过 5 分钟没刷新时才写」。 - * 副作用只有 last_seen_at 精度变粗(用于设备列表排序与展示), - * 不影响会话有效期:expires_at 在建会话时确定,不做滑动续期。 + * 约束: + * 1. 返回值是鉴权有效性——null 表示会话已失效/被删除,上层直接 401; + * 2. 写入要节流:32 天里 last_seen_at 被写了 3300 万次,占数据库总执行时间的 2.8%; + * 3. 续期后的 expires_at 不得超过 Token 里的绝对上限,绝对上限只能重新登录突破。 */ const WORKER_SESSION_TOUCH_MIN_INTERVAL = '5 minutes' -export async function touchWorkerSession(sessionId: string, now: string): Promise { - const result = await query<{ valid: boolean }>( +export async function touchWorkerSession(input: { + sessionId: string + now: string + idleExpiresAt: string + absoluteExpiresAt: string +}): Promise { + const result = await query<{ expires_at: string | Date }>( ` WITH target AS ( - SELECT session_id + SELECT session_id, expires_at FROM worker_sessions WHERE session_id = $1 AND status = 'active' AND expires_at > $2 ), touched AS ( UPDATE worker_sessions ws - SET last_seen_at = $2, updated_at = $2 + SET last_seen_at = $2, + updated_at = $2, + expires_at = LEAST($3::timestamptz, $4::timestamptz) FROM target WHERE ws.session_id = target.session_id AND ws.last_seen_at < ($2::timestamptz - INTERVAL '${WORKER_SESSION_TOUCH_MIN_INTERVAL}') - RETURNING ws.session_id + AND ws.expires_at < LEAST($3::timestamptz, $4::timestamptz) + RETURNING ws.expires_at ) - SELECT EXISTS (SELECT 1 FROM target) AS valid + SELECT COALESCE( + (SELECT expires_at FROM touched), + (SELECT expires_at FROM target) + ) AS expires_at `, - [String(sessionId || '').trim(), now], + [input.sessionId, input.now, input.idleExpiresAt, input.absoluteExpiresAt], ) - return Boolean(result.rows[0]?.valid) + const expiresAt = result.rows[0]?.expires_at + if (!expiresAt) return null + const parsed = new Date(expiresAt).getTime() + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null } export async function revokeWorkerSession( diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts index e268cf29..2b8b675c 100644 --- a/apps/backend/src/routes/admin/auth.ts +++ b/apps/backend/src/routes/admin/auth.ts @@ -110,7 +110,7 @@ router.post( router.get( '/auth/session', - createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { + createJsonHandler((req, res) => getAdminSessionSummary(extractBearerToken(req), res), { successMessage: 'ok', errorMessage: '读取后台登录态失败', scope: '[admin/auth/session]', diff --git a/apps/backend/src/routes/admin/session.ts b/apps/backend/src/routes/admin/session.ts index d907669c..8a473661 100644 --- a/apps/backend/src/routes/admin/session.ts +++ b/apps/backend/src/routes/admin/session.ts @@ -5,6 +5,7 @@ import { } from '../../services/admin/admin-auth-service.js' import { writeAdminAuditLog } from '../../services/admin/admin-audit-service.js' import { buildSuccessPayload, createHttpError, sendRouteError } from '../../utils/http.js' +import { setSessionExpiryHeaders } from '../../utils/session-headers.js' import { logWarn } from '../../utils/logger.js' import type { AdminSession } from '../../services/admin/admin-auth-service.js' @@ -63,6 +64,7 @@ export async function requireAdminSession( ): Promise { try { req.adminSession = await verifyAdminSessionToken(extractBearerToken(req)) + setSessionExpiryHeaders(res, req.adminSession) next() } catch (error) { sendRouteError(res, error, '后台鉴权失败', '[admin/auth]') diff --git a/apps/backend/src/routes/worker/auth-public.ts b/apps/backend/src/routes/worker/auth-public.ts index 82915b3f..61656194 100644 --- a/apps/backend/src/routes/worker/auth-public.ts +++ b/apps/backend/src/routes/worker/auth-public.ts @@ -184,7 +184,7 @@ router.post( router.get( '/auth/session', - createRouteHandler((req) => getWorkerSessionSummary(extractWorkerBearerToken(req)), { + createRouteHandler((req, res) => getWorkerSessionSummary(extractWorkerBearerToken(req), res), { successMessage: 'ok', errorMessage: '读取接单登录态失败', scope: '[worker/auth/session]', diff --git a/apps/backend/src/routes/worker/session.ts b/apps/backend/src/routes/worker/session.ts index c704fcdd..ed534d55 100644 --- a/apps/backend/src/routes/worker/session.ts +++ b/apps/backend/src/routes/worker/session.ts @@ -6,6 +6,7 @@ import { type WorkerSession, } from '../../services/worker-platform/index.js' import { createHttpError, sendRouteError } from '../../utils/http.js' +import { setSessionExpiryHeaders } from '../../utils/session-headers.js' export async function requireWorkerSession( req: Request, @@ -14,6 +15,7 @@ export async function requireWorkerSession( ): Promise { try { req.workerSession = await verifyWorkerSessionToken(extractWorkerBearerToken(req)) + setSessionExpiryHeaders(res, req.workerSession) next() } catch (error) { sendRouteError(res, error, '接单端鉴权失败', '[worker/auth]') diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index 680637c4..31404da3 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -18,7 +18,12 @@ import { revokeAdminSession, touchAdminSession, } from '../../repositories/admin-session-repo.js' -import { addHours, nowIso } from '../../utils/time.js' +import { nowIso } from '../../utils/time.js' +import { + resolveSessionExpiryWindow, + resolveSlidingSessionExpiresAt, +} from '../../utils/session-window.js' +import { setSessionExpiryHeaders } from '../../utils/session-headers.js' import { createHttpError } from '../../utils/http.js' import { normalizePage, normalizePageSize } from './admin-query-utils.js' import { recordAdminLoginLog } from './admin-login-log-service.js' @@ -39,6 +44,7 @@ export type AdminSession = { displayName: string role: AdminRole expiresAt: string + absoluteExpiresAt: string sessionVersion: number deviceId: string permissions: string[] @@ -346,8 +352,8 @@ export async function verifyAdminSessionToken(token: unknown): Promise { +export async function getAdminSessionSummary( + token: unknown, + res?: { setHeader(name: string, value: string): unknown }, +): Promise { const session = await verifyAdminSessionToken(token) + if (res) setSessionExpiryHeaders(res, session) return { authenticated: true, expiresAt: session.expiresAt, + absoluteExpiresAt: session.absoluteExpiresAt, user: { userId: session.userId, username: session.username, @@ -738,7 +766,12 @@ export function ensureAdminAuthConfigured(): void { function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject { const issuedAt = nowIso() - const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12)) + // Token 的 exp 是绝对上限;DB 会话的 expires_at 是空闲窗口,会在每次访问时滑动。 + const { idleExpiresAt, absoluteExpiresAt } = resolveSessionExpiryWindow({ + now: issuedAt, + idleHours: Number(runtimeConfig.admin.sessionIdleTtlHours || 24), + absoluteHours: Number(runtimeConfig.admin.sessionAbsoluteTtlHours || 168), + }) const payload = { sid: crypto.randomBytes(12).toString('hex'), uid: Number(user.id), @@ -747,7 +780,7 @@ function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject { dev: String(deviceId || ''), ver: normalizeAdminSessionVersion(user.session_version), iat: issuedAt, - exp: expiresAt, + exp: absoluteExpiresAt, } const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') const signature = signPayload(encodedPayload) @@ -756,7 +789,8 @@ function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject { sessionId: payload.sid, issuedAt, token: `${encodedPayload}.${signature}`, - expiresAt, + expiresAt: idleExpiresAt, + absoluteExpiresAt, user: { userId: Number(user.id), username: String(user.username || ''), diff --git a/apps/backend/src/services/worker-platform/worker-auth-utils.ts b/apps/backend/src/services/worker-platform/worker-auth-utils.ts index 96951879..6ed3131a 100644 --- a/apps/backend/src/services/worker-platform/worker-auth-utils.ts +++ b/apps/backend/src/services/worker-platform/worker-auth-utils.ts @@ -3,12 +3,18 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' import type { WorkerUserRow } from '../../repositories/worker-platform/index.js' import { createHttpError } from '../../utils/http.js' -import { addHours, nowIso } from '../../utils/time.js' +import { nowIso } from '../../utils/time.js' +import { resolveSessionExpiryWindow } from '../../utils/session-window.js' import { normalizeSessionVersion } from './worker-normalizers.js' export function createWorkerSessionToken(worker: WorkerUserRow) { const issuedAt = nowIso() - const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12)) + // Token 的 exp 是绝对上限;DB 会话的 expires_at 是空闲窗口,会在每次访问时滑动。 + const { idleExpiresAt, absoluteExpiresAt } = resolveSessionExpiryWindow({ + now: issuedAt, + idleHours: Number(runtimeConfig.worker?.sessionIdleTtlHours || 168), + absoluteHours: Number(runtimeConfig.worker?.sessionAbsoluteTtlHours || 720), + }) const payload = { typ: 'worker', sid: crypto.randomBytes(12).toString('hex'), @@ -17,13 +23,14 @@ export function createWorkerSessionToken(worker: WorkerUserRow) { status: worker.status, ver: normalizeSessionVersion(worker.session_version), iat: issuedAt, - exp: expiresAt, + exp: absoluteExpiresAt, } const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') return { sessionId: payload.sid, token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`, - expiresAt, + expiresAt: idleExpiresAt, + absoluteExpiresAt, } } diff --git a/apps/backend/src/services/worker-platform/worker-login-service.ts b/apps/backend/src/services/worker-platform/worker-login-service.ts index 36ec08fd..b8fdaa86 100644 --- a/apps/backend/src/services/worker-platform/worker-login-service.ts +++ b/apps/backend/src/services/worker-platform/worker-login-service.ts @@ -186,6 +186,7 @@ function createCredentialSession(worker: WorkerUserRow): WorkerSession { username: worker.username, status: worker.status, expiresAt: '', + absoluteExpiresAt: '', sessionVersion: normalizeSessionVersion(worker.session_version), } } 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 b64880f3..7c598e81 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 @@ -2828,8 +2828,12 @@ test('会话 touch 必须同时保留鉴权语义与写入节流', async () => { src.indexOf('export async function touchWorkerSession'), src.indexOf('export async function revokeWorkerSession'), ) - // 返回值是鉴权有效性:上层 !(await touch) 会直接 401,不能因为"不写"就返回 false - assert.ok(body.includes('EXISTS (SELECT 1 FROM target) AS valid'), '必须仍然返回会话是否有效') + // 返回值是续期后的空闲到期时间:null 才会被上层判成 401 + assert.ok(body.includes('RETURNING ws.expires_at'), '必须返回滑动续期后的 expires_at') + assert.ok( + body.includes('LEAST($3::timestamptz, $4::timestamptz)'), + '滑动续期不能突破 Token 里的绝对上限', + ) assert.ok(body.includes("status = 'active'"), '有效性判定不能丢') // 写操作必须节流:32 天里被写了 3300 万次 assert.ok(body.includes('WORKER_SESSION_TOUCH_MIN_INTERVAL'), '缺少节流间隔常量') diff --git a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts index fbbb5f44..198b3adb 100644 --- a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts +++ b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts @@ -5,9 +5,12 @@ import { revokeWorkerSession, touchWorkerSession, } from '../../repositories/worker-platform/index.js' +import { runtimeConfig } from '../../config/runtime.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' +import { resolveSlidingSessionExpiresAt } from '../../utils/session-window.js' +import { setSessionExpiryHeaders } from '../../utils/session-headers.js' import { ensureWorkerAuthConfigured, isIgnorableWorkerAuthError, @@ -102,8 +105,8 @@ export async function verifyWorkerSessionToken(token: unknown): Promise { + const window = resolveSessionExpiryWindow({ + now: '2026-10-25T00:00:00.000Z', + idleHours: 24, + absoluteHours: 168, + }) + assert.equal(window.idleExpiresAt, '2026-10-26T00:00:00.000Z') + assert.equal(window.absoluteExpiresAt, '2026-11-01T00:00:00.000Z') +}) + +test('登录时同时给出空闲到期与绝对到期(打手 7d / 30d)', () => { + const window = resolveSessionExpiryWindow({ + now: '2026-10-25T00:00:00.000Z', + idleHours: 168, + absoluteHours: 720, + }) + assert.equal(window.idleExpiresAt, '2026-11-01T00:00:00.000Z') + assert.equal(window.absoluteExpiresAt, '2026-11-24T00:00:00.000Z') +}) + +test('绝对上限小于空闲窗口时以绝对上限为准', () => { + const window = resolveSessionExpiryWindow({ + now: '2026-10-25T00:00:00.000Z', + idleHours: 24, + absoluteHours: 12, + }) + assert.equal(window.idleExpiresAt, '2026-10-25T12:00:00.000Z') + assert.equal(window.absoluteExpiresAt, '2026-10-25T12:00:00.000Z') +}) + +test('滑动续期目标不得超过绝对上限', () => { + assert.equal( + resolveSlidingSessionExpiresAt({ + now: '2026-10-25T00:00:00.000Z', + idleHours: 24, + absoluteExpiresAt: '2026-11-01T00:00:00.000Z', + }), + '2026-10-26T00:00:00.000Z', + ) + assert.equal( + resolveSlidingSessionExpiresAt({ + now: '2026-10-31T18:00:00.000Z', + idleHours: 24, + absoluteExpiresAt: '2026-11-01T00:00:00.000Z', + }), + '2026-11-01T00:00:00.000Z', + ) +}) + +test('绝对上限缺失或非法时仍返回 now + 空闲窗口', () => { + assert.equal( + resolveSlidingSessionExpiresAt({ + now: '2026-10-25T00:00:00.000Z', + idleHours: 24, + absoluteExpiresAt: 'invalid', + }), + '2026-10-26T00:00:00.000Z', + ) +}) + +test('会话续期响应头同时回传空闲到期与绝对到期', () => { + const headers = new Map() + setSessionExpiryHeaders( + { + setHeader(name, value) { + headers.set(name, value) + }, + }, + { + expiresAt: '2026-10-26T00:00:00.000Z', + absoluteExpiresAt: '2026-11-01T00:00:00.000Z', + }, + ) + assert.equal(headers.get('X-Session-Expires-At'), '2026-10-26T00:00:00.000Z') + assert.equal(headers.get('X-Session-Absolute-Expires-At'), '2026-11-01T00:00:00.000Z') +}) diff --git a/apps/backend/src/utils/session-window.ts b/apps/backend/src/utils/session-window.ts new file mode 100644 index 00000000..73af721c --- /dev/null +++ b/apps/backend/src/utils/session-window.ts @@ -0,0 +1,41 @@ +import { addHours } from './time.js' + +export type SessionExpiryWindow = { + /** 空闲窗口:每次访问可滑动续期到的目标时间。 */ + idleExpiresAt: string + /** 绝对上限:从登录起算,任何续期都不能突破。 */ + absoluteExpiresAt: string +} + +function normalizeHours(value: unknown, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : fallback +} + +/** 登录时计算空闲到期与绝对到期;绝对上限小于空闲窗口时以绝对上限为准。 */ +export function resolveSessionExpiryWindow(input: { + now: string + idleHours: number + absoluteHours: number +}): SessionExpiryWindow { + const requestedIdleHours = normalizeHours(input.idleHours, 24) + const absoluteHours = normalizeHours(input.absoluteHours, requestedIdleHours) + const idleHours = Math.min(requestedIdleHours, absoluteHours) + return { + idleExpiresAt: addHours(input.now, idleHours), + absoluteExpiresAt: addHours(input.now, absoluteHours), + } +} + +/** 滑动续期目标:now + 空闲窗口,但不得超过 Token 里的绝对上限。 */ +export function resolveSlidingSessionExpiresAt(input: { + now: string + idleHours: number + absoluteExpiresAt: string +}): string { + const idleHours = normalizeHours(input.idleHours, 24) + const target = addHours(input.now, idleHours) + const absoluteMs = Date.parse(String(input.absoluteExpiresAt || '')) + if (!Number.isFinite(absoluteMs)) return target + return Date.parse(target) <= absoluteMs ? target : new Date(absoluteMs).toISOString() +} diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index babc4e64..4023c302 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -46,11 +46,14 @@ import { } from '@/services/admin' import { clearAdminSession, + expireAdminSession, getAdminDisplayName, getAdminRole, + getAdminSessionExpiryDelay, getAdminToken, getAdminTokenExpiresAt, getAdminUsername, + isAdminSessionStorageKey, setAdminDisplayName, setAdminPermissions, } from '@/utils/admin-auth' @@ -77,7 +80,7 @@ export default function AdminLayout() { ) const role = getAdminRole() const username = getAdminUsername() || 'admin' - const expiresAt = getAdminTokenExpiresAt() + const [expiresAt, setExpiresAt] = useState(() => getAdminTokenExpiresAt()) const [soundEnabled, setSoundEnabled] = useState( () => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1', ) @@ -96,6 +99,43 @@ export default function AdminLayout() { } }, [adminSessionQuery.data]) + useEffect(() => { + const navigateToLogin = () => { + if (!window.location.hash.startsWith('#/admin/login')) { + void navigate('/admin/login', { replace: true }) + } + } + const handleSessionExpired = () => navigateToLogin() + const handleSessionRenewed = () => setExpiresAt(getAdminTokenExpiresAt()) + const handleStorage = (event: StorageEvent) => { + if (!isAdminSessionStorageKey(event.key)) return + if (event.newValue === null) { + expireAdminSession() + navigateToLogin() + return + } + handleSessionRenewed() + } + + window.addEventListener('admin-session-expired', handleSessionExpired) + window.addEventListener('admin-session-renewed', handleSessionRenewed) + window.addEventListener('storage', handleStorage) + return () => { + window.removeEventListener('admin-session-expired', handleSessionExpired) + window.removeEventListener('admin-session-renewed', handleSessionRenewed) + window.removeEventListener('storage', handleStorage) + } + }, [navigate]) + + useEffect(() => { + const delay = getAdminSessionExpiryDelay(expiresAt) + if (delay === null) return + const timer = window.setTimeout(() => { + expireAdminSession() + }, delay) + return () => window.clearTimeout(timer) + }, [expiresAt]) + const displayName = adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username @@ -121,7 +161,7 @@ export default function AdminLayout() { onEvent: (event) => applyAdminRealtimeEvent(event, queryClient), // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { - clearAdminSession() + expireAdminSession() void navigate('/admin/login', { replace: true, state: { from: location } }) }, }) diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 68658ec5..8e1c9649 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -9,7 +9,7 @@ import { import { App, Button, Layout, Menu, Typography } from 'antd' import { useQuery, useQueryClient } from '@tanstack/react-query' import type { MenuProps } from 'antd' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router' import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker' @@ -29,6 +29,7 @@ import { getWorkerToken, getWorkerTokenExpiresAt, getWorkerUsername, + isWorkerSessionExpiryKey, isWorkerSessionStorageKey, } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' @@ -100,6 +101,8 @@ export default function WorkerLayout() { [afterSalesPendingCount], ) + const [sessionExpiresAt, setSessionExpiresAt] = useState(() => getWorkerTokenExpiresAt()) + useEffect(() => { const navigateToLogin = () => { if (window.location.pathname !== '/') { @@ -107,30 +110,38 @@ export default function WorkerLayout() { } } const handleSessionExpired = () => navigateToLogin() + const handleSessionRenewed = () => setSessionExpiresAt(getWorkerTokenExpiresAt()) const handleStorage = (event: StorageEvent) => { - if (isWorkerSessionStorageKey(event.key) && event.newValue === null) { + if (!isWorkerSessionStorageKey(event.key)) return + if (event.newValue === null) { expireWorkerSession() navigateToLogin() + return + } + if (isWorkerSessionExpiryKey(event.key)) { + handleSessionRenewed() } } window.addEventListener('worker-session-expired', handleSessionExpired) + window.addEventListener('worker-session-renewed', handleSessionRenewed) window.addEventListener('storage', handleStorage) return () => { window.removeEventListener('worker-session-expired', handleSessionExpired) + window.removeEventListener('worker-session-renewed', handleSessionRenewed) window.removeEventListener('storage', handleStorage) } }, [navigate]) useEffect(() => { - const delay = getWorkerSessionExpiryDelay(getWorkerTokenExpiresAt()) + const delay = getWorkerSessionExpiryDelay(sessionExpiresAt) if (delay === null) return const timer = window.setTimeout(() => { expireWorkerSession() }, delay) return () => window.clearTimeout(timer) - }, []) + }, [sessionExpiresAt]) useRealtimeEvents({ url: '/api/v1/worker/realtime', diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index d7c259f0..8998194e 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -1,7 +1,7 @@ import axios from 'axios' -import { clearAdminSession, getAdminToken } from '@/utils/admin-auth' +import { expireAdminSession, getAdminToken, setAdminSessionExpiresAt } from '@/utils/admin-auth' import { isWorkerSite } from '@/utils/site' -import { expireWorkerSession, getWorkerToken } from '@/utils/worker-auth' +import { expireWorkerSession, getWorkerToken, setWorkerSessionExpiresAt } from '@/utils/worker-auth' export interface ApiEnvelope { code: number @@ -20,6 +20,7 @@ const http = axios.create({ http.interceptors.response.use( (response) => { + applySessionExpiryHeaders(response) const data = response.data as ApiEnvelope // Business-level error: HTTP 200 but code !== 0 @@ -36,6 +37,7 @@ http.interceptors.response.use( return response.data }, (error) => { + applySessionExpiryHeaders(error?.response) const responseMessage = typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : '' const fallbackMessage = resolveFallbackHttpMessage(error) @@ -57,7 +59,7 @@ http.interceptors.response.use( const errorCode = String(error?.response?.data?.errorCode || '').trim() if (status === 401 && shouldClearAdminSession(errorCode)) { - clearAdminSession() + expireAdminSession() if ( window.location.hash.startsWith('#/admin') && @@ -105,6 +107,37 @@ http.interceptors.request.use((config) => { return config }) +/** + * 服务端每次校验会话都会回传滑动续期后的空闲到期时间; + * 这里同步到 localStorage,WorkerLayout / AdminLayout 的定时器会自动重排。 + */ +function applySessionExpiryHeaders( + response: + | { + config?: { url?: string } + headers?: Record & { get?: (name: string) => unknown } + } + | undefined, +) { + if (!response) return + const url = String(response.config?.url || '') + if (!url.startsWith('/api/v1/admin') && !url.startsWith('/api/v1/worker')) return + + const headers = response.headers + const raw = + typeof headers?.get === 'function' + ? headers.get('x-session-expires-at') + : headers?.['x-session-expires-at'] + const expiresAt = String(raw || '').trim() + if (!expiresAt) return + + if (url.startsWith('/api/v1/admin')) { + setAdminSessionExpiresAt(expiresAt) + } else { + setWorkerSessionExpiresAt(expiresAt) + } +} + function resolveFallbackHttpMessage(error: unknown) { const message = String((error as { message?: string })?.message ?? '') diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 4a77183e..338a2f6a 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -127,6 +127,7 @@ export function fetchWorkerSession() { return apiGet<{ authenticated: boolean expiresAt: string + absoluteExpiresAt?: string worker: WorkerUser }>('/api/v1/worker/auth/session') } diff --git a/apps/frontend/src/types/admin/auth.ts b/apps/frontend/src/types/admin/auth.ts index 0bda82f9..e2f375e3 100644 --- a/apps/frontend/src/types/admin/auth.ts +++ b/apps/frontend/src/types/admin/auth.ts @@ -4,6 +4,7 @@ export type AdminUserStatus = 'active' | 'disabled' export interface AdminLoginResponse { token: string expiresAt: string + absoluteExpiresAt?: string user: { userId: number username: string @@ -41,6 +42,7 @@ export interface AdminDeviceStatusResponse { export interface AdminSessionSummary { authenticated: boolean expiresAt: string + absoluteExpiresAt?: string user: { userId: number username: string diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 8d70feb1..9b6a7eb9 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -721,6 +721,7 @@ export type Pagination = { export type WorkerLoginResponse = { token: string expiresAt: string + absoluteExpiresAt?: string worker: WorkerUser passwordWeak?: boolean } diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts index 1b14e44e..1a54832f 100644 --- a/apps/frontend/src/utils/admin-auth.ts +++ b/apps/frontend/src/utils/admin-auth.ts @@ -6,6 +6,16 @@ const ADMIN_DISPLAY_NAME_KEY = 'order-site-admin-display-name' const ADMIN_ROLE_KEY = 'order-site-admin-role' const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions' +const ADMIN_SESSION_STORAGE_KEYS = new Set([ + ADMIN_TOKEN_KEY, + ADMIN_EXPIRES_AT_KEY, + ADMIN_USER_ID_KEY, + ADMIN_USERNAME_KEY, + ADMIN_DISPLAY_NAME_KEY, + ADMIN_ROLE_KEY, + ADMIN_PERMISSIONS_KEY, +]) + const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin' | 'finance', number> = { support: 1, operator: 2, @@ -143,6 +153,37 @@ export function clearAdminSession() { localStorage.removeItem(ADMIN_PERMISSIONS_KEY) } +/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */ +export function setAdminSessionExpiresAt(expiresAt: string) { + const normalized = String(expiresAt || '').trim() + if (!normalized || normalized === getAdminTokenExpiresAt()) return + localStorage.setItem(ADMIN_EXPIRES_AT_KEY, normalized) + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('admin-session-renewed')) + } +} + +/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */ +export function expireAdminSession() { + const hadSession = Boolean(getAdminToken() || getAdminTokenExpiresAt()) + clearAdminSession() + if (hadSession && typeof window !== 'undefined') { + window.dispatchEvent(new Event('admin-session-expired')) + } + return hadSession +} + +export function isAdminSessionStorageKey(key: string | null) { + return Boolean(key && ADMIN_SESSION_STORAGE_KEYS.has(key)) +} + +export function getAdminSessionExpiryDelay(expiresAt: string, now = Date.now()) { + if (!expiresAt) return null + const expiresAtMs = Date.parse(expiresAt) + if (!Number.isFinite(expiresAtMs)) return null + return Math.max(0, expiresAtMs - now) +} + export function hasAdminSession() { const token = getAdminToken() const expiresAt = getAdminTokenExpiresAt() diff --git a/apps/frontend/src/utils/worker-auth.ts b/apps/frontend/src/utils/worker-auth.ts index f9b2cae0..d3782bb3 100644 --- a/apps/frontend/src/utils/worker-auth.ts +++ b/apps/frontend/src/utils/worker-auth.ts @@ -73,6 +73,16 @@ export function clearWorkerSession() { localStorage.removeItem(WORKER_STATUS_KEY) } +/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */ +export function setWorkerSessionExpiresAt(expiresAt: string) { + const normalized = String(expiresAt || '').trim() + if (!normalized || normalized === getWorkerTokenExpiresAt()) return + localStorage.setItem(WORKER_EXPIRES_AT_KEY, normalized) + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('worker-session-renewed')) + } +} + /** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */ export function expireWorkerSession() { const hadSession = Boolean(getWorkerToken() || getWorkerTokenExpiresAt()) @@ -87,6 +97,10 @@ export function isWorkerSessionStorageKey(key: string | null) { return Boolean(key && WORKER_SESSION_STORAGE_KEYS.has(key)) } +export function isWorkerSessionExpiryKey(key: string | null) { + return key === WORKER_EXPIRES_AT_KEY +} + export function getWorkerSessionExpiryDelay(expiresAt: string, now = Date.now()) { if (!expiresAt) return null const expiresAtMs = Date.parse(expiresAt)