Merge branch 'main' into dev
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user