拆分打手注册服务
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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<string> {
|
||||
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',
|
||||
})
|
||||
}
|
||||
@@ -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<string> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user