拆分打手平台参数校验

This commit is contained in:
yml2213
2026-08-21 17:51:30 +08:00
parent a954bf7d3d
commit fa0cc7772c
2 changed files with 242 additions and 227 deletions
@@ -12,12 +12,9 @@ import {
type WorkOrderShareRow, type WorkOrderShareRow,
type WorkerWalletLedgerRow, type WorkerWalletLedgerRow,
type WorkerUserRow, type WorkerUserRow,
type GrabWorkOrderFailureReason,
type ProblemWorkOrderResolutionAction,
} from '../../repositories/worker-platform/index.js' } from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js' import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { createHttpError } from '../../utils/http.js'
import { safeParseJson } from '../admin/admin-query-utils.js' import { safeParseJson } from '../admin/admin-query-utils.js'
import { import {
refreshStoredFileUrl, refreshStoredFileUrl,
@@ -46,6 +43,29 @@ import {
normalizeStatuses, normalizeStatuses,
normalizeUploadedFiles, normalizeUploadedFiles,
} from './worker-input-normalizers.js' } from './worker-input-normalizers.js'
export {
assertWithdrawChannelAllowedForAmount,
isWorkerPasswordStrong,
normalizeAdminFinanceReviewStatus,
normalizeAmountFen,
normalizeEnabledStatus,
normalizeFinanceRequestStatus,
normalizeFinanceRequestType,
normalizeMatchType,
normalizeOptionalId,
normalizePassword,
normalizeProblemResolutionAction,
normalizeProofFiles,
normalizeReviewStatus,
normalizeSlugKey,
normalizeUsername,
normalizeWalletLedgerType,
normalizeWithdrawChannel,
normalizeWorkerFreezeReason,
throwGrabWorkOrderFailure,
validateWorkerPassword,
validateWorkerUsername,
} from './worker-platform-normalizers.js'
export { export {
normalizeRequirementFields, normalizeRequirementFields,
normalizeRequirementFieldsFromPayload, normalizeRequirementFieldsFromPayload,
@@ -1123,230 +1143,6 @@ export function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) {
return normalizeSubmittedFields(collect.fields) return normalizeSubmittedFields(collect.fields)
} }
export function normalizeReviewStatus(value: unknown) {
const status = String(value || '').trim()
if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status
return 'pending_review'
}
/** 冻结原因必须可追溯,限制长度避免异常大载荷写入账号资料。 */
export function normalizeWorkerFreezeReason(value: unknown): string {
const reason = String(value || '').trim()
if (!reason) {
throw createHttpError('请填写冻结原因或备注', {
statusCode: 400,
errorCode: 'worker_freeze_reason_required',
})
}
if (reason.length > 500) {
throw createHttpError('冻结原因不能超过 500 个字符', {
statusCode: 400,
errorCode: 'worker_freeze_reason_too_long',
})
}
return reason
}
export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
const action = String(value || '').trim()
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
return action as ProblemWorkOrderResolutionAction
}
throw createHttpError('问题单处置方式不正确', {
statusCode: 400,
errorCode: 'work_order_problem_resolution_action_invalid',
})
}
export function throwGrabWorkOrderFailure(failureReason: GrabWorkOrderFailureReason | null): never {
if (failureReason === 'worker_active_order_limit') {
throw createHttpError('已达到当前等级最大同时接单量', {
statusCode: 409,
errorCode: 'worker_active_order_limit',
})
}
if (failureReason === 'worker_deposit_insufficient') {
throw createHttpError('余额不足,无法冻结所需押金', {
statusCode: 409,
errorCode: 'worker_deposit_insufficient',
})
}
throw createHttpError('抢单失败,订单可能已被抢走', {
statusCode: 409,
errorCode: 'work_order_grab_conflict',
})
}
export function normalizeEnabledStatus(value: unknown) {
return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active'
}
export function normalizeWalletLedgerType(value: unknown) {
const ledgerType = String(value || '').trim()
if (!ledgerType) return ''
if (
[
'manual_credit',
'withdraw_paid',
'deposit_freeze',
'deposit_release',
'deposit_pending_unfreeze',
'deposit_unfreeze',
'deposit_deduction',
'reward_settlement',
'after_sales_deposit_deduction',
'after_sales_balance_deduction',
'after_sales_debt_offset',
].includes(ledgerType)
) {
return ledgerType
}
throw createHttpError('钱包流水类型不正确', {
statusCode: 400,
errorCode: 'worker_wallet_ledger_type_invalid',
})
}
export function normalizeFinanceRequestType(value: unknown) {
const requestType = String(value || '').trim()
if (!requestType) return ''
if (['recharge', 'withdraw'].includes(requestType)) {
return requestType
}
throw createHttpError('资金申请类型不正确', {
statusCode: 400,
errorCode: 'worker_finance_request_type_invalid',
})
}
export function normalizeFinanceRequestStatus(value: unknown) {
const status = String(value || '').trim()
if (!status) return ''
if (['pending', 'approved', 'rejected', 'cancelled'].includes(status)) {
return status
}
throw createHttpError('资金申请状态不正确', {
statusCode: 400,
errorCode: 'worker_finance_request_status_invalid',
})
}
export function normalizeAdminFinanceReviewStatus(value: unknown) {
const status = String(value || '').trim()
if (['approved', 'rejected', 'cancelled'].includes(status)) {
return status
}
throw createHttpError('审核状态不正确', {
statusCode: 400,
errorCode: 'worker_finance_review_status_invalid',
})
}
export function normalizeWithdrawChannel(value: unknown) {
const channel = String(value || '').trim()
if (['alipay', 'wechat'].includes(channel)) {
return channel
}
throw createHttpError('提现方式不正确', {
statusCode: 400,
errorCode: 'worker_withdraw_channel_invalid',
})
}
/** 提现金额超过 100 元时,仅允许使用支付宝。 */
export function assertWithdrawChannelAllowedForAmount(amount: number, channel: string) {
if (amount > 10_000 && channel !== 'alipay') {
throw createHttpError('提现金额超过 100 元时,仅支持支付宝提现', {
statusCode: 400,
errorCode: 'worker_withdraw_channel_amount_limited',
})
}
}
export function normalizeMatchType(value: unknown) {
const matchType = String(value || '').trim()
if (matchType === 'exact') return 'exact'
return 'contains'
}
export function normalizeSlugKey(value: unknown, fallback: string) {
const key = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return key || fallback
}
export function normalizeUsername(value: unknown): string {
return String(value || '')
.trim()
.toLowerCase()
}
export function normalizePassword(value: unknown): string {
return String(value || '').trim()
}
export function validateWorkerUsername(username: string) {
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) {
throw createHttpError('接单账号格式无效,需为 3-32 位字母数字或 ._-', {
statusCode: 400,
errorCode: 'worker_username_invalid',
})
}
}
export function validateWorkerPassword(password: string) {
if (!isWorkerPasswordStrong(password)) {
throw createHttpError('接单密码至少 8 位,且必须包含英文字母和数字', {
statusCode: 400,
errorCode: 'worker_password_invalid',
})
}
}
export function isWorkerPasswordStrong(password: string): boolean {
return password.length >= 8 && /[a-zA-Z]/.test(password) && /\d/.test(password)
}
export function normalizeOptionalId(value: unknown): number | null {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
export function normalizeAmountFen(value: unknown, fallback: number): number {
const text = String(value ?? '').trim()
if (!text) return fallback
const parsed = Number(text)
if (!Number.isFinite(parsed) || parsed < 0) return fallback
return Math.round(parsed * 100)
}
export function normalizeProofFiles(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return []
const files: Array<Record<string, unknown>> = []
for (const item of value) {
if (!item || typeof item !== 'object') continue
const file = item as Record<string, unknown>
const url = String(file.url || '').trim()
if (!url) continue
files.push({
url,
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(),
mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(),
objectKey: String(file.objectKey || file.object_key || '').trim(),
originalFilename: String(
file.originalFilename || file.original_filename || file.name || '',
).trim(),
contentType: String(file.contentType || file.content_type || '').trim(),
sizeBytes: Number(file.sizeBytes || file.size_bytes || 0),
})
if (files.length >= 10) break
}
return files
}
export function isIgnorableWorkerAuthError(error: unknown) { export function isIgnorableWorkerAuthError(error: unknown) {
return IGNORABLE_WORKER_AUTH_ERROR_CODES.has( return IGNORABLE_WORKER_AUTH_ERROR_CODES.has(
String((error as { errorCode?: string })?.errorCode || '').trim(), String((error as { errorCode?: string })?.errorCode || '').trim(),
@@ -0,0 +1,219 @@
import type {
GrabWorkOrderFailureReason,
ProblemWorkOrderResolutionAction,
} from '../../repositories/worker-platform/index.js'
import { createHttpError } from '../../utils/http.js'
export function normalizeReviewStatus(value: unknown) {
const status = String(value || '').trim()
if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status
return 'pending_review'
}
/** 冻结原因必须可追溯,限制长度避免异常大载荷写入账号资料。 */
export function normalizeWorkerFreezeReason(value: unknown): string {
const reason = String(value || '').trim()
if (!reason) {
throw createHttpError('请填写冻结原因或备注', {
statusCode: 400,
errorCode: 'worker_freeze_reason_required',
})
}
if (reason.length > 500) {
throw createHttpError('冻结原因不能超过 500 个字符', {
statusCode: 400,
errorCode: 'worker_freeze_reason_too_long',
})
}
return reason
}
export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction {
const action = String(value || '').trim()
if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) {
return action as ProblemWorkOrderResolutionAction
}
throw createHttpError('问题单处置方式不正确', {
statusCode: 400,
errorCode: 'work_order_problem_resolution_action_invalid',
})
}
export function throwGrabWorkOrderFailure(failureReason: GrabWorkOrderFailureReason | null): never {
if (failureReason === 'worker_active_order_limit') {
throw createHttpError('已达到当前等级最大同时接单量', {
statusCode: 409,
errorCode: 'worker_active_order_limit',
})
}
if (failureReason === 'worker_deposit_insufficient') {
throw createHttpError('余额不足,无法冻结所需押金', {
statusCode: 409,
errorCode: 'worker_deposit_insufficient',
})
}
throw createHttpError('抢单失败,订单可能已被抢走', {
statusCode: 409,
errorCode: 'work_order_grab_conflict',
})
}
export function normalizeEnabledStatus(value: unknown) {
return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active'
}
export function normalizeWalletLedgerType(value: unknown) {
const ledgerType = String(value || '').trim()
if (!ledgerType) return ''
if (
[
'manual_credit',
'withdraw_paid',
'deposit_freeze',
'deposit_release',
'deposit_pending_unfreeze',
'deposit_unfreeze',
'deposit_deduction',
'reward_settlement',
'after_sales_deposit_deduction',
'after_sales_balance_deduction',
'after_sales_debt_offset',
].includes(ledgerType)
) {
return ledgerType
}
throw createHttpError('钱包流水类型不正确', {
statusCode: 400,
errorCode: 'worker_wallet_ledger_type_invalid',
})
}
export function normalizeFinanceRequestType(value: unknown) {
const requestType = String(value || '').trim()
if (!requestType) return ''
if (['recharge', 'withdraw'].includes(requestType)) return requestType
throw createHttpError('资金申请类型不正确', {
statusCode: 400,
errorCode: 'worker_finance_request_type_invalid',
})
}
export function normalizeFinanceRequestStatus(value: unknown) {
const status = String(value || '').trim()
if (!status) return ''
if (['pending', 'approved', 'rejected', 'cancelled'].includes(status)) return status
throw createHttpError('资金申请状态不正确', {
statusCode: 400,
errorCode: 'worker_finance_request_status_invalid',
})
}
export function normalizeAdminFinanceReviewStatus(value: unknown) {
const status = String(value || '').trim()
if (['approved', 'rejected', 'cancelled'].includes(status)) return status
throw createHttpError('审核状态不正确', {
statusCode: 400,
errorCode: 'worker_finance_review_status_invalid',
})
}
export function normalizeWithdrawChannel(value: unknown) {
const channel = String(value || '').trim()
if (['alipay', 'wechat'].includes(channel)) return channel
throw createHttpError('提现方式不正确', {
statusCode: 400,
errorCode: 'worker_withdraw_channel_invalid',
})
}
/** 提现金额超过 100 元时,仅允许使用支付宝。 */
export function assertWithdrawChannelAllowedForAmount(amount: number, channel: string) {
if (amount > 10_000 && channel !== 'alipay') {
throw createHttpError('提现金额超过 100 元时,仅支持支付宝提现', {
statusCode: 400,
errorCode: 'worker_withdraw_channel_amount_limited',
})
}
}
export function normalizeMatchType(value: unknown) {
return String(value || '').trim() === 'exact' ? 'exact' : 'contains'
}
export function normalizeSlugKey(value: unknown, fallback: string) {
const key = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return key || fallback
}
export function normalizeUsername(value: unknown): string {
return String(value || '')
.trim()
.toLowerCase()
}
export function normalizePassword(value: unknown): string {
return String(value || '').trim()
}
export function validateWorkerUsername(username: string) {
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) {
throw createHttpError('接单账号格式无效,需为 3-32 位字母数字或 ._-', {
statusCode: 400,
errorCode: 'worker_username_invalid',
})
}
}
export function validateWorkerPassword(password: string) {
if (!isWorkerPasswordStrong(password)) {
throw createHttpError('接单密码至少 8 位,且必须包含英文字母和数字', {
statusCode: 400,
errorCode: 'worker_password_invalid',
})
}
}
export function isWorkerPasswordStrong(password: string): boolean {
return password.length >= 8 && /[a-zA-Z]/.test(password) && /\d/.test(password)
}
export function normalizeOptionalId(value: unknown): number | null {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
export function normalizeAmountFen(value: unknown, fallback: number): number {
const text = String(value ?? '').trim()
if (!text) return fallback
const parsed = Number(text)
if (!Number.isFinite(parsed) || parsed < 0) return fallback
return Math.round(parsed * 100)
}
export function normalizeProofFiles(value: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(value)) return []
const files: Array<Record<string, unknown>> = []
for (const item of value) {
if (!item || typeof item !== 'object') continue
const file = item as Record<string, unknown>
const url = String(file.url || '').trim()
if (!url) continue
files.push({
url,
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(),
mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(),
objectKey: String(file.objectKey || file.object_key || '').trim(),
originalFilename: String(
file.originalFilename || file.original_filename || file.name || '',
).trim(),
contentType: String(file.contentType || file.content_type || '').trim(),
sizeBytes: Number(file.sizeBytes || file.size_bytes || 0),
})
if (files.length >= 10) break
}
return files
}