修复打手短信验证码发送
This commit is contained in:
@@ -126,5 +126,18 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
||||
cors: {
|
||||
allowedOrigins: ['*'],
|
||||
},
|
||||
|
||||
worker: {
|
||||
sms: {
|
||||
provider: 'mock',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
codeTtlSeconds: 300,
|
||||
cooldownSeconds: 60,
|
||||
dailyLimit: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,14 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
stringEnv('KUAISHOU_FEIFEI_BASE_URL', ['platforms', 'kuaishouFeifei', 'baseUrl']),
|
||||
stringEnv('KUAISHOU_FEIFEI_APP_KEY', ['platforms', 'kuaishouFeifei', 'appKey']),
|
||||
stringEnv('KUAISHOU_FEIFEI_APP_SECRET', ['platforms', 'kuaishouFeifei', 'appSecret']),
|
||||
stringEnv('WORKER_SMS_PROVIDER', ['worker', 'sms', 'provider']),
|
||||
stringEnv('WORKER_SMS_ACCESS_KEY_ID', ['worker', 'sms', 'accessKeyId']),
|
||||
stringEnv('WORKER_SMS_ACCESS_KEY_SECRET', ['worker', 'sms', 'accessKeySecret']),
|
||||
stringEnv('WORKER_SMS_SIGN_NAME', ['worker', 'sms', 'signName']),
|
||||
stringEnv('WORKER_SMS_TEMPLATE_CODE', ['worker', 'sms', 'templateCode']),
|
||||
integerEnv('WORKER_SMS_CODE_TTL_SECONDS', ['worker', 'sms', 'codeTtlSeconds']),
|
||||
integerEnv('WORKER_SMS_COOLDOWN_SECONDS', ['worker', 'sms', 'cooldownSeconds']),
|
||||
integerEnv('WORKER_SMS_DAILY_LIMIT', ['worker', 'sms', 'dailyLimit']),
|
||||
integerEnv('KUAISHOU_FEIFEI_TIMEOUT_MS', ['platforms', 'kuaishouFeifei', 'timeoutMs']),
|
||||
stringEnv('KUAISHOU_FEIFEI_NOTIFY_URL', ['platforms', 'kuaishouFeifei', 'notifyUrl']),
|
||||
kuaishouFeifeiProductRulesEnv('KUAISHOU_FEIFEI_PRODUCT_RULES_JSON', [
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS worker_sms_codes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phone TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'register',
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_sms_codes_phone_created
|
||||
ON worker_sms_codes(phone, created_at DESC);
|
||||
|
||||
UPDATE worker_users wu
|
||||
SET phone = ''
|
||||
FROM (
|
||||
SELECT MIN(id) AS keep_id, phone
|
||||
FROM worker_users
|
||||
WHERE phone != ''
|
||||
GROUP BY phone
|
||||
HAVING COUNT(*) > 1
|
||||
) dup
|
||||
WHERE wu.phone = dup.phone AND wu.id <> dup.keep_id;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_users_phone
|
||||
ON worker_users(phone)
|
||||
WHERE phone != '';
|
||||
|
||||
COMMENT ON TABLE worker_sms_codes IS '打手注册短信验证码(哈希存储,一次性消费)';
|
||||
@@ -2,3 +2,4 @@ export * from './types.js'
|
||||
export * from './shared.js'
|
||||
export * from './worker-repo.js'
|
||||
export * from './work-order-repo.js'
|
||||
export * from './sms-code-repo.js'
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { query } from '../../db/client.js'
|
||||
|
||||
export type WorkerSmsCodeRow = {
|
||||
id: number
|
||||
phone: string
|
||||
purpose: string
|
||||
code_hash: string
|
||||
expires_at: string
|
||||
consumed_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function createWorkerSmsCode(input: {
|
||||
phone: string
|
||||
purpose: string
|
||||
codeHash: string
|
||||
expiresAt: string
|
||||
now: string
|
||||
}): Promise<WorkerSmsCodeRow | null> {
|
||||
const result = await query<WorkerSmsCodeRow>(
|
||||
`
|
||||
INSERT INTO worker_sms_codes (phone, purpose, code_hash, expires_at, consumed_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, $5)
|
||||
RETURNING *
|
||||
`,
|
||||
[input.phone, input.purpose, input.codeHash, input.expiresAt, input.now],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getLatestWorkerSmsCode(
|
||||
phone: string,
|
||||
purpose: string,
|
||||
): Promise<WorkerSmsCodeRow | null> {
|
||||
const result = await query<WorkerSmsCodeRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM worker_sms_codes
|
||||
WHERE phone = $1 AND purpose = $2
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[phone, purpose],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function consumeWorkerSmsCode(
|
||||
codeId: number,
|
||||
now: string,
|
||||
): Promise<WorkerSmsCodeRow | null> {
|
||||
const result = await query<WorkerSmsCodeRow>(
|
||||
`
|
||||
UPDATE worker_sms_codes
|
||||
SET consumed_at = $1
|
||||
WHERE id = $2 AND consumed_at IS NULL
|
||||
RETURNING *
|
||||
`,
|
||||
[now, codeId],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function countWorkerSmsCodesOnDay(
|
||||
phone: string,
|
||||
dayStartIso: string,
|
||||
dayEndIso: string,
|
||||
): Promise<number> {
|
||||
const result = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM worker_sms_codes
|
||||
WHERE phone = $1
|
||||
AND created_at >= $2
|
||||
AND created_at < $3
|
||||
`,
|
||||
[phone, dayStartIso, dayEndIso],
|
||||
)
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
@@ -118,6 +118,14 @@ export async function getWorkerUserByUsername(username: string): Promise<WorkerU
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerUserByPhone(phone: string): Promise<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(
|
||||
`${WORKER_USER_SELECT} WHERE wu.phone = $1 LIMIT 1`,
|
||||
[String(phone || '').trim()],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerUserByInviteCode(
|
||||
inviteCode: string,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
loginWorker,
|
||||
logoutWorkerSession,
|
||||
registerWorker,
|
||||
sendWorkerSmsCode,
|
||||
submitWorkerOrderAcceptance,
|
||||
} from '../services/worker-platform/index.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
@@ -32,16 +33,31 @@ import {
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/auth/sms-code',
|
||||
createRateLimitMiddleware({
|
||||
scope: 'worker:sms-code',
|
||||
windowMs: 60_000,
|
||||
max: 5,
|
||||
key: getBodyFieldRateLimitKey('phone'),
|
||||
}),
|
||||
createRouteHandler((req) => sendWorkerSmsCode(req.body || {}), {
|
||||
successMessage: '验证码已发送',
|
||||
errorMessage: '验证码发送失败',
|
||||
scope: '[worker/auth/sms-code]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/auth/register',
|
||||
createRateLimitMiddleware({
|
||||
scope: 'worker:register',
|
||||
windowMs: 60_000,
|
||||
max: 10,
|
||||
key: getBodyFieldRateLimitKey('username'),
|
||||
max: 5,
|
||||
key: getBodyFieldRateLimitKey('phone'),
|
||||
}),
|
||||
createRouteHandler((req) => registerWorker(req.body || {}), {
|
||||
successMessage: '注册成功,等待后台审核',
|
||||
successMessage: '注册成功,请使用手机号登录',
|
||||
errorMessage: '接单账号注册失败',
|
||||
scope: '[worker/auth/register]',
|
||||
}),
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -118,6 +118,18 @@ export type RuntimeConfig = {
|
||||
productRules: KuaishouFeifeiProductRule[]
|
||||
}
|
||||
}
|
||||
worker: {
|
||||
sms: {
|
||||
provider: string
|
||||
accessKeyId: string
|
||||
accessKeySecret: string
|
||||
signName: string
|
||||
templateCode: string
|
||||
codeTtlSeconds: number
|
||||
cooldownSeconds: number
|
||||
dailyLimit: number
|
||||
}
|
||||
}
|
||||
cors: {
|
||||
allowedOrigins: string[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user