拆分打手短信服务

This commit is contained in:
yml2213
2026-08-21 16:39:57 +08:00
parent f0d1da4e1a
commit dae32504c7
3 changed files with 134 additions and 176 deletions
@@ -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'
@@ -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<string> {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) {
@@ -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() }
}