diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index fb7f183e..26c2ae65 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -38,6 +38,7 @@ import { reviewAdminWorkerFinanceRequest, resolveAdminProblemWorkOrder, reviewAdminWorkerUser, + resetAdminWorkerPassword, saveAdminWorkerFinanceConfig, saveAdminWorkerProductMatchConfig, saveAdminWorkerPlatformNotificationConfig, @@ -318,6 +319,25 @@ router.post( ), ) +router.post( + '/worker-platform/workers/:workerId/reset-password', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => resetAdminWorkerPassword(String(req.params.workerId || ''), req.body || {}), + { + successMessage: '打手密码已重置', + errorMessage: '重置打手密码失败', + scope: '[admin/worker-platform/workers/:workerId/reset-password]', + audit: (req, data) => ({ + action: 'worker_password_reset', + targetType: 'worker', + targetId: String(req.params.workerId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/workers/:workerId/wallet-credit', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 4468ec29..96efa731 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -28,6 +28,8 @@ import { logoutWorkerSession, registerWorker, sendWorkerSmsCode, + sendWorkerPasswordResetSmsCode, + resetWorkerPassword, saveWorkerOrderAcceptanceDraft, saveWorkerOrderNote, remindWorkerOrderAcceptance, @@ -76,6 +78,36 @@ router.post( }), ) +router.post( + '/auth/password-reset/sms-code', + createRateLimitMiddleware({ + scope: 'worker:password-reset-sms-code', + windowMs: 60_000, + max: 5, + key: getBodyFieldRateLimitKey('phone'), + }), + createRouteHandler((req) => sendWorkerPasswordResetSmsCode(req.body || {}), { + successMessage: '验证码已发送', + errorMessage: '验证码发送失败', + scope: '[worker/auth/password-reset/sms-code]', + }), +) + +router.post( + '/auth/password-reset', + createRateLimitMiddleware({ + scope: 'worker:password-reset', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('phone'), + }), + createRouteHandler((req) => resetWorkerPassword(req.body || {}), { + successMessage: '密码已重置,请重新登录', + errorMessage: '密码重置失败', + scope: '[worker/auth/password-reset]', + }), +) + router.post( '/auth/login', createRateLimitMiddleware({ diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 8740921b..e43164d6 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -52,6 +52,7 @@ import { updateWorkOrder, updateWorkOrderBasic, updateWorkerUser, + updateWorkerPassword, revokeAllWorkerSessions, upsertWorkerWithdrawalAccount, upsertWorkCategory, @@ -123,6 +124,8 @@ import { normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, + hashWorkerPassword, + validateWorkerPassword, normalizeSlugKey, normalizeStatuses, normalizeSubmittedFields, @@ -941,6 +944,30 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload: return { worker: mapWorkerUser(updated || worker) } } +export async function resetAdminWorkerPassword( + workerId: number | string, + payload: JsonObject = {}, +) { + const worker = await getRequiredWorker(workerId) + const password = String(payload.password || payload.newPassword || '').trim() + if (!password) { + throw createHttpError('请输入新密码', { + statusCode: 400, + errorCode: 'worker_password_required', + }) + } + validateWorkerPassword(password) + const now = nowIso() + const updated = await updateWorkerPassword({ + workerId: worker.id, + passwordHash: hashWorkerPassword(password), + sessionVersion: normalizeSessionVersion(worker.session_version) + 1, + now, + }) + await revokeAllWorkerSessions(worker.id, now) + return { worker: mapWorkerUser(updated || worker), reloginRequired: true } +} + export async function assignAdminWorkOrderToWorker( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index df6df82f..8222084e 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -11,6 +11,7 @@ import { countWorkerWithdrawRequestsOnDay, countWorkOrderPendingSharingSubmissions, countWorkerSmsCodesOnDay, + consumeWorkerSmsCode, createWorkerSmsCode, createWorkOrderEvent, createWorkerFinanceRequest, @@ -236,6 +237,119 @@ export async function sendWorkerSmsCode(payload: JsonObject = {}) { } } +export async function sendWorkerPasswordResetSmsCode(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, 'password_reset') + 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: 'password_reset', + 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() + const password = normalizePassword(payload.password || payload.newPassword) + validateWorkerPassword(password) + if (!code) { + throw createHttpError('请输入短信验证码', { + statusCode: 400, + errorCode: 'worker_sms_code_required', + }) + } + + const worker = await getWorkerUserByPhone(phone) + if (!worker) { + throw createHttpError('该手机号未注册', { + statusCode: 404, + errorCode: 'worker_phone_not_registered', + }) + } + const latest = await getLatestWorkerSmsCode(phone, 'password_reset') + const codeValid = Boolean( + latest && + !latest.consumed_at && + new Date(latest.expires_at).getTime() > Date.now() && + latest.code_hash === hashSmsCode(code), + ) + if (!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: 400, + errorCode: 'worker_sms_code_invalid', + }) + } + const consumed = await consumeWorkerSmsCode(latest!.id, nowIso()) + if (!consumed) { + throw createHttpError('验证码已失效,请重新获取', { + statusCode: 400, + errorCode: 'worker_sms_code_invalid', + }) + } + const now = nowIso() + const updated = await updateWorkerPassword({ + workerId: worker.id, + passwordHash: hashWorkerPassword(password), + sessionVersion: normalizeSessionVersion(worker.session_version) + 1, + now, + }) + await revokeAllWorkerSessions(worker.id, now) + return { worker: mapWorkerUser(updated || worker), reloginRequired: true } +} + export async function registerWorker(payload: JsonObject = {}) { ensureWorkerAuthConfigured() const phone = normalizeWorkerPhone(payload.phone) diff --git a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx index 22bb3a30..8f14302c 100644 --- a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx @@ -22,6 +22,7 @@ import { fetchAdminWorkerLevels, fetchAdminWorkerUsers, fetchAdminWorkerWithdrawalAccounts, + resetAdminWorkerPassword, reviewAdminWorkerUser, saveAdminWorkerWithdrawalAccount, } from '@/services/admin' @@ -53,12 +54,14 @@ export default function WorkersPanel() { const [creditWorker, setCreditWorker] = useState(null) const [levelWorker, setLevelWorker] = useState(null) const [withdrawalAccountWorker, setWithdrawalAccountWorker] = useState(null) + const [resetPasswordWorker, setResetPasswordWorker] = useState(null) const [withdrawalAccounts, setWithdrawalAccounts] = useState([]) const [loadingWithdrawalAccounts, setLoadingWithdrawalAccounts] = useState(false) const [savingWithdrawalAccount, setSavingWithdrawalAccount] = useState(false) const [creditForm] = Form.useForm() const [levelForm] = Form.useForm<{ levelId?: number }>() const [withdrawalAccountForm] = Form.useForm() + const [resetPasswordForm] = Form.useForm<{ password: string }>() const withdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm) const workersQuery = useQuery({ @@ -145,6 +148,21 @@ export default function WorkersPanel() { } } + async function submitResetPassword(values: { password: string }) { + if (!resetPasswordWorker) return + try { + await resetAdminWorkerPassword(resetPasswordWorker.workerId, { + password: values.password.trim(), + }) + message.success('打手密码已重置,旧登录设备已退出') + setResetPasswordWorker(null) + resetPasswordForm.resetFields() + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '重置打手密码失败') + } + } + async function submitLevel(values: { levelId?: number }) { if (!levelWorker) return try { @@ -306,6 +324,7 @@ export default function WorkersPanel() { 调等级 + ), @@ -387,6 +406,45 @@ export default function WorkersPanel() { scroll={{ x: 1000 }} /> + { + setResetPasswordWorker(null) + resetPasswordForm.resetFields() + }} + onOk={() => resetPasswordForm.submit()} + destroyOnHidden + > +
+ + + {resetPasswordWorker?.displayName || resetPasswordWorker?.username || '-'} + + + + !value || + (/[A-Z]/.test(value) && + /[a-z]/.test(value) && + /\d/.test(value) && + /[\p{P}\p{S}]/u.test(value)) + ? Promise.resolve() + : Promise.reject(new Error('密码需包含大写字母、小写字母、数字和标点符号')), + }, + ]} + > + + +
+
+ () const [deviceForm] = Form.useForm<{ username: string; password: string }>() const [countdown, setCountdown] = useState(0) + const [resetCountdown, setResetCountdown] = useState(0) + const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) const [deviceManagerOpen, setDeviceManagerOpen] = useState(false) const [deviceManagerLoading, setDeviceManagerLoading] = useState(false) const [managedDevices, setManagedDevices] = useState([]) @@ -59,6 +64,12 @@ export default function WorkerLoginPage() { return () => window.clearInterval(timer) }, [countdown]) + useEffect(() => { + if (resetCountdown <= 0) return + const timer = window.setInterval(() => setResetCountdown((value) => value - 1), 1000) + return () => window.clearInterval(timer) + }, [resetCountdown]) + if (hasWorkerSession()) { return } @@ -146,6 +157,42 @@ export default function WorkerLoginPage() { } } + async function requestResetSmsCode() { + const phone = String(resetForm.getFieldValue('phone') || '').trim() + if (!/^1[3-9]\d{9}$/.test(phone)) { + message.error('请先填写正确的手机号') + return + } + setLoading(true) + try { + const response = await sendWorkerPasswordResetSmsCode({ phone }) + setResetCountdown(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 submitResetPassword(values: { phone: string; code: string; password: string }) { + setLoading(true) + try { + await resetWorkerPassword(values) + message.success('密码已重置,请使用新密码登录') + setForgotPasswordOpen(false) + resetForm.resetFields() + } catch (error) { + message.error(error instanceof Error ? error.message : '密码重置失败') + } finally { + setLoading(false) + } + } + async function submitRegister(values: { phone: string code: string @@ -269,6 +316,9 @@ export default function WorkerLoginPage() { > 登录设备管理 + ), @@ -377,6 +427,70 @@ export default function WorkerLoginPage() { + { + setForgotPasswordOpen(false) + resetForm.resetFields() + }} + footer={null} + destroyOnHidden + > +
+ + } maxLength={11} placeholder="注册时使用的手机号" /> + + + + } + maxLength={6} + placeholder="6 位数字验证码" + /> + + + + + !value || isWorkerPasswordStrong(value) + ? Promise.resolve() + : Promise.reject(new Error(WORKER_PASSWORD_RULE_MESSAGE)), + }, + ]} + extra={WORKER_PASSWORD_RULE_MESSAGE} + > + } autoComplete="new-password" /> + + +
+
+ ( + `/api/v1/admin/worker-platform/workers/${workerId}/reset-password`, + payload, + ) +} + export function fetchAdminWorkerWithdrawalAccounts(workerId: number) { return apiGet<{ worker: WorkerUser; items: AdminWorkerWithdrawalAccount[] }>( `/api/v1/admin/worker-platform/workers/${workerId}/withdrawal-accounts`, diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 7a2ef1fd..bf1de4d6 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -39,6 +39,20 @@ export function registerWorker(payload: { ) } +export function sendWorkerPasswordResetSmsCode(payload: { phone: string }) { + return apiPost<{ sent: boolean; expiresInSeconds: number; provider: string }>( + '/api/v1/worker/auth/password-reset/sms-code', + payload, + ) +} + +export function resetWorkerPassword(payload: { phone: string; code: string; password: string }) { + return apiPost<{ worker: WorkerUser; reloginRequired: boolean }>( + '/api/v1/worker/auth/password-reset', + payload, + ) +} + export function loginWorker(payload: { username: string; password: string }) { const userAgent = navigator.userAgent const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent)