修复打手短信验证码发送

This commit is contained in:
yml2213
2026-08-02 15:12:02 +08:00
parent c138d75d8e
commit 2227cda73d
19 changed files with 1010 additions and 36 deletions
+116
View File
@@ -0,0 +1,116 @@
import { createRequire } from 'node:module'
import { runtimeConfig } from '../../config/runtime.js'
import { createHttpError } from '../../utils/http.js'
import { logWarn } from '../../utils/logger.js'
const require = createRequire(import.meta.url)
const Dysmsapi = require('@alicloud/dysmsapi20170525') as {
default: new (config: unknown) => AliyunSmsClient
SendSmsRequest: new (input: Record<string, unknown>) => unknown
}
const OpenApiClient = require('@alicloud/openapi-client') as {
Config: new (config: Record<string, unknown>) => unknown
}
type AliyunSmsClient = {
sendSmsWithOptions(
request: unknown,
runtime: unknown,
): Promise<{ body?: { code?: string; message?: string } }>
}
export interface SmsProvider {
sendCode(phone: string, code: string): Promise<void>
}
const ALIYUN_DEFAULT_ENDPOINT = 'dysmsapi.aliyuncs.com'
export function getSmsProvider(): SmsProvider {
const config = runtimeConfig.worker.sms
if (String(config.provider || '').trim() === 'aliyun') {
const accessKeyId = String(config.accessKeyId || '').trim()
const accessKeySecret = String(config.accessKeySecret || '').trim()
const signName = String(config.signName || '').trim()
const templateCode = String(config.templateCode || '').trim()
if (!accessKeyId || !accessKeySecret || !signName || !templateCode) {
logWarn('[sms]', '阿里云短信配置不完整,已降级为 Mock 模式', {
hasAccessKeyId: Boolean(accessKeyId),
hasAccessKeySecret: Boolean(accessKeySecret),
hasSignName: Boolean(signName),
hasTemplateCode: Boolean(templateCode),
})
return new MockSmsProvider()
}
return new AliyunSmsProvider({ accessKeyId, accessKeySecret, signName, templateCode })
}
return new MockSmsProvider()
}
export class MockSmsProvider implements SmsProvider {
async sendCode(phone: string, code: string) {
logWarn('[sms/mock]', `【开发模式】手机验证码:${phone}${code}`, {
phone,
code,
hint: '生产环境请配置 WORKER_SMS_PROVIDER=aliyun 后使用真实短信',
})
}
}
export class AliyunSmsProvider implements SmsProvider {
private readonly client: AliyunSmsClient
private readonly signName: string
private readonly templateCode: string
constructor(input: {
accessKeyId: string
accessKeySecret: string
signName: string
templateCode: string
endpoint?: string
}) {
const Config = OpenApiClient.Config
this.client = new Dysmsapi.default(
new Config({
accessKeyId: input.accessKeyId,
accessKeySecret: input.accessKeySecret,
endpoint: (input.endpoint || ALIYUN_DEFAULT_ENDPOINT).replace(/^https?:\/\//, ''),
}),
) as AliyunSmsClient
this.signName = input.signName
this.templateCode = input.templateCode
}
async sendCode(phone: string, code: string) {
try {
const request = new Dysmsapi.SendSmsRequest({
phoneNumbers: phone,
signName: this.signName,
templateCode: this.templateCode,
templateParam: JSON.stringify({ code }),
})
const response = await this.client.sendSmsWithOptions(request, {})
const resultCode = String(response?.body?.code || '')
if (resultCode !== 'OK') {
throw createHttpError(
`短信发送失败:${String(response?.body?.message || resultCode || '未知错误').trim()}`,
{
statusCode: 502,
errorCode: 'worker_sms_send_failed',
},
)
}
} catch (error) {
if (error instanceof Error && 'errorCode' in error) {
throw error
}
throw createHttpError(
`短信发送失败:${error instanceof Error ? error.message : '未知错误'}`,
{
statusCode: 502,
errorCode: 'worker_sms_send_failed',
},
)
}
}
}
@@ -14,8 +14,11 @@ import {
countWorkCategoryUsages,
countWorkerLevelUsages,
countWorkOrderPendingSharingSubmissions,
cancelWorkerWorkOrder,
countWorkerCancellationsSince,
countWorkerSmsCodesOnDay,
cancelWorkerWorkOrder,
consumeWorkerSmsCode,
createWorkerSmsCode,
createWorkOrder,
createWorkOrderEvent,
createWorkerFinanceRequest,
@@ -28,8 +31,10 @@ import {
getWorkCategoryByKey,
getWorkerLevelById,
getWorkerLevelByKey,
getLatestWorkerSmsCode,
getWorkerUserByInviteCode,
getWorkerUserById,
getWorkerUserByPhone,
getWorkerUserByUsername,
getWorkOrderById,
getWorkOrderByOrderItemId,
@@ -79,6 +84,7 @@ import { createHttpError } from '../../utils/http.js'
import { randomId } from '../../utils/random.js'
import { nowIso, addHours } from '../../utils/time.js'
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
import { getSmsProvider } from '../sms/index.js'
import {
normalizeStoredFileUrl,
refreshStoredFileUrl,
@@ -131,23 +137,96 @@ export async function ensureWorkerPlatformDefaults() {
return { level, category }
}
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 && 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 registerWorker(payload: JsonObject = {}) {
ensureWorkerAuthConfigured()
const username = normalizeUsername(payload.username)
const phone = normalizeWorkerPhone(payload.phone)
const code = String(payload.code || payload.smsCode || '').trim()
const password = normalizePassword(payload.password)
const displayName = String(payload.displayName || payload.nickname || username).trim()
const phone = String(payload.phone || '').trim()
const displayName = String(payload.displayName || payload.nickname || phone).trim()
const inviteCode = String(payload.inviteCode || '').trim().toUpperCase()
validateWorkerUsername(username)
validateWorkerPassword(password)
if (!code) {
throw createHttpError('请输入短信验证码', {
statusCode: 400,
errorCode: 'worker_sms_code_required',
})
}
if (await getWorkerUserByUsername(username)) {
throw createHttpError('接单账号已存在', {
const latest = await getLatestWorkerSmsCode(phone, 'register')
const codeValid = Boolean(
latest &&
!latest.consumed_at &&
new Date(latest.expires_at).getTime() > Date.now() &&
latest.code_hash === hashSmsCode(code),
)
if (!codeValid) {
throw createHttpError('短信验证码错误或已过期', {
statusCode: 400,
errorCode: 'worker_sms_code_invalid',
})
}
await consumeWorkerSmsCode(latest!.id, nowIso())
if (await getWorkerUserByUsername(phone)) {
throw createHttpError('该手机号已注册,请直接登录', {
statusCode: 409,
errorCode: 'worker_user_exists',
})
}
if (await getWorkerUserByPhone(phone)) {
throw createHttpError('该手机号已注册,请直接登录', {
statusCode: 409,
errorCode: 'worker_phone_exists',
})
}
const inviter = inviteCode ? await getWorkerUserByInviteCode(inviteCode) : null
if (inviteCode && !inviter) {
@@ -166,12 +245,12 @@ export async function registerWorker(payload: JsonObject = {}) {
const defaults = await ensureWorkerPlatformDefaults()
const now = nowIso()
const created = await createWorkerUser({
username,
username: phone,
passwordHash: hashWorkerPassword(password),
displayName,
phone,
levelId: defaults.level?.id || null,
status: 'pending_review',
status: 'active',
inviterId: inviter ? Number(inviter.id) : null,
inviteCode: await generateInviteCode(now),
now,
@@ -186,10 +265,33 @@ export async function registerWorker(payload: JsonObject = {}) {
return {
worker: mapWorkerUser(created),
reviewRequired: true,
reviewRequired: false,
}
}
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++) {