diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index fdb4ce43..d170a019 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -6,6 +6,7 @@ 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-registration-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-registration-service.ts b/apps/backend/src/services/worker-platform/worker-registration-service.ts new file mode 100644 index 00000000..3d67a27a --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-registration-service.ts @@ -0,0 +1,208 @@ +import crypto from 'node:crypto' + +import { + consumeWorkerSmsCode, + createWorkerUser, + getLatestWorkerSmsCode, + getWorkerUserByDisplayName, + getWorkerUserByInviteCode, + getWorkerUserByPhone, + getWorkerUserByUsername, + recordWorkerSmsCodeFailure, + revokeAllWorkerSessions, + updateWorkerPassword, +} 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 { + ensureWorkerAuthConfigured, + hashWorkerPassword, + mapWorkerUser, + normalizePassword, + normalizeSessionVersion, + validateWorkerPassword, +} from './mappers.js' +import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' +import { hashSmsCode, normalizeWorkerPhone } from './worker-sms-service.js' + +const INVITE_CODE_LENGTH = 8 +const INVITE_CODE_RETRY_LIMIT = 5 +const WORKER_SMS_MAX_VERIFY_ATTEMPTS = 5 + +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 = isWorkerSmsCodeValid(latest, code) + if (!codeValid) { + await recordInvalidWorkerSmsCode(latest) + throw createHttpError('短信验证码错误或已过期', { + statusCode: 400, + errorCode: 'worker_sms_code_invalid', + }) + } + if (!(await consumeWorkerSmsCode(latest!.id, nowIso()))) { + 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) + const code = String(payload.code || payload.smsCode || '').trim() + const password = normalizePassword(payload.password) + const displayName = String(payload.displayName || payload.nickname || '').trim() + const inviteCode = String(payload.inviteCode || '') + .trim() + .toUpperCase() + + validateWorkerPassword(password) + if (!displayName) { + throw createHttpError('请输入昵称', { + statusCode: 400, + errorCode: 'worker_display_name_required', + }) + } + if (!code) { + throw createHttpError('请输入短信验证码', { + statusCode: 400, + errorCode: 'worker_sms_code_required', + }) + } + + const latest = await getLatestWorkerSmsCode(phone, 'register') + if (!isWorkerSmsCodeValid(latest, code)) { + await recordInvalidWorkerSmsCode(latest) + throw createHttpError('短信验证码错误或已过期', { + statusCode: 400, + errorCode: 'worker_sms_code_invalid', + }) + } + 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) { + throw createHttpError('邀请码无效', { + statusCode: 400, + errorCode: 'worker_invite_code_invalid', + }) + } + if (inviter && inviter.status !== 'active') { + throw createHttpError('邀请人账号不可用', { + statusCode: 400, + errorCode: 'worker_inviter_invalid', + }) + } + if (await getWorkerUserByDisplayName(displayName)) { + throw createHttpError('该昵称已被使用,请更换昵称', { + statusCode: 409, + errorCode: 'worker_display_name_taken', + }) + } + + const defaults = await ensureWorkerPlatformDefaults() + const now = nowIso() + const created = await createWorkerUser({ + username: phone, + passwordHash: hashWorkerPassword(password), + displayName, + phone, + levelId: defaults.level?.id || null, + status: 'active', + inviterId: inviter ? Number(inviter.id) : null, + inviteCode: await generateInviteCode(), + smsCodeId: latest!.id, + now, + }) + if (!created) { + throw createHttpError('接单账号注册失败', { + statusCode: 500, + errorCode: 'worker_register_failed', + }) + } + return { worker: mapWorkerUser(created), reviewRequired: false } +} + +function isWorkerSmsCodeValid( + latest: { consumed_at: string | null; expires_at: string; code_hash: string } | null, + code: string, +) { + return Boolean( + latest && + !latest.consumed_at && + new Date(latest.expires_at).getTime() > Date.now() && + latest.code_hash === hashSmsCode(code), + ) +} + +async function recordInvalidWorkerSmsCode( + latest: { id: number; consumed_at: string | null } | null, +) { + if (!latest || latest.consumed_at) return + 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', + }) + } +} + +async function generateInviteCode(): Promise { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt += 1) { + const candidate = Array.from( + { length: INVITE_CODE_LENGTH }, + () => chars[Math.floor(crypto.randomInt(chars.length))], + ).join('') + if (!(await getWorkerUserByInviteCode(candidate))) return candidate + } + throw createHttpError('邀请码生成失败,请重试', { + statusCode: 500, + errorCode: 'worker_invite_code_generation_failed', + }) +} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 011e3caa..11f421f4 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -1,5 +1,3 @@ -import crypto from 'node:crypto' - import { runtimeConfig } from '../../config/runtime.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { @@ -15,12 +13,10 @@ import { consumeWorkerSmsCode, createWorkOrderEvent, createWorkerFinanceRequest, - createWorkerUser, createWorkerWithdrawalAccount as createWorkerWithdrawalAccountRecord, getWorkerFinanceRequestSummary, getLatestWorkerSmsCode, recordWorkerSmsCodeFailure, - getWorkerUserByInviteCode, getWorkerUserById, getWorkerWithdrawalAccount, getWorkerUserByPhone, @@ -34,7 +30,6 @@ import { getWorkerSessionBySessionId, listWorkerSessions, revokeWorkerSession, - revokeAllWorkerSessions, touchWorkerSession, joinWorkOrderShare, listPendingMaterialWorkOrdersByPlatformOrderId, @@ -67,7 +62,6 @@ import { updateWorkOrderShareDraftAcceptance, upsertWorkerWorkOrderNote, markWorkerWorkOrderViewed, - updateWorkerPassword, type WorkOrderRow, type WorkOrderShareRow, type WorkerSessionRow, @@ -103,8 +97,6 @@ import { } from '../realtime/realtime-event-service.js' import { - INVITE_CODE_LENGTH, - INVITE_CODE_RETRY_LIMIT, createWorkerSession, ensureWorkerAuthConfigured, hashWorkerPassword, @@ -153,6 +145,7 @@ import { normalizeWorkerDeviceType, } from './worker-auth-policy.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' +import { registerWorker, resetWorkerPassword } from './worker-registration-service.js' import { hashSmsCode, normalizeWorkerPhone, @@ -189,6 +182,7 @@ export { } from './work-order-timeout-policy.js' export type { WorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' export { changeWorkerPassword } from './worker-credentials-service.js' +export { registerWorker, resetWorkerPassword } from './worker-registration-service.js' export { sendWorkerLoginSmsCode, sendWorkerPasswordResetSmsCode, @@ -204,200 +198,6 @@ export { const WORKER_MAX_DEVICES = 3 -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) - const code = String(payload.code || payload.smsCode || '').trim() - const password = normalizePassword(payload.password) - const displayName = String(payload.displayName || payload.nickname || '').trim() - const inviteCode = String(payload.inviteCode || '') - .trim() - .toUpperCase() - - validateWorkerPassword(password) - if (!displayName) { - throw createHttpError('请输入昵称', { - statusCode: 400, - errorCode: 'worker_display_name_required', - }) - } - if (!code) { - throw createHttpError('请输入短信验证码', { - statusCode: 400, - errorCode: 'worker_sms_code_required', - }) - } - - 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) { - if (latest && !latest.consumed_at) { - const failed = await recordWorkerSmsCodeFailure( - latest.id, - nowIso(), - WORKER_SMS_MAX_VERIFY_ATTEMPTS, - ) - if (failed && failed.consumed_at) { - throw createHttpError('验证码错误次数过多,请重新获取验证码', { - statusCode: 400, - errorCode: 'worker_sms_code_locked', - }) - } - } - throw createHttpError('短信验证码错误或已过期', { - statusCode: 400, - errorCode: 'worker_sms_code_invalid', - }) - } - 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) { - throw createHttpError('邀请码无效', { - statusCode: 400, - errorCode: 'worker_invite_code_invalid', - }) - } - if (inviter && inviter.status !== 'active') { - throw createHttpError('邀请人账号不可用', { - statusCode: 400, - errorCode: 'worker_inviter_invalid', - }) - } - - if (await getWorkerUserByDisplayName(displayName)) { - throw createHttpError('该昵称已被使用,请更换昵称', { - statusCode: 409, - errorCode: 'worker_display_name_taken', - }) - } - - const defaults = await ensureWorkerPlatformDefaults() - const now = nowIso() - const created = await createWorkerUser({ - username: phone, - passwordHash: hashWorkerPassword(password), - displayName, - phone, - levelId: defaults.level?.id || null, - // 手机号验证码已完成注册身份校验,账号直接启用,无需人工审核。 - status: 'active', - inviterId: inviter ? Number(inviter.id) : null, - inviteCode: await generateInviteCode(now), - smsCodeId: latest!.id, - now, - }) - - if (!created) { - throw createHttpError('接单账号注册失败', { - statusCode: 500, - errorCode: 'worker_register_failed', - }) - } - - return { - worker: mapWorkerUser(created), - // 保留字段兼容旧版客户端,但注册流程不再需要后台审核。 - reviewRequired: false, - } -} - -async function generateInviteCode(_now: string): Promise { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) { - const candidate = Array.from( - { length: INVITE_CODE_LENGTH }, - () => chars[Math.floor(crypto.randomInt(chars.length))], - ).join('') - if (!(await getWorkerUserByInviteCode(candidate))) { - return candidate - } - } - throw createHttpError('邀请码生成失败,请重试', { - statusCode: 500, - errorCode: 'worker_invite_code_generation_failed', - }) -} - export async function loginWorker( username: unknown, password: unknown,