修复接单注册审核与拼单状态校验
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE worker_sms_codes
|
||||||
|
ADD COLUMN IF NOT EXISTS failed_attempt_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN worker_sms_codes.failed_attempt_count IS '当前验证码已失败的校验次数,达到上限后验证码锁定';
|
||||||
@@ -7,6 +7,7 @@ export type WorkerSmsCodeRow = {
|
|||||||
code_hash: string
|
code_hash: string
|
||||||
expires_at: string
|
expires_at: string
|
||||||
consumed_at: string | null
|
consumed_at: string | null
|
||||||
|
failed_attempt_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,29 @@ export async function consumeWorkerSmsCode(
|
|||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 记录错误验证码尝试,达到上限后立即锁定该验证码。 */
|
||||||
|
export async function recordWorkerSmsCodeFailure(
|
||||||
|
codeId: number,
|
||||||
|
now: string,
|
||||||
|
maxAttempts: number,
|
||||||
|
): Promise<WorkerSmsCodeRow | null> {
|
||||||
|
const result = await query<WorkerSmsCodeRow>(
|
||||||
|
`
|
||||||
|
UPDATE worker_sms_codes
|
||||||
|
SET
|
||||||
|
failed_attempt_count = COALESCE(failed_attempt_count, 0) + 1,
|
||||||
|
consumed_at = CASE
|
||||||
|
WHEN COALESCE(failed_attempt_count, 0) + 1 >= $3 THEN $2
|
||||||
|
ELSE consumed_at
|
||||||
|
END
|
||||||
|
WHERE id = $1 AND consumed_at IS NULL
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[codeId, now, Math.max(1, Math.floor(maxAttempts))],
|
||||||
|
)
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
export async function countWorkerSmsCodesOnDay(
|
export async function countWorkerSmsCodesOnDay(
|
||||||
phone: string,
|
phone: string,
|
||||||
dayStartIso: string,
|
dayStartIso: string,
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export async function getWorkOrderShare(
|
|||||||
): Promise<WorkOrderShareRow | null> {
|
): Promise<WorkOrderShareRow | null> {
|
||||||
const result = await query<WorkOrderShareRow>(
|
const result = await query<WorkOrderShareRow>(
|
||||||
`${WORK_ORDER_SHARE_SELECT}
|
`${WORK_ORDER_SHARE_SELECT}
|
||||||
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 LIMIT 1`,
|
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' LIMIT 1`,
|
||||||
[Number(workOrderId), Number(workerId)],
|
[Number(workOrderId), Number(workerId)],
|
||||||
)
|
)
|
||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
@@ -2394,7 +2394,10 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
|||||||
`UPDATE work_order_shares SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
`UPDATE work_order_shares SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
||||||
[input.now, input.shareId],
|
[input.now, input.shareId],
|
||||||
)
|
)
|
||||||
const progressResult = await client.query<{ joined_quantity: number; unfinished_count: number }>(
|
const progressResult = await client.query<{
|
||||||
|
joined_quantity: number
|
||||||
|
unfinished_count: number
|
||||||
|
}>(
|
||||||
`SELECT COALESCE(SUM(quantity), 0)::int AS joined_quantity,
|
`SELECT COALESCE(SUM(quantity), 0)::int AS joined_quantity,
|
||||||
COUNT(*) FILTER (WHERE status != 'accepted' AND status != 'cancelled')::int AS unfinished_count
|
COUNT(*) FILTER (WHERE status != 'accepted' AND status != 'cancelled')::int AS unfinished_count
|
||||||
FROM work_order_shares WHERE work_order_id = $1 AND status != 'cancelled'`,
|
FROM work_order_shares WHERE work_order_id = $1 AND status != 'cancelled'`,
|
||||||
@@ -3195,9 +3198,11 @@ function buildWorkOrderWhere({
|
|||||||
AND wo.acceptance_json->>'evidenceStatus' = 'pending'
|
AND wo.acceptance_json->>'evidenceStatus' = 'pending'
|
||||||
AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||||
`
|
`
|
||||||
if (normalizedStatuses.length > 0) {
|
if (normalizedStatuses.length > 0) {
|
||||||
params.push(normalizedStatuses)
|
params.push(normalizedStatuses)
|
||||||
filters.push(`(${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`)
|
filters.push(
|
||||||
|
`(${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
filters.push(`(${vipEvidencePendingWhere})`)
|
filters.push(`(${vipEvidencePendingWhere})`)
|
||||||
}
|
}
|
||||||
@@ -3210,7 +3215,9 @@ function buildWorkOrderWhere({
|
|||||||
`${workerOrderStatusExpression(`$${params.length}`)} = ANY(${statusesPlaceholder}::text[])`,
|
`${workerOrderStatusExpression(`$${params.length}`)} = ANY(${statusesPlaceholder}::text[])`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
filters.push(`${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`)
|
filters.push(
|
||||||
|
`${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (workOrderId) {
|
if (workOrderId) {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ router.post(
|
|||||||
key: getBodyFieldRateLimitKey('phone'),
|
key: getBodyFieldRateLimitKey('phone'),
|
||||||
}),
|
}),
|
||||||
createRouteHandler((req) => registerWorker(req.body || {}), {
|
createRouteHandler((req) => registerWorker(req.body || {}), {
|
||||||
successMessage: '注册成功,请使用手机号登录',
|
successMessage: '注册成功,请等待后台审核',
|
||||||
errorMessage: '接单账号注册失败',
|
errorMessage: '接单账号注册失败',
|
||||||
scope: '[worker/auth/register]',
|
scope: '[worker/auth/register]',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -134,8 +134,10 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const [encodedPayload, signature] = normalizedToken.split('.')
|
const parts = normalizedToken.split('.')
|
||||||
if (!encodedPayload || !signature) {
|
const encodedPayload = parts[0]
|
||||||
|
const signature = parts[1]
|
||||||
|
if (parts.length !== 2 || !encodedPayload || !signature) {
|
||||||
throw createHttpError('后台登录态无效', {
|
throw createHttpError('后台登录态无效', {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
errorCode: 'admin_auth_invalid',
|
errorCode: 'admin_auth_invalid',
|
||||||
|
|||||||
@@ -2057,7 +2057,11 @@ export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName
|
|||||||
const workOrders = await Promise.all(
|
const workOrders = await Promise.all(
|
||||||
workOrderIds.map((workOrderId) => getRequiredWorkOrder(workOrderId)),
|
workOrderIds.map((workOrderId) => getRequiredWorkOrder(workOrderId)),
|
||||||
)
|
)
|
||||||
if (workOrders.some((workOrder) => workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE)) {
|
if (
|
||||||
|
workOrders.some(
|
||||||
|
(workOrder) => mapWorkOrderAdmin(workOrder).status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw createHttpError('选中的订单包含非待验收状态,请刷新后重试', {
|
throw createHttpError('选中的订单包含非待验收状态,请刷新后重试', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'work_order_batch_accept_status_invalid',
|
errorCode: 'work_order_batch_accept_status_invalid',
|
||||||
@@ -2204,7 +2208,12 @@ export async function getAdminWorkerPlatformSummary() {
|
|||||||
statuses: [WORK_ORDER_STATUS.OPEN],
|
statuses: [WORK_ORDER_STATUS.OPEN],
|
||||||
excludeFilledSharing: true,
|
excludeFilledSharing: true,
|
||||||
}),
|
}),
|
||||||
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.IN_PROGRESS], useOperationalStatus: true }),
|
listWorkOrders({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
statuses: [WORK_ORDER_STATUS.IN_PROGRESS],
|
||||||
|
useOperationalStatus: true,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
return {
|
return {
|
||||||
pendingWorkerCount: pendingWorkers.total,
|
pendingWorkerCount: pendingWorkers.total,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getWorkCategoryByKey,
|
getWorkCategoryByKey,
|
||||||
getWorkerLevelByKey,
|
getWorkerLevelByKey,
|
||||||
getLatestWorkerSmsCode,
|
getLatestWorkerSmsCode,
|
||||||
|
recordWorkerSmsCodeFailure,
|
||||||
getWorkerUserByInviteCode,
|
getWorkerUserByInviteCode,
|
||||||
getWorkerUserById,
|
getWorkerUserById,
|
||||||
getWorkerWithdrawalAccount,
|
getWorkerWithdrawalAccount,
|
||||||
@@ -141,6 +142,7 @@ import {
|
|||||||
|
|
||||||
const WORKER_DAILY_WITHDRAW_LIMIT = 3
|
const WORKER_DAILY_WITHDRAW_LIMIT = 3
|
||||||
const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24
|
const VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS = 24
|
||||||
|
const WORKER_SMS_MAX_VERIFY_ATTEMPTS = 5
|
||||||
|
|
||||||
export type WorkerSession = {
|
export type WorkerSession = {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -198,7 +200,11 @@ export async function sendWorkerSmsCode(payload: JsonObject = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latest = await getLatestWorkerSmsCode(phone, 'register')
|
const latest = await getLatestWorkerSmsCode(phone, 'register')
|
||||||
if (latest && new Date(latest.created_at).getTime() > Date.now() - cooldownMs) {
|
if (
|
||||||
|
latest &&
|
||||||
|
!latest.consumed_at &&
|
||||||
|
new Date(latest.created_at).getTime() > Date.now() - cooldownMs
|
||||||
|
) {
|
||||||
throw createHttpError('验证码发送过于频繁,请稍后再试', {
|
throw createHttpError('验证码发送过于频繁,请稍后再试', {
|
||||||
statusCode: 429,
|
statusCode: 429,
|
||||||
errorCode: 'worker_sms_cooldown',
|
errorCode: 'worker_sms_cooldown',
|
||||||
@@ -262,13 +268,24 @@ export async function registerWorker(payload: JsonObject = {}) {
|
|||||||
latest.code_hash === hashSmsCode(code),
|
latest.code_hash === hashSmsCode(code),
|
||||||
)
|
)
|
||||||
if (!codeValid) {
|
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('短信验证码错误或已过期', {
|
throw createHttpError('短信验证码错误或已过期', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
errorCode: 'worker_sms_code_invalid',
|
errorCode: 'worker_sms_code_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await consumeWorkerSmsCode(latest!.id, nowIso())
|
|
||||||
|
|
||||||
if (await getWorkerUserByUsername(phone)) {
|
if (await getWorkerUserByUsername(phone)) {
|
||||||
throw createHttpError('该手机号已注册,请直接登录', {
|
throw createHttpError('该手机号已注册,请直接登录', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
@@ -311,7 +328,7 @@ export async function registerWorker(payload: JsonObject = {}) {
|
|||||||
displayName,
|
displayName,
|
||||||
phone,
|
phone,
|
||||||
levelId: defaults.level?.id || null,
|
levelId: defaults.level?.id || null,
|
||||||
status: 'active',
|
status: 'pending_review',
|
||||||
inviterId: inviter ? Number(inviter.id) : null,
|
inviterId: inviter ? Number(inviter.id) : null,
|
||||||
inviteCode: await generateInviteCode(now),
|
inviteCode: await generateInviteCode(now),
|
||||||
now,
|
now,
|
||||||
@@ -324,9 +341,12 @@ export async function registerWorker(payload: JsonObject = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 账号创建成功后再消费验证码,避免邀请码、昵称或数据库异常导致验证码提前失效。
|
||||||
|
await consumeWorkerSmsCode(latest!.id, nowIso())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worker: mapWorkerUser(created),
|
worker: mapWorkerUser(created),
|
||||||
reviewRequired: false,
|
reviewRequired: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,8 +554,15 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const [encodedPayload, signature] = normalizedToken.split('.')
|
const parts = normalizedToken.split('.')
|
||||||
if (!encodedPayload || !signature || !safeCompare(signature, signWorkerPayload(encodedPayload))) {
|
const encodedPayload = parts[0]
|
||||||
|
const signature = parts[1]
|
||||||
|
if (
|
||||||
|
parts.length !== 2 ||
|
||||||
|
!encodedPayload ||
|
||||||
|
!signature ||
|
||||||
|
!safeCompare(signature, signWorkerPayload(encodedPayload))
|
||||||
|
) {
|
||||||
throw createHttpError('接单登录态无效', {
|
throw createHttpError('接单登录态无效', {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
errorCode: 'worker_auth_invalid',
|
errorCode: 'worker_auth_invalid',
|
||||||
|
|||||||
@@ -155,8 +155,10 @@ export default function WorkerLoginPage() {
|
|||||||
}) {
|
}) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await registerWorker(values)
|
const response = await registerWorker(values)
|
||||||
message.success('注册成功,请使用手机号登录')
|
message.success(
|
||||||
|
response.data.reviewRequired ? '注册成功,请等待后台审核' : '注册成功,请使用手机号登录',
|
||||||
|
)
|
||||||
registerForm.resetFields()
|
registerForm.resetFields()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '注册失败')
|
message.error(error instanceof Error ? error.message : '注册失败')
|
||||||
|
|||||||
Reference in New Issue
Block a user