增加打手登录设备管理与三台设备限制
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -140,6 +140,7 @@ function shouldClearWorkerSession(errorCode: string) {
|
||||
'worker_auth_expired',
|
||||
'worker_auth_user_invalid',
|
||||
'worker_auth_stale',
|
||||
'worker_auth_device_revoked',
|
||||
].includes(errorCode)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,18 @@ import {
|
||||
ThunderboltOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { App, Button, Form, Input, Space, Tabs } from 'antd'
|
||||
import { Alert, App, Button, Form, Input, Modal, Space, Table, Tabs, Typography } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router'
|
||||
|
||||
import { loginWorker, registerWorker, sendWorkerSmsCode } from '@/services/worker'
|
||||
import {
|
||||
deleteWorkerSessionWithCredentials,
|
||||
loginWorker,
|
||||
manageWorkerSessions,
|
||||
registerWorker,
|
||||
sendWorkerSmsCode,
|
||||
} from '@/services/worker'
|
||||
import type { WorkerSessionDevice } from '@/types/worker-platform'
|
||||
import { hasWorkerSession, setWorkerSession } from '@/utils/worker-auth'
|
||||
import { isWorkerPasswordStrong, WORKER_PASSWORD_RULE_MESSAGE } from '@/utils/worker-password'
|
||||
|
||||
@@ -40,7 +47,11 @@ export default function WorkerLoginPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [registerForm] = Form.useForm()
|
||||
const [deviceForm] = Form.useForm<{ username: string; password: string }>()
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [deviceManagerOpen, setDeviceManagerOpen] = useState(false)
|
||||
const [deviceManagerLoading, setDeviceManagerLoading] = useState(false)
|
||||
const [managedDevices, setManagedDevices] = useState<WorkerSessionDevice[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
@@ -71,12 +82,48 @@ export default function WorkerLoginPage() {
|
||||
void navigate('/hall', { replace: true })
|
||||
}
|
||||
} catch (error) {
|
||||
if (String((error as { errorCode?: string })?.errorCode || '') === 'worker_device_limit') {
|
||||
deviceForm.setFieldsValue(values)
|
||||
setDeviceManagerOpen(true)
|
||||
await loadManagedDevices(values)
|
||||
return
|
||||
}
|
||||
message.error(error instanceof Error ? error.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManagedDevices(values?: { username: string; password: string }) {
|
||||
const credentials = values || deviceForm.getFieldsValue()
|
||||
if (!credentials.username || !credentials.password) {
|
||||
return
|
||||
}
|
||||
setDeviceManagerLoading(true)
|
||||
try {
|
||||
const response = await manageWorkerSessions(credentials)
|
||||
setManagedDevices(response.data.items)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '读取登录设备失败')
|
||||
} finally {
|
||||
setDeviceManagerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeManagedDevice(device: WorkerSessionDevice) {
|
||||
const credentials = deviceForm.getFieldsValue()
|
||||
setDeviceManagerLoading(true)
|
||||
try {
|
||||
await deleteWorkerSessionWithCredentials({ ...credentials, sessionId: device.sessionId })
|
||||
message.success('登录设备已删除,请重新登录')
|
||||
await loadManagedDevices(credentials)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除登录设备失败')
|
||||
} finally {
|
||||
setDeviceManagerLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSmsCode() {
|
||||
const phone = String(registerForm.getFieldValue('phone') || '').trim()
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
@@ -214,6 +261,14 @@ export default function WorkerLoginPage() {
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<GlobalOutlined />}
|
||||
block
|
||||
onClick={() => setDeviceManagerOpen(true)}
|
||||
>
|
||||
登录设备管理
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
),
|
||||
@@ -322,6 +377,74 @@ export default function WorkerLoginPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Modal
|
||||
title="登录设备管理"
|
||||
open={deviceManagerOpen}
|
||||
width={isMobileViewport() ? '92%' : 720}
|
||||
destroyOnHidden
|
||||
footer={null}
|
||||
onCancel={() => setDeviceManagerOpen(false)}
|
||||
>
|
||||
<Form
|
||||
form={deviceForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => void loadManagedDevices(values)}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入账号' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="手机号 / 昵称 / 账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={deviceManagerLoading}>
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="验证账号密码后,可删除旧设备。所有 PC 和手机合计最多同时在线 3 台。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="sessionId"
|
||||
loading={deviceManagerLoading}
|
||||
dataSource={managedDevices}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '设备',
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{item.deviceName || item.deviceType}</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.ipAddress || '未知 IP'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最近活动',
|
||||
render: (_, item) => item.lastSeenAt.replace('T', ' ').slice(0, 19),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_, item) => (
|
||||
<Button danger type="link" onClick={() => void removeManagedDevice(item)}>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function isMobileViewport() {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BankOutlined,
|
||||
CopyOutlined,
|
||||
CustomerServiceOutlined,
|
||||
GlobalOutlined,
|
||||
LockOutlined,
|
||||
LogoutOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
createWorkerWithdrawRequest,
|
||||
fetchWorkerFinanceRequests,
|
||||
fetchWorkerProfile,
|
||||
fetchWorkerSessions,
|
||||
deleteWorkerSession,
|
||||
fetchWorkerWalletLedgers,
|
||||
logoutWorker,
|
||||
} from '@/services/worker'
|
||||
@@ -51,6 +54,7 @@ import type {
|
||||
UploadedFile,
|
||||
WorkerFinanceRequest,
|
||||
WorkerProfileSummary,
|
||||
WorkerSessionDevice,
|
||||
WorkerUser,
|
||||
WorkerWalletLedger,
|
||||
} from '@/types/worker-platform'
|
||||
@@ -104,6 +108,7 @@ export default function WorkerProfilePage() {
|
||||
const [withdrawalAccountOpen, setWithdrawalAccountOpen] = useState(false)
|
||||
const [passwordOpen, setPasswordOpen] = useState(false)
|
||||
const [contactOpen, setContactOpen] = useState(false)
|
||||
const [devicesOpen, setDevicesOpen] = useState(false)
|
||||
const [submittingRecharge, setSubmittingRecharge] = useState(false)
|
||||
const [submittingWithdraw, setSubmittingWithdraw] = useState(false)
|
||||
const [submittingWithdrawalAccount, setSubmittingWithdrawalAccount] = useState(false)
|
||||
@@ -115,6 +120,13 @@ export default function WorkerProfilePage() {
|
||||
const selectedWithdrawChannel = Form.useWatch('accountChannel', withdrawForm)
|
||||
const selectedWithdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm)
|
||||
|
||||
const devicesQuery = useQuery({
|
||||
queryKey: ['worker-sessions'],
|
||||
queryFn: () => fetchWorkerSessions(),
|
||||
enabled: devicesOpen,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['worker-profile'],
|
||||
queryFn: () => fetchWorkerProfile(),
|
||||
@@ -186,6 +198,16 @@ export default function WorkerProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeWorkerDevice(device: WorkerSessionDevice) {
|
||||
try {
|
||||
await deleteWorkerSession(device.sessionId)
|
||||
message.success('登录设备已删除')
|
||||
await devicesQuery.refetch()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除登录设备失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openRechargeModal() {
|
||||
if (!canUseFinanceActions) {
|
||||
message.warning('当前账号尚未通过审核,暂时不能发起充值申请')
|
||||
@@ -611,6 +633,19 @@ export default function WorkerProfilePage() {
|
||||
</div>
|
||||
<span>修改密码</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="worker-profile-mobile-tool-item"
|
||||
onClick={() => setDevicesOpen(true)}
|
||||
>
|
||||
<div
|
||||
className="worker-profile-mobile-tool-icon"
|
||||
style={{ background: '#f0fdfa', color: '#0f766e' }}
|
||||
>
|
||||
<GlobalOutlined />
|
||||
</div>
|
||||
<span>登录设备</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="worker-profile-mobile-tool-item"
|
||||
@@ -1120,6 +1155,9 @@ export default function WorkerProfilePage() {
|
||||
<Button icon={<LockOutlined />} onClick={() => setPasswordOpen(true)}>
|
||||
修改密码
|
||||
</Button>
|
||||
<Button icon={<GlobalOutlined />} onClick={() => setDevicesOpen(true)}>
|
||||
登录设备
|
||||
</Button>
|
||||
<Button icon={<CustomerServiceOutlined />} onClick={() => setContactOpen(true)}>
|
||||
联系管理员
|
||||
</Button>
|
||||
@@ -1626,6 +1664,55 @@ export default function WorkerProfilePage() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="登录设备"
|
||||
open={devicesOpen}
|
||||
width={isMobile ? '92%' : 720}
|
||||
destroyOnHidden
|
||||
footer={null}
|
||||
onCancel={() => setDevicesOpen(false)}
|
||||
>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message={`所有设备合计最多同时在线 ${devicesQuery.data?.data.maxDevices || 3} 台,删除旧设备后才能在新设备登录。`}
|
||||
/>
|
||||
<Table
|
||||
size="small"
|
||||
loading={devicesQuery.isLoading || devicesQuery.isFetching}
|
||||
rowKey="sessionId"
|
||||
dataSource={devicesQuery.data?.data.items || []}
|
||||
pagination={false}
|
||||
style={{ marginTop: 16 }}
|
||||
columns={[
|
||||
{
|
||||
title: '设备',
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{item.deviceName || item.deviceType}</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.ipAddress || '未知 IP'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '最近活动', render: (_, item) => formatDateTime(item.lastSeenAt) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_, item) => (
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
disabled={item.current}
|
||||
onClick={() => void removeWorkerDevice(item)}
|
||||
>
|
||||
{item.current ? '当前设备' : '删除'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="修改密码"
|
||||
open={passwordOpen}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
CollectLookupResponse,
|
||||
UploadedFile,
|
||||
@@ -12,9 +12,11 @@ import type {
|
||||
WorkerLoginResponse,
|
||||
WorkerOrderOverviewResponse,
|
||||
WorkerProfileResponse,
|
||||
WorkerSessionDevice,
|
||||
WorkerUser,
|
||||
WorkerWalletLedger,
|
||||
} from '@/types/worker-platform'
|
||||
import { getWorkerDeviceId } from '@/utils/worker-auth'
|
||||
|
||||
export function sendWorkerSmsCode(payload: { phone: string }) {
|
||||
return apiPost<{ sent: boolean; expiresInSeconds: number; provider: string }>(
|
||||
@@ -37,7 +39,37 @@ export function registerWorker(payload: {
|
||||
}
|
||||
|
||||
export function loginWorker(payload: { username: string; password: string }) {
|
||||
return apiPost<WorkerLoginResponse>('/api/v1/worker/auth/login', payload)
|
||||
const userAgent = navigator.userAgent
|
||||
const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent)
|
||||
return apiPost<WorkerLoginResponse>('/api/v1/worker/auth/login', {
|
||||
...payload,
|
||||
deviceId: getWorkerDeviceId(),
|
||||
deviceType: isMobile ? 'mobile' : 'pc',
|
||||
deviceName: isMobile ? '手机浏览器' : 'PC 浏览器',
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchWorkerSessions() {
|
||||
return apiGet<{ maxDevices: number; items: WorkerSessionDevice[] }>('/api/v1/worker/auth/devices')
|
||||
}
|
||||
|
||||
export function deleteWorkerSession(sessionId: string) {
|
||||
return apiDelete<{ deleted: boolean }>(`/api/v1/worker/auth/devices/${sessionId}`)
|
||||
}
|
||||
|
||||
export function manageWorkerSessions(payload: { username: string; password: string }) {
|
||||
return apiPost<{ maxDevices: number; items: WorkerSessionDevice[] }>(
|
||||
'/api/v1/worker/auth/devices/manage/list',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteWorkerSessionWithCredentials(payload: {
|
||||
username: string
|
||||
password: string
|
||||
sessionId: string
|
||||
}) {
|
||||
return apiPost<{ deleted: boolean }>('/api/v1/worker/auth/devices/manage/delete', payload)
|
||||
}
|
||||
|
||||
export function fetchWorkerSession() {
|
||||
|
||||
@@ -37,6 +37,19 @@ export type WorkerUser = {
|
||||
reviewedAt: string | null
|
||||
}
|
||||
|
||||
export type WorkerSessionDevice = {
|
||||
sessionId: string
|
||||
deviceId: string
|
||||
deviceType: string
|
||||
deviceName: string
|
||||
userAgent: string
|
||||
ipAddress: string
|
||||
issuedAt: string
|
||||
lastSeenAt: string
|
||||
expiresAt: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export type WorkerProfileSummary = {
|
||||
acceptedOrderCount: number
|
||||
activeOrderCount: number
|
||||
|
||||
@@ -3,11 +3,24 @@ const WORKER_EXPIRES_AT_KEY = 'order-site-worker-token-expires-at'
|
||||
const WORKER_ID_KEY = 'order-site-worker-id'
|
||||
const WORKER_USERNAME_KEY = 'order-site-worker-username'
|
||||
const WORKER_STATUS_KEY = 'order-site-worker-status'
|
||||
const WORKER_DEVICE_ID_KEY = 'order-site-worker-device-id'
|
||||
|
||||
export function getWorkerToken() {
|
||||
return localStorage.getItem(WORKER_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function getWorkerDeviceId() {
|
||||
let deviceId = localStorage.getItem(WORKER_DEVICE_ID_KEY) || ''
|
||||
if (!deviceId) {
|
||||
deviceId =
|
||||
typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
localStorage.setItem(WORKER_DEVICE_ID_KEY, deviceId)
|
||||
}
|
||||
return deviceId
|
||||
}
|
||||
|
||||
export function getWorkerTokenExpiresAt() {
|
||||
return localStorage.getItem(WORKER_EXPIRES_AT_KEY) || ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user