增加打手登录设备管理与三台设备限制

This commit is contained in:
yml2213
2026-08-17 15:26:40 +08:00
parent 8d18daa0fe
commit f938ab0cba
13 changed files with 722 additions and 20 deletions
@@ -0,0 +1,27 @@
-- 029_worker_sessions.sql —— 打手端登录设备会话。
CREATE TABLE IF NOT EXISTS worker_sessions (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE,
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL DEFAULT '',
device_type TEXT NOT NULL DEFAULT 'pc',
device_name TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
issued_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_worker_sessions_worker_status
ON worker_sessions(worker_id, status, expires_at DESC);
CREATE INDEX IF NOT EXISTS idx_worker_sessions_worker_device
ON worker_sessions(worker_id, device_type, device_id, status);
COMMENT ON TABLE worker_sessions IS '打手端登录会话与设备记录';
COMMENT ON COLUMN worker_sessions.device_type IS '设备类型:pc、mobile 等;所有设备合计每个账号最多 3 台';
@@ -37,6 +37,24 @@ export type WorkerUserRow = {
total_settled_amount?: number
}
export type WorkerSessionRow = {
id: number
session_id: string
worker_id: number
device_id: string
device_type: string
device_name: string
user_agent: string
ip_address: string
status: string
issued_at: string
last_seen_at: string
expires_at: string
revoked_at: string | null
created_at: string
updated_at: string
}
export type WorkerWalletRow = {
worker_id: number
available_amount: number
@@ -8,6 +8,7 @@ import type {
WorkerFinanceRequestRow,
WorkerLevelRow,
WorkerUserRow,
WorkerSessionRow,
WorkerWithdrawalAccountRow,
WorkerWalletLedgerRow,
WorkerWalletRow,
@@ -43,6 +44,165 @@ const WORKER_FINANCE_REQUEST_SELECT = `
LEFT JOIN worker_users wu ON wu.id = wfr.worker_id
`
export type CreateWorkerSessionInput = {
sessionId: string
workerId: number
deviceId: string
deviceType: string
deviceName: string
userAgent: string
ipAddress: string
issuedAt: string
expiresAt: string
now: string
maxDevices: number
}
export type CreateWorkerSessionResult = {
created: boolean
activeSessions: WorkerSessionRow[]
session: WorkerSessionRow | null
}
export async function createWorkerSessionRecord(
input: CreateWorkerSessionInput,
): Promise<CreateWorkerSessionResult> {
return withTransaction(async (client) => {
// 对账号加行锁,避免并发登录同时绕过设备数量限制。
await client.query('SELECT id FROM worker_users WHERE id = $1 FOR UPDATE', [input.workerId])
await client.query(
`
UPDATE worker_sessions
SET status = 'revoked', revoked_at = $2, updated_at = $2
WHERE worker_id = $1 AND status = 'active' AND expires_at <= $2
`,
[input.workerId, input.now],
)
await client.query(
`
UPDATE worker_sessions
SET status = 'revoked', revoked_at = $3, updated_at = $3
WHERE worker_id = $1 AND device_id = $2 AND status = 'active'
`,
[input.workerId, input.deviceId, input.now],
)
const activeResult = await client.query<WorkerSessionRow>(
`
SELECT * FROM worker_sessions
WHERE worker_id = $1 AND status = 'active' AND expires_at > $2
ORDER BY last_seen_at DESC, id DESC
`,
[input.workerId, input.now],
)
const activeSessions = activeResult.rows
if (activeSessions.length >= input.maxDevices) {
return { created: false, activeSessions, session: null }
}
const result = await client.query<WorkerSessionRow>(
`
INSERT INTO worker_sessions (
session_id, worker_id, device_id, device_type, device_name,
user_agent, ip_address, status, issued_at, last_seen_at,
expires_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $8, $9, $10, $10)
RETURNING *
`,
[
input.sessionId,
input.workerId,
input.deviceId,
input.deviceType,
input.deviceName,
input.userAgent,
input.ipAddress,
input.issuedAt,
input.expiresAt,
input.now,
],
)
return { created: true, activeSessions, session: result.rows[0] || null }
})
}
export async function getWorkerSessionBySessionId(
sessionId: string,
): Promise<WorkerSessionRow | null> {
const result = await query<WorkerSessionRow>(
'SELECT * FROM worker_sessions WHERE session_id = $1 LIMIT 1',
[String(sessionId || '').trim()],
)
return result.rows[0] || null
}
export async function touchWorkerSession(sessionId: string, now: string): Promise<boolean> {
const result = await query(
`
UPDATE worker_sessions
SET last_seen_at = $2, updated_at = $2
WHERE session_id = $1 AND status = 'active' AND expires_at > $2
`,
[String(sessionId || '').trim(), now],
)
return (result.rowCount || 0) > 0
}
export async function revokeWorkerSession(
workerId: number | string,
sessionId: string,
now: string,
): Promise<boolean> {
const result = await query(
`
UPDATE worker_sessions
SET status = 'revoked', revoked_at = $3, updated_at = $3
WHERE worker_id = $1 AND session_id = $2 AND status = 'active'
`,
[Number(workerId), String(sessionId || '').trim(), now],
)
return (result.rowCount || 0) > 0
}
export async function revokeAllWorkerSessions(
workerId: number | string,
now: string,
): Promise<number> {
const result = await query(
`
UPDATE worker_sessions
SET status = 'revoked', revoked_at = $2, updated_at = $2
WHERE worker_id = $1 AND status = 'active'
`,
[Number(workerId), now],
)
return result.rowCount || 0
}
export async function listWorkerSessions(
workerId: number | string,
now: string,
): Promise<WorkerSessionRow[]> {
await query(
`
UPDATE worker_sessions
SET status = 'revoked', revoked_at = $2, updated_at = $2
WHERE worker_id = $1 AND status = 'active' AND expires_at <= $2
`,
[Number(workerId), now],
)
const result = await query<WorkerSessionRow>(
`
SELECT * FROM worker_sessions
WHERE worker_id = $1 AND status = 'active' AND expires_at > $2
ORDER BY device_type ASC, last_seen_at DESC, id DESC
`,
[Number(workerId), now],
)
return result.rows
}
export async function getWorkerLevelByKey(levelKey: string): Promise<WorkerLevelRow | null> {
const result = await query<WorkerLevelRow>(
'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1',
+77 -4
View File
@@ -20,6 +20,10 @@ import {
listWorkerMyOrders,
getWorkerMyOrderEvents,
loginWorker,
getWorkerSessions,
deleteWorkerSession,
manageWorkerSessions,
deleteWorkerSessionWithCredentials,
logoutWorkerSession,
registerWorker,
sendWorkerSmsCode,
@@ -79,11 +83,59 @@ router.post(
max: 10,
key: getBodyFieldRateLimitKey('username'),
}),
createRouteHandler((req) => loginWorker(req.body?.username, req.body?.password), {
successMessage: '登录成功',
errorMessage: '接单端登录失败',
scope: '[worker/auth/login]',
createRouteHandler(
(req) =>
loginWorker(req.body?.username, req.body?.password, {
deviceId: req.body?.deviceId,
deviceType: req.body?.deviceType,
deviceName: req.body?.deviceName,
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
}),
{
successMessage: '登录成功',
errorMessage: '接单端登录失败',
scope: '[worker/auth/login]',
},
),
)
router.post(
'/auth/devices/manage/list',
createRateLimitMiddleware({
scope: 'worker:devices-manage',
windowMs: 60_000,
max: 10,
key: getBodyFieldRateLimitKey('username'),
}),
createRouteHandler((req) => manageWorkerSessions(req.body?.username, req.body?.password), {
successMessage: 'ok',
errorMessage: '读取登录设备失败',
scope: '[worker/auth/devices/manage/list]',
}),
)
router.post(
'/auth/devices/manage/delete',
createRateLimitMiddleware({
scope: 'worker:devices-manage-delete',
windowMs: 60_000,
max: 10,
key: getBodyFieldRateLimitKey('username'),
}),
createRouteHandler(
(req) =>
deleteWorkerSessionWithCredentials(
req.body?.username,
req.body?.password,
req.body?.sessionId,
),
{
successMessage: '登录设备已删除',
errorMessage: '删除登录设备失败',
scope: '[worker/auth/devices/manage/delete]',
},
),
)
router.get(
@@ -106,6 +158,27 @@ router.post(
router.use(requireWorkerSession)
router.get(
'/auth/devices',
createRouteHandler((req) => getWorkerSessions(getRequiredWorkerSession(req)), {
successMessage: 'ok',
errorMessage: '读取登录设备失败',
scope: '[worker/auth/devices]',
}),
)
router.delete(
'/auth/devices/:sessionId',
createRouteHandler(
(req) => deleteWorkerSession(getRequiredWorkerSession(req), req.params.sessionId),
{
successMessage: '登录设备已删除',
errorMessage: '删除登录设备失败',
scope: '[worker/auth/devices/delete]',
},
),
)
router.get('/realtime', (req, res) => {
openWorkerRealtimeStream(res, getRequiredWorkerSession(req).workerId)
})
@@ -41,6 +41,7 @@ import {
updateWorkOrder,
updateWorkOrderBasic,
updateWorkerUser,
revokeAllWorkerSessions,
upsertWorkerWithdrawalAccount,
upsertWorkCategory,
upsertWorkProductRule,
@@ -406,6 +407,7 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
reviewed_at: now,
updated_at: now,
})
await revokeAllWorkerSessions(worker.id, now)
return { worker: mapWorkerUser(updated || worker) }
}
@@ -49,6 +49,7 @@ export function createWorkerSession(worker: WorkerUserRow) {
}
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
return {
sessionId: payload.sid,
token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`,
expiresAt,
worker: mapWorkerUser(worker),
@@ -1055,6 +1056,7 @@ export const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
'worker_auth_expired',
'worker_auth_user_invalid',
'worker_auth_stale',
'worker_auth_device_revoked',
])
export type WorkOrderEventView = {
@@ -30,7 +30,12 @@ import {
getWorkOrderShare,
getWorkerOrderOverview,
grabWorkOrder,
incrementWorkerSessionVersion,
createWorkerSessionRecord,
getWorkerSessionBySessionId,
listWorkerSessions,
revokeWorkerSession,
revokeAllWorkerSessions,
touchWorkerSession,
joinWorkOrderShare,
listPendingMaterialWorkOrdersByPlatformOrderId,
listWorkerFinanceRequests,
@@ -60,6 +65,7 @@ import {
upsertWorkerLevel,
type WorkOrderRow,
type WorkOrderShareRow,
type WorkerSessionRow,
type WorkerUserRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
@@ -142,6 +148,8 @@ export type WorkerSession = {
sessionVersion: number
}
const WORKER_MAX_DEVICES = 3
export async function ensureWorkerPlatformDefaults() {
const now = nowIso()
const existingLevel = await getWorkerLevelByKey(DEFAULT_LEVEL_KEY)
@@ -362,11 +370,91 @@ async function generateInviteCode(_now: string): Promise<string> {
})
}
export async function loginWorker(username: unknown, password: unknown) {
export async function loginWorker(
username: unknown,
password: unknown,
deviceInfo: {
deviceId?: unknown
deviceType?: unknown
deviceName?: unknown
userAgent?: unknown
ipAddress?: unknown
} = {},
) {
ensureWorkerAuthConfigured()
const normalizedPassword = normalizePassword(password)
const worker = await authenticateWorkerCredentials(username, password)
const session = createWorkerSession(worker)
const deviceType = normalizeWorkerDeviceType(deviceInfo.deviceType)
const deviceId = normalizeWorkerDeviceId(deviceInfo.deviceId, session.sessionId)
const sessionRecord = await createWorkerSessionRecord({
sessionId: session.sessionId,
workerId: Number(worker.id),
deviceId,
deviceType,
deviceName: normalizeWorkerDeviceName(deviceInfo.deviceName, deviceType),
userAgent: String(deviceInfo.userAgent || '')
.trim()
.slice(0, 500),
ipAddress: String(deviceInfo.ipAddress || '')
.trim()
.slice(0, 100),
issuedAt: new Date().toISOString(),
expiresAt: session.expiresAt,
now: nowIso(),
maxDevices: WORKER_MAX_DEVICES,
})
if (!sessionRecord.created) {
throw createHttpError('在线设备已达到 3 台,请先删除旧的登录设备', {
statusCode: 409,
errorCode: 'worker_device_limit',
})
}
return {
...session,
passwordWeak: !isWorkerPasswordStrong(normalizedPassword),
}
}
export async function manageWorkerSessions(username: unknown, password: unknown) {
const worker = await authenticateWorkerCredentials(username, password)
return getWorkerSessions({
sessionId: '',
workerId: Number(worker.id),
username: worker.username,
status: worker.status,
expiresAt: '',
sessionVersion: normalizeSessionVersion(worker.session_version),
})
}
export async function deleteWorkerSessionWithCredentials(
username: unknown,
password: unknown,
targetSessionId: unknown,
) {
const worker = await authenticateWorkerCredentials(username, password)
return deleteWorkerSession(
{
sessionId: '',
workerId: Number(worker.id),
username: worker.username,
status: worker.status,
expiresAt: '',
sessionVersion: normalizeSessionVersion(worker.session_version),
},
targetSessionId,
)
}
async function authenticateWorkerCredentials(
username: unknown,
password: unknown,
): Promise<WorkerUserRow> {
const normalizedUsername = normalizeUsername(username)
const normalizedPassword = normalizePassword(password)
if (!normalizedUsername || !normalizedPassword) {
throw createHttpError('缺少接单账号或密码', {
statusCode: 400,
@@ -384,13 +472,8 @@ export async function loginWorker(username: unknown, password: unknown) {
errorCode: 'worker_login_failed',
})
}
assertWorkerLoginAllowed(worker)
return {
...createWorkerSession(worker),
passwordWeak: !isWorkerPasswordStrong(normalizedPassword),
}
return worker
}
export function assertWorkerLoginAllowed(worker: WorkerUserRow) {
@@ -426,10 +509,9 @@ export async function logoutWorkerSession(token: unknown) {
try {
const session = await verifyWorkerSessionToken(normalizedToken)
const revokedWorker = await incrementWorkerSessionVersion(session.workerId, nowIso())
return {
success: true,
revoked: Boolean(revokedWorker),
revoked: await revokeWorkerSession(session.workerId, session.sessionId, nowIso()),
}
} catch (error) {
if (isIgnorableWorkerAuthError(error)) {
@@ -499,8 +581,23 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
})
}
const sessionId = String(payload.sid || '').trim()
const sessionRecord = sessionId ? await getWorkerSessionBySessionId(sessionId) : null
if (
!sessionRecord ||
Number(sessionRecord.worker_id) !== Number(worker.id) ||
sessionRecord.status !== 'active' ||
Date.parse(String(sessionRecord.expires_at || '')) <= Date.now() ||
!(await touchWorkerSession(sessionId, nowIso()))
) {
throw createHttpError('登录设备已被删除,请重新登录', {
statusCode: 401,
errorCode: 'worker_auth_device_revoked',
})
}
return {
sessionId: String(payload.sid || '').trim(),
sessionId,
workerId: Number(worker.id),
username: worker.username,
status: worker.status,
@@ -509,6 +606,59 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
}
}
export async function getWorkerSessions(session: WorkerSession) {
const sessions = await listWorkerSessions(session.workerId, nowIso())
return {
maxDevices: WORKER_MAX_DEVICES,
items: sessions.map((item) => mapWorkerSession(item, session.sessionId)),
}
}
export async function deleteWorkerSession(session: WorkerSession, targetSessionId: unknown) {
const normalizedTarget = String(targetSessionId || '').trim()
if (!normalizedTarget) {
throw createHttpError('缺少登录设备标识', {
statusCode: 400,
errorCode: 'worker_session_id_required',
})
}
return {
deleted: await revokeWorkerSession(session.workerId, normalizedTarget, nowIso()),
}
}
function mapWorkerSession(session: WorkerSessionRow, currentSessionId: string) {
return {
sessionId: session.session_id,
deviceId: session.device_id,
deviceType: session.device_type,
deviceName: session.device_name,
userAgent: session.user_agent,
ipAddress: session.ip_address,
issuedAt: session.issued_at,
lastSeenAt: session.last_seen_at,
expiresAt: session.expires_at,
current: session.session_id === currentSessionId,
}
}
function normalizeWorkerDeviceType(value: unknown): string {
const type = String(value || '')
.trim()
.toLowerCase()
return type === 'mobile' || type === 'tablet' ? type : 'pc'
}
function normalizeWorkerDeviceId(value: unknown, fallback: string): string {
const deviceId = String(value || '').trim()
return (deviceId || fallback).slice(0, 200)
}
function normalizeWorkerDeviceName(value: unknown, deviceType: string): string {
const name = String(value || '').trim()
return (name || (deviceType === 'pc' ? 'PC 浏览器' : '移动设备')).slice(0, 100)
}
export async function getWorkerSessionSummary(token: unknown) {
const session = await verifyWorkerSessionToken(token)
const worker = await getRequiredWorker(session.workerId)
@@ -953,6 +1103,7 @@ export async function changeWorkerPassword(payload: JsonObject = {}, session: Wo
sessionVersion: normalizeSessionVersion(worker.session_version) + 1,
now: nowIso(),
})
await revokeAllWorkerSessions(worker.id, nowIso())
return {
worker: mapWorkerUser(updated || worker),