From 555a7d43a78742e9a242741cb656eaaf38c76444 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 20 Aug 2026 14:46:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E2=80=9C=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E7=99=BB=E5=BD=95=E2=80=9D=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/worker.ts | 42 ++++ .../worker-platform/worker-service.ts | 119 +++++++++- .../src/pages/worker/WorkerLoginPage.tsx | 212 ++++++++++++++---- apps/frontend/src/services/worker.ts | 24 ++ 4 files changed, 352 insertions(+), 45 deletions(-) diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 96efa731..c04f9955 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -21,6 +21,7 @@ import { markWorkerOrderRead, getWorkerMyOrderEvents, loginWorker, + loginWorkerBySmsCode, getWorkerSessions, deleteWorkerSession, manageWorkerSessions, @@ -28,6 +29,7 @@ import { logoutWorkerSession, registerWorker, sendWorkerSmsCode, + sendWorkerLoginSmsCode, sendWorkerPasswordResetSmsCode, resetWorkerPassword, saveWorkerOrderAcceptanceDraft, @@ -133,6 +135,46 @@ router.post( ), ) +router.post( + '/auth/login/sms-code', + createRateLimitMiddleware({ + scope: 'worker:login-sms-code', + windowMs: 60_000, + max: 5, + key: getBodyFieldRateLimitKey('phone'), + }), + createRouteHandler((req) => sendWorkerLoginSmsCode(req.body || {}), { + successMessage: '验证码已发送', + errorMessage: '验证码发送失败', + scope: '[worker/auth/login/sms-code]', + }), +) + +router.post( + '/auth/login/sms', + createRateLimitMiddleware({ + scope: 'worker:login-sms', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('phone'), + }), + createRouteHandler( + (req) => + loginWorkerBySmsCode(req.body?.phone, req.body?.code, { + 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/sms]', + }, + ), +) + router.post( '/auth/devices/manage/list', createRateLimitMiddleware({ diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 8222084e..57f05bac 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -287,6 +287,52 @@ export async function sendWorkerPasswordResetSmsCode(payload: JsonObject = {}) { } } +export async function sendWorkerLoginSmsCode(payload: JsonObject = {}) { + const phone = normalizeWorkerPhone(payload.phone) + const config = runtimeConfig.worker.sms + const cooldownMs = Math.max(10, Number(config.cooldownSeconds || 60)) * 1000 + const dailyLimit = Math.max(1, Number(config.dailyLimit || 10)) + const ttlSeconds = Math.max(60, Number(config.codeTtlSeconds || 300)) + if (!(await getWorkerUserByPhone(phone))) { + throw createHttpError('该手机号未注册', { + statusCode: 404, + errorCode: 'worker_phone_not_registered', + }) + } + const latest = await getLatestWorkerSmsCode(phone, 'login') + if ( + latest && + !latest.consumed_at && + new Date(latest.created_at).getTime() > Date.now() - cooldownMs + ) { + throw createHttpError('验证码发送过于频繁,请稍后再试', { + statusCode: 429, + errorCode: 'worker_sms_cooldown', + }) + } + const dayRange = resolveChinaDayRange() + if ((await countWorkerSmsCodesOnDay(phone, dayRange.start, dayRange.end)) >= dailyLimit) { + throw createHttpError('今日验证码发送次数已达上限,请明天再试', { + statusCode: 429, + errorCode: 'worker_sms_daily_limit', + }) + } + const code = generateSmsCode() + await getSmsProvider().sendCode(phone, code) + await createWorkerSmsCode({ + phone, + purpose: 'login', + codeHash: hashSmsCode(code), + expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), + now: nowIso(), + }) + return { + sent: true, + expiresInSeconds: ttlSeconds, + provider: String(runtimeConfig.worker.sms.provider || 'mock'), + } +} + export async function resetWorkerPassword(payload: JsonObject = {}) { const phone = normalizeWorkerPhone(payload.phone) const code = String(payload.code || payload.smsCode || '').trim() @@ -522,6 +568,77 @@ export async function loginWorker( const normalizedPassword = normalizePassword(password) const worker = await authenticateWorkerCredentials(username, password) + return createWorkerLoginSession(worker, deviceInfo, !isWorkerPasswordStrong(normalizedPassword)) +} + +export async function loginWorkerBySmsCode( + phoneValue: unknown, + codeValue: unknown, + deviceInfo: { + deviceId?: unknown + deviceType?: unknown + deviceName?: unknown + userAgent?: unknown + ipAddress?: unknown + } = {}, +) { + ensureWorkerAuthConfigured() + const phone = normalizeWorkerPhone(phoneValue) + const code = String(codeValue || '').trim() + if (!code) { + throw createHttpError('请输入短信验证码', { + statusCode: 400, + errorCode: 'worker_sms_code_required', + }) + } + const worker = await getWorkerUserByPhone(phone) + const latest = await getLatestWorkerSmsCode(phone, 'login') + const codeValid = Boolean( + latest && + !latest.consumed_at && + new Date(latest.expires_at).getTime() > Date.now() && + latest.code_hash === hashSmsCode(code), + ) + if (!worker || !codeValid) { + if (latest && !latest.consumed_at) { + const failed = await recordWorkerSmsCodeFailure( + latest.id, + nowIso(), + WORKER_SMS_MAX_VERIFY_ATTEMPTS, + ) + if (failed?.consumed_at) { + throw createHttpError('验证码错误次数过多,请重新获取验证码', { + statusCode: 400, + errorCode: 'worker_sms_code_locked', + }) + } + } + throw createHttpError('手机号或验证码错误', { + statusCode: 401, + errorCode: 'worker_sms_code_invalid', + }) + } + if (!(await consumeWorkerSmsCode(latest!.id, nowIso()))) { + throw createHttpError('验证码已失效,请重新获取', { + statusCode: 400, + errorCode: 'worker_sms_code_invalid', + }) + } + assertWorkerLoginAllowed(worker) + return createWorkerLoginSession(worker, deviceInfo, false) +} + +async function createWorkerLoginSession( + worker: WorkerUserRow, + deviceInfo: { + deviceId?: unknown + deviceType?: unknown + deviceName?: unknown + userAgent?: unknown + ipAddress?: unknown + }, + passwordWeak: boolean, +) { const session = createWorkerSession(worker) const deviceType = normalizeWorkerDeviceType(deviceInfo.deviceType) const deviceId = normalizeWorkerDeviceId(deviceInfo.deviceId, session.sessionId) @@ -551,7 +668,7 @@ export async function loginWorker( return { ...session, - passwordWeak: !isWorkerPasswordStrong(normalizedPassword), + passwordWeak, } } diff --git a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx index 94d413d1..39b94f5d 100644 --- a/apps/frontend/src/pages/worker/WorkerLoginPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerLoginPage.tsx @@ -7,18 +7,32 @@ import { ThunderboltOutlined, UserOutlined, } from '@ant-design/icons' -import { Alert, App, Button, Form, Input, Modal, Space, Table, Tabs, Typography } from 'antd' +import { + Alert, + App, + Button, + Form, + Input, + Modal, + Segmented, + Space, + Table, + Tabs, + Typography, +} from 'antd' import { useEffect, useState } from 'react' import { Navigate, useNavigate } from 'react-router' import { deleteWorkerSessionWithCredentials, loginWorker, + loginWorkerBySmsCode, manageWorkerSessions, registerWorker, resetWorkerPassword, sendWorkerSmsCode, sendWorkerPasswordResetSmsCode, + sendWorkerLoginSmsCode, } from '@/services/worker' import type { WorkerSessionDevice } from '@/types/worker-platform' import { hasWorkerSession, setWorkerSession } from '@/utils/worker-auth' @@ -49,9 +63,12 @@ export default function WorkerLoginPage() { const { message, modal } = App.useApp() const [loading, setLoading] = useState(false) const [registerForm] = Form.useForm() + const [loginSmsForm] = Form.useForm<{ phone: string; code: string }>() const [resetForm] = Form.useForm<{ phone: string; code: string; password: string }>() const [deviceForm] = Form.useForm<{ username: string; password: string }>() const [countdown, setCountdown] = useState(0) + const [loginSmsCountdown, setLoginSmsCountdown] = useState(0) + const [loginMode, setLoginMode] = useState<'password' | 'sms'>('password') const [resetCountdown, setResetCountdown] = useState(0) const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [deviceManagerOpen, setDeviceManagerOpen] = useState(false) @@ -64,6 +81,12 @@ export default function WorkerLoginPage() { return () => window.clearInterval(timer) }, [countdown]) + useEffect(() => { + if (loginSmsCountdown <= 0) return + const timer = window.setInterval(() => setLoginSmsCountdown((value) => value - 1), 1000) + return () => window.clearInterval(timer) + }, [loginSmsCountdown]) + useEffect(() => { if (resetCountdown <= 0) return const timer = window.setInterval(() => setResetCountdown((value) => value - 1), 1000) @@ -105,6 +128,42 @@ export default function WorkerLoginPage() { } } + async function requestLoginSmsCode() { + const phone = String(loginSmsForm.getFieldValue('phone') || '').trim() + if (!/^1[3-9]\d{9}$/.test(phone)) { + message.error('请先填写正确的手机号') + return + } + setLoading(true) + try { + const response = await sendWorkerLoginSmsCode({ phone }) + setLoginSmsCountdown(SMS_COUNTDOWN_SECONDS) + message.success( + response.data.provider === 'mock' + ? '验证码已发送(开发模式,验证码见服务日志)' + : `验证码已发送,${response.data.expiresInSeconds} 秒内有效`, + ) + } catch (error) { + message.error(error instanceof Error ? error.message : '验证码发送失败') + } finally { + setLoading(false) + } + } + + async function submitSmsLogin(values: { phone: string; code: string }) { + setLoading(true) + try { + const response = await loginWorkerBySmsCode(values) + setWorkerSession(response.data.token, response.data.expiresAt, response.data.worker) + message.success('登录成功') + void navigate('/hall', { replace: true }) + } catch (error) { + 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) { @@ -275,51 +334,116 @@ export default function WorkerLoginPage() { <>

欢迎回来

-

使用手机号、昵称或账号登录,进入抢单大厅

+

使用密码或短信验证码登录,进入抢单大厅

-
- setLoginMode(value as 'password' | 'sms')} + style={{ marginBottom: 20 }} + /> + {loginMode === 'password' ? ( + + + } + autoComplete="username" + placeholder="手机号 / 昵称 / 账号" + /> + + + } + autoComplete="current-password" + /> + + + + + + ) : ( +
- } - autoComplete="username" - placeholder="手机号 / 昵称 / 账号" - /> - - - } - autoComplete="current-password" - /> - - - - -
+ + } + maxLength={11} + placeholder="注册时使用的手机号" + /> + + + + } + maxLength={6} + placeholder="6 位数字验证码" + /> + + + + + + )} ), }, diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index bf1de4d6..934acb99 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -64,6 +64,30 @@ export function loginWorker(payload: { username: string; password: string }) { }) } +function buildWorkerDevicePayload() { + const userAgent = navigator.userAgent + const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent) + return { + deviceId: getWorkerDeviceId(), + deviceType: isMobile ? 'mobile' : 'pc', + deviceName: isMobile ? '手机浏览器' : 'PC 浏览器', + } +} + +export function sendWorkerLoginSmsCode(payload: { phone: string }) { + return apiPost<{ sent: boolean; expiresInSeconds: number; provider: string }>( + '/api/v1/worker/auth/login/sms-code', + payload, + ) +} + +export function loginWorkerBySmsCode(payload: { phone: string; code: string }) { + return apiPost('/api/v1/worker/auth/login/sms', { + ...payload, + ...buildWorkerDevicePayload(), + }) +} + export function fetchWorkerSessions() { return apiGet<{ maxDevices: number; items: WorkerSessionDevice[] }>('/api/v1/worker/auth/devices') }