Merge branch 'main' into dev

This commit is contained in:
yml2213
2026-10-25 13:33:05 +08:00
37 changed files with 825 additions and 112 deletions
+5
View File
@@ -115,6 +115,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"}]
# 生产环境必须使用显式来源白名单,多个来源用逗号分隔。
+6 -1
View File
@@ -69,7 +69,9 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
admin: {
sessionSecret: '',
sessionTtlHours: 12,
// 后台:24h 空闲过期,登录后最多 7 天必须重新登录。
sessionIdleTtlHours: 24,
sessionAbsoluteTtlHours: 168,
defaultUsers: [],
},
@@ -124,6 +126,9 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
dailyLimit: 10,
},
orderTimelineVisible: false,
// 打手:7 天空闲过期,登录后最多 30 天必须重新登录。
sessionIdleTtlHours: 168,
sessionAbsoluteTtlHours: 720,
},
}
}
@@ -129,7 +129,8 @@ function createRuntimeConfig(): RuntimeConfig {
},
admin: {
sessionSecret: '',
sessionTtlHours: 12,
sessionIdleTtlHours: 24,
sessionAbsoluteTtlHours: 168,
defaultUsers: [],
},
platforms: {
@@ -178,6 +179,8 @@ function createRuntimeConfig(): RuntimeConfig {
dailyLimit: 10,
},
orderTimelineVisible: false,
sessionIdleTtlHours: 168,
sessionAbsoluteTtlHours: 720,
},
cors: {
allowedOrigins: ['*'],
+6 -1
View File
@@ -57,7 +57,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']),
@@ -193,7 +193,8 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
},
admin: {
sessionSecret: '0123456789abcdef0123456789abcdef',
sessionTtlHours: 12,
sessionIdleTtlHours: 24,
sessionAbsoluteTtlHours: 168,
defaultUsers: [
{
username: 'admin',
@@ -248,6 +249,8 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
dailyLimit: 10,
},
orderTimelineVisible: false,
sessionIdleTtlHours: 168,
sessionAbsoluteTtlHours: 720,
},
},
overrides,
+10 -1
View File
@@ -100,7 +100,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)
+5
View File
@@ -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') {
@@ -60,16 +60,47 @@ export async function getActiveAdminSession(
return result.rows[0] || null
}
export async function touchAdminSession(sessionId: string, now: string): Promise<boolean> {
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<string | null> {
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<boolean> {
@@ -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.*测试商品/)
@@ -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}
`
}
@@ -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<WorkOrderShareRow>(
`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<WorkOrderShareRow>(
`${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 }
})
@@ -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<boolean> {
const result = await query<{ valid: boolean }>(
export async function touchWorkerSession(input: {
sessionId: string
now: string
idleExpiresAt: string
absoluteExpiresAt: string
}): Promise<string | null> {
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(
+1 -1
View File
@@ -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]',
+2
View File
@@ -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<void> {
try {
req.adminSession = await verifyAdminSessionToken(extractBearerToken(req))
setSessionExpiryHeaders(res, req.adminSession)
next()
} catch (error) {
sendRouteError(res, error, '后台鉴权失败', '[admin/auth]')
@@ -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]',
@@ -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<void> {
try {
req.workerSession = await verifyWorkerSessionToken(extractWorkerBearerToken(req))
setSessionExpiryHeaders(res, req.workerSession)
next()
} catch (error) {
sendRouteError(res, error, '接单端鉴权失败', '[worker/auth]')
@@ -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<AdminSess
})
}
const expiresAt = String(payload?.exp || '').trim()
if (!expiresAt || Date.parse(expiresAt) <= Date.now()) {
const absoluteExpiresAt = String(payload?.exp || '').trim()
if (!absoluteExpiresAt || Date.parse(absoluteExpiresAt) <= Date.now()) {
throw createHttpError('后台登录已过期,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_expired',
@@ -362,14 +368,30 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
errorCode: 'admin_auth_invalid',
})
}
const activeSession = await getActiveAdminSession(sessionId, nowIso())
const now = nowIso()
const activeSession = await getActiveAdminSession(sessionId, now)
if (!activeSession || Number(activeSession.user_id) !== userId) {
throw createHttpError('后台登录态已失效,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_revoked',
})
}
await touchAdminSession(sessionId, nowIso())
const touchedExpiresAt = await touchAdminSession({
sessionId,
now,
idleExpiresAt: resolveSlidingSessionExpiresAt({
now,
idleHours: runtimeConfig.admin.sessionIdleTtlHours,
absoluteExpiresAt,
}),
absoluteExpiresAt,
})
if (!touchedExpiresAt) {
throw createHttpError('后台登录态已失效,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_revoked',
})
}
const user = await getAdminUserById(userId)
if (!user || user.status !== 'active') {
throw createHttpError('后台账号已不可用,请重新登录', {
@@ -393,7 +415,8 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
username: String(user.username || ''),
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
expiresAt,
expiresAt: touchedExpiresAt,
absoluteExpiresAt,
sessionVersion: currentSessionVersion,
deviceId: String(activeSession.device_id || ''),
permissions: await listAdminPermissionKeysForRole(normalizeAdminRole(user.role)),
@@ -406,12 +429,17 @@ export async function logoutAdmin(token: unknown): Promise<{ success: boolean; r
return { success: true, revoked }
}
export async function getAdminSessionSummary(token: unknown): Promise<JsonObject> {
export async function getAdminSessionSummary(
token: unknown,
res?: { setHeader(name: string, value: string): unknown },
): Promise<JsonObject> {
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 || ''),
@@ -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,
}
}
@@ -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: '余额不足,无法冻结所需押金',
}
@@ -186,6 +186,7 @@ function createCredentialSession(worker: WorkerUserRow): WorkerSession {
username: worker.username,
status: worker.status,
expiresAt: '',
absoluteExpiresAt: '',
sessionVersion: normalizeSessionVersion(worker.session_version),
}
}
@@ -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> = {}): WorkOrderRow {
return {
id: 1,
@@ -2773,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'), '缺少节流间隔常量')
@@ -3092,6 +3151,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(
@@ -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<WorkerSe
})
}
const expiresAt = String(payload.exp || '').trim()
if (!expiresAt || Date.parse(expiresAt) <= Date.now()) {
const absoluteExpiresAt = String(payload.exp || '').trim()
if (!absoluteExpiresAt || Date.parse(absoluteExpiresAt) <= Date.now()) {
throw createHttpError('登录已过期,请重新登录', {
statusCode: 401,
errorCode: 'worker_auth_expired',
@@ -133,8 +136,7 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
!sessionRecord ||
Number(sessionRecord.worker_id) !== Number(worker.id) ||
sessionRecord.status !== 'active' ||
Date.parse(String(sessionRecord.expires_at || '')) <= Date.now() ||
!(await touchWorkerSession(sessionId, nowIso()))
Date.parse(String(sessionRecord.expires_at || '')) <= Date.now()
) {
throw createHttpError('登录设备已被删除,请重新登录', {
statusCode: 401,
@@ -142,12 +144,31 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
})
}
const nowAt = nowIso()
const touchedExpiresAt = await touchWorkerSession({
sessionId,
now: nowAt,
idleExpiresAt: resolveSlidingSessionExpiresAt({
now: nowAt,
idleHours: runtimeConfig.worker?.sessionIdleTtlHours || 168,
absoluteExpiresAt,
}),
absoluteExpiresAt,
})
if (!touchedExpiresAt) {
throw createHttpError('登录设备已被删除,请重新登录', {
statusCode: 401,
errorCode: 'worker_auth_device_revoked',
})
}
const verifiedSession: WorkerSession = {
sessionId,
workerId: Number(worker.id),
username: worker.username,
status: worker.status,
expiresAt,
expiresAt: touchedExpiresAt,
absoluteExpiresAt,
sessionVersion: currentSessionVersion,
}
@@ -200,12 +221,17 @@ export async function deleteWorkerSession(session: WorkerSession, targetSessionI
}
}
export async function getWorkerSessionSummary(token: unknown) {
export async function getWorkerSessionSummary(
token: unknown,
res?: { setHeader(name: string, value: string): unknown },
) {
const session = await verifyWorkerSessionToken(token)
if (res) setSessionExpiryHeaders(res, session)
const worker = await getRequiredWorker(session.workerId)
return {
authenticated: true,
expiresAt: session.expiresAt,
absoluteExpiresAt: session.absoluteExpiresAt,
worker: mapWorkerUser(worker),
}
}
@@ -12,6 +12,7 @@ export type WorkerSession = {
username: string
status: string
expiresAt: string
absoluteExpiresAt: string
sessionVersion: number
}
+8 -1
View File
@@ -69,7 +69,10 @@ export type RuntimeConfig = {
}
admin: {
sessionSecret: string
sessionTtlHours: number
/** 后台空闲多久未访问即失效(每次访问滑动续期)。 */
sessionIdleTtlHours: number
/** 后台会话从登录起算的绝对上限,滑动续期不能突破。 */
sessionAbsoluteTtlHours: number
defaultUsers: AdminDefaultUser[]
}
platforms: {
@@ -119,6 +122,10 @@ export type RuntimeConfig = {
}
/** 打手端订单详情是否展示流转记录(默认关闭,仅后台可见) */
orderTimelineVisible: boolean
/** 打手空闲多久未访问即失效(每次访问滑动续期)。 */
sessionIdleTtlHours: number
/** 打手会话从登录起算的绝对上限,滑动续期不能突破。 */
sessionAbsoluteTtlHours: number
}
cors: {
allowedOrigins: string[]
+21
View File
@@ -0,0 +1,21 @@
type HeaderWriter = {
setHeader(name: string, value: string): unknown
}
type SessionExpiryLike = {
expiresAt?: string | null
absoluteExpiresAt?: string | null
}
/**
* 把滑动续期后的空闲到期时间回传给前端。
* Token 本身不重签(exp 是绝对上限),前端只更新本地空闲计时器。
*/
export function setSessionExpiryHeaders(res: HeaderWriter, session: SessionExpiryLike): void {
if (session.expiresAt) {
res.setHeader('X-Session-Expires-At', session.expiresAt)
}
if (session.absoluteExpiresAt) {
res.setHeader('X-Session-Absolute-Expires-At', session.absoluteExpiresAt)
}
}
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveSessionExpiryWindow, resolveSlidingSessionExpiresAt } from './session-window.js'
import { setSessionExpiryHeaders } from './session-headers.js'
test('登录时同时给出空闲到期与绝对到期(后台 24h / 7d)', () => {
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<string, string>()
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')
})
+41
View File
@@ -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()
}
+42 -2
View File
@@ -48,11 +48,14 @@ import {
} from '@/services/admin'
import {
clearAdminSession,
expireAdminSession,
getAdminDisplayName,
getAdminRole,
getAdminSessionExpiryDelay,
getAdminToken,
getAdminTokenExpiresAt,
getAdminUsername,
isAdminSessionStorageKey,
setAdminDisplayName,
setAdminPermissions,
} from '@/utils/admin-auth'
@@ -79,7 +82,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',
)
@@ -98,6 +101,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
@@ -123,7 +163,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 } })
},
})
+15 -4
View File
@@ -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',
+36 -3
View File
@@ -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<T> {
code: number
@@ -20,6 +20,7 @@ const http = axios.create({
http.interceptors.response.use(
(response) => {
applySessionExpiryHeaders(response)
const data = response.data as ApiEnvelope<unknown>
// 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<string, unknown> & { 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 ?? '')
+1
View File
@@ -127,6 +127,7 @@ export function fetchWorkerSession() {
return apiGet<{
authenticated: boolean
expiresAt: string
absoluteExpiresAt?: string
worker: WorkerUser
}>('/api/v1/worker/auth/session')
}
+2
View File
@@ -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
@@ -721,6 +721,7 @@ export type Pagination = {
export type WorkerLoginResponse = {
token: string
expiresAt: string
absoluteExpiresAt?: string
worker: WorkerUser
passwordWeak?: boolean
}
+41
View File
@@ -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()
+14
View File
@@ -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)
@@ -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 }
}
@@ -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],
)
})
/**
* 「我的订单」状态页签的实时删行判定。
*