From dae32504c78b321c4a6a8d5ccea41cecf3a363e9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 21 Aug 2026 16:39:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=89=93=E6=89=8B=E7=9F=AD?= =?UTF-8?q?=E4=BF=A1=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/worker-platform/index.ts | 1 + .../worker-platform/worker-service.ts | 188 ++---------------- .../worker-platform/worker-sms-service.ts | 121 +++++++++++ 3 files changed, 134 insertions(+), 176 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/worker-sms-service.ts diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 5372483d..fdb4ce43 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -5,6 +5,7 @@ export * from './worker-auth-policy.js' export * from './worker-session-view.js' export * from './worker-session-auth-service.js' export * from './worker-credentials-service.js' +export * from './worker-sms-service.js' export * from './worker-platform-defaults.js' export * from './work-order-timeout-policy.js' export * from './after-sales-service.js' diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 674b3671..011e3caa 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -12,9 +12,7 @@ import { countWorkerTimeoutEvents, countWorkerWithdrawRequestsOnDay, countWorkOrderPendingSharingSubmissions, - countWorkerSmsCodesOnDay, consumeWorkerSmsCode, - createWorkerSmsCode, createWorkOrderEvent, createWorkerFinanceRequest, createWorkerUser, @@ -79,7 +77,6 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { addHours, nowIso } from '../../utils/time.js' import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' -import { getSmsProvider } from '../sms/index.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' import { getWorkerAnnouncementConfig } from './worker-announcement-config-service.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' @@ -156,6 +153,13 @@ import { normalizeWorkerDeviceType, } from './worker-auth-policy.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' +import { + hashSmsCode, + normalizeWorkerPhone, + sendWorkerLoginSmsCode, + sendWorkerPasswordResetSmsCode, + sendWorkerSmsCode, +} from './worker-sms-service.js' import { changeWorkerPassword } from './worker-credentials-service.js' import { deleteWorkerSession, @@ -185,6 +189,11 @@ export { } from './work-order-timeout-policy.js' export type { WorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' export { changeWorkerPassword } from './worker-credentials-service.js' +export { + sendWorkerLoginSmsCode, + sendWorkerPasswordResetSmsCode, + sendWorkerSmsCode, +} from './worker-sms-service.js' export { deleteWorkerSession, getWorkerSessionSummary, @@ -195,153 +204,6 @@ export { const WORKER_MAX_DEVICES = 3 -export async function sendWorkerSmsCode(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: 409, - errorCode: 'worker_phone_registered', - }) - } - - const latest = await getLatestWorkerSmsCode(phone, 'register') - 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() - const todayCount = await countWorkerSmsCodesOnDay(phone, dayRange.start, dayRange.end) - if (todayCount >= dailyLimit) { - throw createHttpError('今日验证码发送次数已达上限,请明天再试', { - statusCode: 429, - errorCode: 'worker_sms_daily_limit', - }) - } - - const code = generateSmsCode() - await getSmsProvider().sendCode(phone, code) - await createWorkerSmsCode({ - phone, - purpose: 'register', - 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 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 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() @@ -519,32 +381,6 @@ export async function registerWorker(payload: JsonObject = {}) { } } -function normalizeWorkerPhone(value: unknown): string { - const phone = String(value || '').trim() - if (!/^1[3-9]\d{9}$/.test(phone)) { - throw createHttpError('手机号格式不正确', { - statusCode: 400, - errorCode: 'worker_phone_invalid', - }) - } - return phone -} - -function generateSmsCode(): string { - let code = '' - for (let index = 0; index < 6; index += 1) { - code += String(crypto.randomInt(10)) - } - return code -} - -function hashSmsCode(code: string): string { - return crypto - .createHash('sha256') - .update(String(code || '')) - .digest('hex') -} - async function generateInviteCode(_now: string): Promise { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) { diff --git a/apps/backend/src/services/worker-platform/worker-sms-service.ts b/apps/backend/src/services/worker-platform/worker-sms-service.ts new file mode 100644 index 00000000..df6cf782 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-sms-service.ts @@ -0,0 +1,121 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import { + countWorkerSmsCodesOnDay, + createWorkerSmsCode, + getLatestWorkerSmsCode, + getWorkerUserByPhone, +} from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { getSmsProvider } from '../sms/index.js' + +type WorkerSmsPurpose = 'register' | 'password_reset' | 'login' + +export async function sendWorkerSmsCode(payload: JsonObject = {}) { + return sendWorkerSmsCodeForPurpose(payload.phone, 'register') +} + +export async function sendWorkerPasswordResetSmsCode(payload: JsonObject = {}) { + return sendWorkerSmsCodeForPurpose(payload.phone, 'password_reset') +} + +export async function sendWorkerLoginSmsCode(payload: JsonObject = {}) { + return sendWorkerSmsCodeForPurpose(payload.phone, 'login') +} + +async function sendWorkerSmsCodeForPurpose(phoneValue: unknown, purpose: WorkerSmsPurpose) { + const phone = normalizeWorkerPhone(phoneValue) + 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)) + const worker = await getWorkerUserByPhone(phone) + + if (purpose === 'register' && worker) { + throw createHttpError('该手机号已注册,请直接登录', { + statusCode: 409, + errorCode: 'worker_phone_registered', + }) + } + if (purpose !== 'register' && !worker) { + throw createHttpError('该手机号未注册', { + statusCode: 404, + errorCode: 'worker_phone_not_registered', + }) + } + + const latest = await getLatestWorkerSmsCode(phone, purpose) + 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, + 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 function normalizeWorkerPhone(value: unknown): string { + const phone = String(value || '').trim() + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw createHttpError('手机号格式不正确', { + statusCode: 400, + errorCode: 'worker_phone_invalid', + }) + } + return phone +} + +export function hashSmsCode(code: string): string { + return crypto + .createHash('sha256') + .update(String(code || '')) + .digest('hex') +} + +function generateSmsCode(): string { + let code = '' + for (let index = 0; index < 6; index += 1) { + code += String(crypto.randomInt(10)) + } + return code +} + +function resolveChinaDayRange(): { start: string; end: string } { + const now = new Date() + const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000) + const year = chinaNow.getUTCFullYear() + const month = chinaNow.getUTCMonth() + const day = chinaNow.getUTCDate() + const start = new Date(Date.UTC(year, month, day) - 8 * 60 * 60 * 1000) + const end = new Date(Date.UTC(year, month, day + 1) - 8 * 60 * 60 * 1000) + return { start: start.toISOString(), end: end.toISOString() } +}