diff --git a/apps/backend/src/db/migrations/029_worker_sessions.sql b/apps/backend/src/db/migrations/029_worker_sessions.sql new file mode 100644 index 00000000..1d0d702d --- /dev/null +++ b/apps/backend/src/db/migrations/029_worker_sessions.sql @@ -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 台'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 4a649164..ff1b1106 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -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 diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index 8cc9b0a8..9ed8d1d4 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -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 { + 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( + ` + 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( + ` + 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 { + const result = await query( + '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 { + 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 { + 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 { + 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 { + 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( + ` + 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 { const result = await query( 'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1', diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 46192485..3d9f0a05 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -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) }) diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index c8e2d8a2..d5762a32 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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) } } diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index edea6cc4..36a3e394 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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 = { diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 5b497d45..090ded50 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -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 { }) } -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 { 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 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), diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index f221c5b8..07d61d7f 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -140,6 +140,7 @@ function shouldClearWorkerSession(errorCode: string) { 'worker_auth_expired', 'worker_auth_user_invalid', 'worker_auth_stale', + 'worker_auth_device_revoked', ].includes(errorCode) } diff --git a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx index 16fe4d6f..8563ee4d 100644 --- a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx @@ -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([]) 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() { > 登录 + ), @@ -322,6 +377,74 @@ export default function WorkerLoginPage() { + setDeviceManagerOpen(false)} + > +
void loadManagedDevices(values)} + style={{ marginBottom: 16 }} + > + + } placeholder="手机号 / 昵称 / 账号" /> + + + } placeholder="密码" /> + + + + +
+ + ( + + {item.deviceName || item.deviceType} + {item.ipAddress || '未知 IP'} + + ), + }, + { + title: '最近活动', + render: (_, item) => item.lastSeenAt.replace('T', ' ').slice(0, 19), + }, + { + title: '操作', + width: 100, + render: (_, item) => ( + + ), + }, + ]} + /> + ) } + +function isMobileViewport() { + return typeof window !== 'undefined' && window.innerWidth < 768 +} diff --git a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx index 832fdd93..f261e3e3 100644 --- a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx +++ b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx @@ -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() { 修改密码 + + @@ -1626,6 +1664,55 @@ export default function WorkerProfilePage() { + setDevicesOpen(false)} + > + +
( + + {item.deviceName || item.deviceType} + {item.ipAddress || '未知 IP'} + + ), + }, + { title: '最近活动', render: (_, item) => formatDateTime(item.lastSeenAt) }, + { + title: '操作', + width: 100, + render: (_, item) => ( + + ), + }, + ]} + /> + + ( @@ -37,7 +39,37 @@ export function registerWorker(payload: { } export function loginWorker(payload: { username: string; password: string }) { - return apiPost('/api/v1/worker/auth/login', payload) + const userAgent = navigator.userAgent + const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent) + return apiPost('/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() { diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index f9d6fd7e..a43e6c5c 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -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 diff --git a/apps/frontend/src/utils/worker-auth.ts b/apps/frontend/src/utils/worker-auth.ts index 9b94ab55..3252111d 100644 --- a/apps/frontend/src/utils/worker-auth.ts +++ b/apps/frontend/src/utils/worker-auth.ts @@ -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) || '' }