diff --git a/log-optimization-artifacts/BASELINE_FILE.ts b/log-optimization-artifacts/BASELINE_FILE.ts deleted file mode 100644 index efeb1f97..00000000 --- a/log-optimization-artifacts/BASELINE_FILE.ts +++ /dev/null @@ -1,685 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import process from 'node:process' -import util from 'node:util' - -import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js' -import type { HttpErrorLike } from './http.js' -import { maskSecret } from './masking.js' -import { asJsonObject } from '../types/json.js' - -type LogLevel = 'debug' | 'info' | 'warn' | 'error' -type LogChannel = 'app' | 'integration' -type LogDetail = unknown -type InlineFields = Record -type ExternalHttpPacketDetail = Record -type LogEntry = { - time: string - level: LogLevel - scope: string - message: string - pid: number - detail?: unknown -} - -const LOG_LEVEL_PRIORITY: Record = { - debug: 10, - info: 20, - warn: 30, - error: 40, -} - -const LEVEL_LABELS: Record = { - debug: 'DEBUG', - info: 'INFO ', - warn: 'WARN ', - error: 'ERROR', -} - -const ANSI = { - reset: '\u001B[0m', - dim: '\u001B[2m', - bold: '\u001B[1m', - gray: '\u001B[90m', - cyan: '\u001B[36m', - blue: '\u001B[34m', - yellow: '\u001B[33m', - red: '\u001B[31m', -} - -const DATA_ROOT = resolveDataRoot() -const LOG_DIR = path.join(DATA_ROOT, 'logs') -const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) -/** integration 通道独立阈值,默认 warn(仅错误/告警落盘) */ -const ACTIVE_INTEGRATION_LOG_LEVEL = normalizeLogLevel( - runtimeConfig.logging?.integrationLevel ?? 'warn', -) -const DEFAULT_LOG_RETENTION_DAYS = 30 -const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays) -const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log']) -const SENSITIVE_KEY_PATTERN = - /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session|x-sign|(?:^|[_-])sign(?:ature)?(?:[_-]|$)/i -const SENSITIVE_URL_PARAM_PATTERN = - /([?&](?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key|sign|signature|code)=)([^&\s,;"']+)/gi -const SENSITIVE_JSON_FIELD_PATTERN = - /("(?:token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardNo|cardPwd|apiKey|api_key|deviceKey|device_key|session|x-sign|sign|signature)"\s*:\s*")([^"]*)(")/gi -const MAX_SANITIZE_DEPTH = 8 - -let writeQueue = Promise.resolve() -let lastCleanupDateKey = '' - -export function createRequestId(prefix = 'req'): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` -} - -export function logDebug(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('debug', scope, message, detail) -} - -export function logInfo(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('info', scope, message, detail) -} - -export function logWarn(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('warn', scope, message, detail) -} - -export function logError(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('error', scope, message, detail) -} - -export function logIntegration( - scope: unknown, - message: unknown, - detail: LogDetail = undefined, - { level = 'info' }: { level?: LogLevel } = {}, -) { - // 默认 integration 阈值为 warn:失败类文案即使调用方传 info 也提升,避免漏记异常 - return writeLog(resolveIntegrationLogLevel(level, message), scope, message, detail, { - channel: 'integration', - }) -} - -function resolveIntegrationLogLevel(level: LogLevel, message: unknown): LogLevel { - const normalized = normalizeLogLevel(level) - if (normalized === 'warn' || normalized === 'error') { - return normalized - } - - const text = String(message || '') - if (/失败|异常|错误|超时|拒绝|expired|fail|error|timeout|denied/i.test(text)) { - return 'error' - } - - return normalized -} - -export function logExternalHttpPacket( - scope: unknown, - message: unknown, - detail: ExternalHttpPacketDetail = {}, - { level = 'info' }: { level?: LogLevel } = {}, -) { - return logIntegration(scope, message, normalizeExternalHttpPacketDetail(detail), { level }) -} - -export function logCapture( - scope: unknown, - message: unknown, - detail: LogDetail = undefined, - { channel = 'capture', sanitize = false }: { channel?: string; sanitize?: boolean } = {}, -): LogEntry | null { - const entry = { - time: new Date().toISOString(), - level: 'info' as LogLevel, - scope: String(scope || 'capture').trim() || 'capture', - message: String(message || '').trim() || '-', - pid: process.pid, - detail: normalizeLogValue(detail, sanitize !== false), - } - - enqueueFileWrite(entry, resolveLogFilePath(channel, entry.time), sanitize !== false, true) - return entry -} - -function writeLog( - level: LogLevel, - scope: unknown, - message: unknown, - detail: LogDetail, - { channel = 'app' }: { channel?: LogChannel } = {}, -): LogEntry | null { - const configuredLevel = - channel === 'integration' ? ACTIVE_INTEGRATION_LOG_LEVEL : ACTIVE_LOG_LEVEL - if (!shouldWriteLog(level, configuredLevel)) { - return null - } - - const entry = { - time: new Date().toISOString(), - level: normalizeLogLevel(level), - scope: String(scope || 'app').trim() || 'app', - message: String(message || '').trim() || '-', - pid: process.pid, - detail: normalizeLogValue(detail), - } - - writeConsole(entry) - enqueueFileWrite(entry, resolveLogFilePath(channel, entry.time), true) - return entry -} - -function enqueueFileWrite( - entry: LogEntry, - filePath: string, - sanitize: boolean, - jsonLine = false, -): void { - const line = jsonLine ? JSON.stringify(entry) : formatLogEntry(entry, { color: false, sanitize }) - const dateKey = extractLogDateKey(entry.time) - - writeQueue = writeQueue - .then(async () => { - await fs.mkdir(LOG_DIR, { recursive: true }) - await fs.appendFile(filePath, `${line}\n`, 'utf8') - await cleanupExpiredLogsIfNeeded(dateKey) - }) - .catch((error) => { - const reason = error instanceof Error ? error.message : String(error || '未知错误') - console.error('[logger] failed to write log file:', reason) - }) -} - -function writeConsole(entry: LogEntry): void { - const logger = resolveConsoleMethod(entry.level) - logger(formatLogEntry(entry, { color: supportsAnsiColor() })) -} - -function resolveConsoleMethod(level: unknown) { - const normalized = normalizeLogLevel(level) - - if (normalized === 'error') { - return console.error - } - - if (normalized === 'warn') { - return console.warn - } - - return console.log -} - -export function normalizeLogLevel(level: unknown): LogLevel { - const normalized = String(level || '') - .trim() - .toLowerCase() - return isLogLevel(normalized) ? normalized : 'info' -} - -export function shouldWriteLog( - level: unknown, - configuredLevel: unknown = ACTIVE_LOG_LEVEL, -): boolean { - const normalizedLevel = normalizeLogLevel(level) - const normalizedConfigured = normalizeLogLevel(configuredLevel) - - return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] -} - -export function formatLogEntry( - entry: Partial, - { color = false, sanitize = true }: { color?: boolean; sanitize?: boolean } = {}, -): string { - const normalizedEntry = { - time: String(entry?.time || new Date().toISOString()), - level: normalizeLogLevel(entry?.level), - scope: String(entry?.scope || 'app').trim() || 'app', - message: String(entry?.message || '').trim() || '-', - pid: Number(entry?.pid || process.pid), - detail: sanitize !== false ? sanitizeLogValue(entry?.detail) : entry?.detail, - } - const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color) - const level = colorize( - LEVEL_LABELS[normalizedEntry.level], - resolveLevelColor(normalizedEntry.level), - color, - ) - const scope = colorize(normalizedEntry.scope, ANSI.cyan, color) - const message = colorize(normalizedEntry.message, ANSI.bold, color) - const separator = colorize('|', ANSI.dim, color) - const { inline, block } = splitDetailPayload(normalizedEntry.detail) - const inlineFields = formatInlineFields({ - pid: normalizedEntry.pid, - ...inline, - }) - const firstLine = [timestamp, level, scope, message, separator, inlineFields] - .filter(Boolean) - .join(' ') - - if (!block) { - return firstLine - } - - const inspected = util.inspect(block, { - depth: 8, - colors: color, - compact: false, - breakLength: 120, - maxArrayLength: 100, - }) - - return `${firstLine}\n${indentBlock(inspected, ' ')}` -} - -export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()): string { - const normalizedChannel = normalizeLogChannelName(channel) - return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`) -} - -function normalizeLogChannelName(value: unknown): string { - const normalized = String(value || '') - .trim() - .toLowerCase() - if (!normalized) { - return 'app' - } - if (normalized === 'integration') { - return 'integration' - } - return /^[a-z0-9-]{1,40}$/.test(normalized) ? normalized : 'app' -} - -export function resolveExpiredLogFilenames( - fileNames: string[] = [], - referenceTime = new Date().toISOString(), - retentionDays = LOG_RETENTION_DAYS, -): string[] { - const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays) - const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) - - return (Array.isArray(fileNames) ? fileNames : []).filter((fileName) => - shouldDeleteLogFileByDate(fileName, cutoffDateKey), - ) -} - -export function formatLogTimestamp(value: unknown): string { - const date = new Date(value instanceof Date ? value : String(value || '')) - - if (Number.isNaN(date.getTime())) { - return String(value || '').trim() || new Date().toISOString() - } - - return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}` -} - -function resolveDataRoot(): string { - const configured = String(runtimeConfig.data?.root || '').trim() - return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data') -} - -function normalizeLogValue(value: unknown, sanitize = true): unknown { - if (typeof value === 'undefined') { - return undefined - } - - const normalizeCurrent = (current: unknown): unknown => { - if (current instanceof Error) { - const currentError = current as Error & HttpErrorLike - return { - name: currentError.name, - message: sanitize ? sanitizeLogString(currentError.message) : currentError.message, - stack: sanitize ? sanitizeLogString(currentError.stack) : currentError.stack, - statusCode: currentError.statusCode, - errorCode: currentError.errorCode, - context: sanitize ? sanitizeLogValue(currentError.context) : currentError.context, - } - } - - if (typeof current === 'bigint') { - return String(current) - } - - return current - } - - try { - return sanitize - ? sanitizeLogValue( - JSON.parse(JSON.stringify(value, (_key, current) => normalizeCurrent(current))), - ) - : JSON.parse(JSON.stringify(value, (_key, current) => normalizeCurrent(current))) - } catch { - return sanitize ? sanitizeLogString(String(value)) : String(value) - } -} - -function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { - if (typeof value === 'undefined') { - return undefined - } - - if (value === null) { - return null - } - - if (isSensitiveLogKey(key)) { - return maskSecret(value) - } - - if (typeof value === 'string') { - return sanitizeLogString(value) - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return value - } - - if (typeof value === 'bigint') { - return String(value) - } - - if (depth >= MAX_SANITIZE_DEPTH) { - return '[MaxDepth]' - } - - if (Array.isArray(value)) { - return value.map((item) => sanitizeLogValue(item, key, depth + 1)) - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([currentKey, currentValue]) => [ - currentKey, - sanitizeLogValue(currentValue, currentKey, depth + 1), - ]), - ) - } - - return sanitizeLogString(String(value)) -} - -function sanitizeLogString(value: unknown): string { - return String(value || '') - .replace( - /(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) - .replace( - SENSITIVE_URL_PARAM_PATTERN, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) - .replace( - SENSITIVE_JSON_FIELD_PATTERN, - (_matched, prefix, secret, suffix) => `${prefix}${maskSecret(secret)}${suffix}`, - ) - .replace( - /\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) -} - -function isSensitiveLogKey(key: unknown): boolean { - return SENSITIVE_KEY_PATTERN.test(String(key || '').trim()) -} - -function normalizeExternalHttpPacketDetail( - detail: ExternalHttpPacketDetail, -): ExternalHttpPacketDetail { - const source = asJsonObject(detail) - const normalized: ExternalHttpPacketDetail = {} - - for (const [key, value] of Object.entries(source)) { - normalized[key] = normalizeExternalPacketValue(value) - } - - return normalized -} - -function normalizeExternalPacketValue(value: unknown): unknown { - if (typeof value === 'undefined' || value === null) { - return value - } - - if (value instanceof Error) { - const currentError = value as Error & HttpErrorLike - return { - name: value.name, - message: value.message, - stack: value.stack, - statusCode: currentError.statusCode, - errorCode: currentError.errorCode, - context: normalizeExternalPacketValue(currentError.context), - } - } - - if (typeof value === 'string') { - return value - } - - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { - return value - } - - if (Array.isArray(value)) { - return value.map((item) => normalizeExternalPacketValue(item)) - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, currentValue]) => [ - key, - normalizeExternalPacketValue(currentValue), - ]), - ) - } - - return String(value) -} - -function splitDetailPayload(detail: unknown): { inline: InlineFields; block: unknown | null } { - if (typeof detail === 'undefined') { - return { - inline: {}, - block: null, - } - } - - if (isScalarLogValue(detail)) { - return { - inline: { detail }, - block: null, - } - } - - if (Array.isArray(detail)) { - return { - inline: {}, - block: detail, - } - } - - if (detail && typeof detail === 'object') { - const inline: InlineFields = {} - const block: Record = {} - - for (const [key, value] of Object.entries(detail)) { - if (isScalarLogValue(value)) { - inline[key] = value - continue - } - - block[key] = value - } - - return { - inline, - block: Object.keys(block).length > 0 ? block : null, - } - } - - return { - inline: { detail: String(detail) }, - block: null, - } -} - -function formatInlineFields(fields: InlineFields): string { - return Object.entries(fields) - .filter(([, value]) => typeof value !== 'undefined' && value !== '') - .map(([key, value]) => `${key}=${formatInlineValue(value)}`) - .join(' ') -} - -function formatInlineValue(value: unknown): string { - if (value === null) { - return 'null' - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - - const text = String(value || '') - return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text) -} - -function isScalarLogValue(value: unknown): value is string | number | boolean | null { - return value === null || ['string', 'number', 'boolean'].includes(typeof value) -} - -function indentBlock(text: unknown, indent: string): string { - return String(text || '') - .split('\n') - .map((line) => `${indent}${line}`) - .join('\n') -} - -function supportsAnsiColor(): boolean { - return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY) -} - -function resolveLevelColor(level: unknown): string { - switch (normalizeLogLevel(level)) { - case 'debug': - return ANSI.blue - case 'warn': - return ANSI.yellow - case 'error': - return ANSI.red - default: - return ANSI.cyan - } -} - -function colorize(text: string, ansiCode: string, enabled: boolean): string { - if (!enabled || !ansiCode) { - return text - } - - return `${ansiCode}${text}${ANSI.reset}` -} - -function formatLocalDate(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` -} - -function formatLocalTime(date: Date): string { - return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` -} - -function formatTimezoneOffset(date: Date): string { - const totalMinutes = -date.getTimezoneOffset() - const sign = totalMinutes >= 0 ? '+' : '-' - const absoluteMinutes = Math.abs(totalMinutes) - const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0') - const minutes = String(absoluteMinutes % 60).padStart(2, '0') - return `${sign}${hours}:${minutes}` -} - -async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { - if (!dateKey || dateKey === lastCleanupDateKey) { - return - } - - lastCleanupDateKey = dateKey - - try { - const fileNames = await fs.readdir(LOG_DIR) - const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS) - await Promise.all( - expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true })), - ) - } catch (error) { - const reason = error instanceof Error ? error.message : String(error || '未知错误') - console.error('[logger] failed to cleanup old log files:', reason) - } -} - -function extractLogDateKey(time: unknown): string { - return toDateKey(time) -} - -function toDateKey(value: unknown): string { - const date = parseLogDate(value) - - if (Number.isNaN(date.getTime())) { - return formatLocalDate(new Date()) - } - - return formatLocalDate(date) -} - -function offsetDate(value: unknown, offsetDays: unknown): Date { - const date = parseLogDate(value) - if (Number.isNaN(date.getTime())) { - return new Date() - } - - date.setDate(date.getDate() + Number(offsetDays || 0)) - return date -} - -function parseLogDate(value: unknown): Date { - if (value instanceof Date) { - return new Date(value.getTime()) - } - - const text = String(value || '').trim() - const matchedDateKey = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) - if (matchedDateKey) { - return new Date( - Number(matchedDateKey[1]), - Number(matchedDateKey[2]) - 1, - Number(matchedDateKey[3]), - ) - } - - return new Date(text) -} - -function normalizeLogRetentionDays(value: unknown): number { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return DEFAULT_LOG_RETENTION_DAYS - } - - return Math.max(1, Math.floor(parsed)) -} - -function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean { - if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { - return true - } - - const matched = /^([a-z0-9-]{1,40})-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim()) - - if (!matched) { - return false - } - - return String(matched[2] || '') < cutoffDateKey -} - -function isLogLevel(value: string): value is LogLevel { - return ['debug', 'info', 'warn', 'error'].includes(value) -} diff --git a/log-optimization-artifacts/DIFF_FILE.patch b/log-optimization-artifacts/DIFF_FILE.patch deleted file mode 100644 index 03e67789..00000000 --- a/log-optimization-artifacts/DIFF_FILE.patch +++ /dev/null @@ -1,14 +0,0 @@ -Log volume optimization diff summary - -1. apps/backend/src/middleware/access-log.ts - - Successful GET/HEAD responses (2xx/3xx) now use DEBUG. - - Writes and all 4xx/5xx responses remain INFO. - -2. apps/backend/src/utils/logger.ts - - Appends rotate at LOG_MAX_FILE_SIZE_MB (default 20MB). - - Keeps LOG_MAX_FILES (default 5) files per channel/date. - - Rotated files are included in retention cleanup. - -3. Configuration/docs/tests - - Added LOG_MAX_FILE_SIZE_MB and LOG_MAX_FILES to env overrides, - Compose files, examples, runtime types, backend README, and tests. diff --git a/log-optimization-artifacts/MODIFIED_FILE.ts b/log-optimization-artifacts/MODIFIED_FILE.ts deleted file mode 100644 index bd7e4b23..00000000 --- a/log-optimization-artifacts/MODIFIED_FILE.ts +++ /dev/null @@ -1,757 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import process from 'node:process' -import util from 'node:util' - -import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js' -import type { HttpErrorLike } from './http.js' -import { maskSecret } from './masking.js' -import { asJsonObject } from '../types/json.js' - -type LogLevel = 'debug' | 'info' | 'warn' | 'error' -type LogChannel = 'app' | 'integration' -type LogDetail = unknown -type InlineFields = Record -type ExternalHttpPacketDetail = Record -type LogEntry = { - time: string - level: LogLevel - scope: string - message: string - pid: number - detail?: unknown -} - -const LOG_LEVEL_PRIORITY: Record = { - debug: 10, - info: 20, - warn: 30, - error: 40, -} - -const LEVEL_LABELS: Record = { - debug: 'DEBUG', - info: 'INFO ', - warn: 'WARN ', - error: 'ERROR', -} - -const ANSI = { - reset: '\u001B[0m', - dim: '\u001B[2m', - bold: '\u001B[1m', - gray: '\u001B[90m', - cyan: '\u001B[36m', - blue: '\u001B[34m', - yellow: '\u001B[33m', - red: '\u001B[31m', -} - -const DATA_ROOT = resolveDataRoot() -const LOG_DIR = path.join(DATA_ROOT, 'logs') -const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) -/** integration 通道独立阈值,默认 warn(仅错误/告警落盘) */ -const ACTIVE_INTEGRATION_LOG_LEVEL = normalizeLogLevel( - runtimeConfig.logging?.integrationLevel ?? 'warn', -) -const DEFAULT_LOG_RETENTION_DAYS = 30 -const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays) -const DEFAULT_LOG_MAX_FILE_SIZE_MB = 20 -const DEFAULT_LOG_MAX_FILES = 5 -const LOG_MAX_FILE_SIZE_BYTES = - normalizeLogMaxFileSizeMb(runtimeConfig.logging?.maxFileSizeMb) * 1024 * 1024 -const LOG_MAX_FILES = normalizeLogMaxFiles(runtimeConfig.logging?.maxFiles) -const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log']) -const SENSITIVE_KEY_PATTERN = - /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session|x-sign|(?:^|[_-])sign(?:ature)?(?:[_-]|$)/i -const SENSITIVE_URL_PARAM_PATTERN = - /([?&](?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key|sign|signature|code)=)([^&\s,;"']+)/gi -const SENSITIVE_JSON_FIELD_PATTERN = - /("(?:token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardNo|cardPwd|apiKey|api_key|deviceKey|device_key|session|x-sign|sign|signature)"\s*:\s*")([^"]*)(")/gi -const MAX_SANITIZE_DEPTH = 8 - -let writeQueue = Promise.resolve() -let lastCleanupDateKey = '' - -export function createRequestId(prefix = 'req'): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` -} - -export function logDebug(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('debug', scope, message, detail) -} - -export function logInfo(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('info', scope, message, detail) -} - -export function logWarn(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('warn', scope, message, detail) -} - -export function logError(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('error', scope, message, detail) -} - -export function logIntegration( - scope: unknown, - message: unknown, - detail: LogDetail = undefined, - { level = 'info' }: { level?: LogLevel } = {}, -) { - // 默认 integration 阈值为 warn:失败类文案即使调用方传 info 也提升,避免漏记异常 - return writeLog(resolveIntegrationLogLevel(level, message), scope, message, detail, { - channel: 'integration', - }) -} - -function resolveIntegrationLogLevel(level: LogLevel, message: unknown): LogLevel { - const normalized = normalizeLogLevel(level) - if (normalized === 'warn' || normalized === 'error') { - return normalized - } - - const text = String(message || '') - if (/失败|异常|错误|超时|拒绝|expired|fail|error|timeout|denied/i.test(text)) { - return 'error' - } - - return normalized -} - -export function logExternalHttpPacket( - scope: unknown, - message: unknown, - detail: ExternalHttpPacketDetail = {}, - { level = 'info' }: { level?: LogLevel } = {}, -) { - return logIntegration(scope, message, normalizeExternalHttpPacketDetail(detail), { level }) -} - -export function logCapture( - scope: unknown, - message: unknown, - detail: LogDetail = undefined, - { channel = 'capture', sanitize = false }: { channel?: string; sanitize?: boolean } = {}, -): LogEntry | null { - const entry = { - time: new Date().toISOString(), - level: 'info' as LogLevel, - scope: String(scope || 'capture').trim() || 'capture', - message: String(message || '').trim() || '-', - pid: process.pid, - detail: normalizeLogValue(detail, sanitize !== false), - } - - enqueueFileWrite(entry, resolveLogFilePath(channel, entry.time), sanitize !== false, true) - return entry -} - -function writeLog( - level: LogLevel, - scope: unknown, - message: unknown, - detail: LogDetail, - { channel = 'app' }: { channel?: LogChannel } = {}, -): LogEntry | null { - const configuredLevel = - channel === 'integration' ? ACTIVE_INTEGRATION_LOG_LEVEL : ACTIVE_LOG_LEVEL - if (!shouldWriteLog(level, configuredLevel)) { - return null - } - - const entry = { - time: new Date().toISOString(), - level: normalizeLogLevel(level), - scope: String(scope || 'app').trim() || 'app', - message: String(message || '').trim() || '-', - pid: process.pid, - detail: normalizeLogValue(detail), - } - - writeConsole(entry) - enqueueFileWrite(entry, resolveLogFilePath(channel, entry.time), true) - return entry -} - -function enqueueFileWrite( - entry: LogEntry, - filePath: string, - sanitize: boolean, - jsonLine = false, -): void { - const line = jsonLine ? JSON.stringify(entry) : formatLogEntry(entry, { color: false, sanitize }) - const dateKey = extractLogDateKey(entry.time) - - writeQueue = writeQueue - .then(async () => { - await fs.mkdir(LOG_DIR, { recursive: true }) - await rotateLogFileIfNeeded(filePath, Buffer.byteLength(`${line}\n`, 'utf8')) - await fs.appendFile(filePath, `${line}\n`, 'utf8') - await cleanupExpiredLogsIfNeeded(dateKey) - }) - .catch((error) => { - const reason = error instanceof Error ? error.message : String(error || '未知错误') - console.error('[logger] failed to write log file:', reason) - }) -} - -function writeConsole(entry: LogEntry): void { - const logger = resolveConsoleMethod(entry.level) - logger(formatLogEntry(entry, { color: supportsAnsiColor() })) -} - -function resolveConsoleMethod(level: unknown) { - const normalized = normalizeLogLevel(level) - - if (normalized === 'error') { - return console.error - } - - if (normalized === 'warn') { - return console.warn - } - - return console.log -} - -export function normalizeLogLevel(level: unknown): LogLevel { - const normalized = String(level || '') - .trim() - .toLowerCase() - return isLogLevel(normalized) ? normalized : 'info' -} - -export function shouldWriteLog( - level: unknown, - configuredLevel: unknown = ACTIVE_LOG_LEVEL, -): boolean { - const normalizedLevel = normalizeLogLevel(level) - const normalizedConfigured = normalizeLogLevel(configuredLevel) - - return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] -} - -export function formatLogEntry( - entry: Partial, - { color = false, sanitize = true }: { color?: boolean; sanitize?: boolean } = {}, -): string { - const normalizedEntry = { - time: String(entry?.time || new Date().toISOString()), - level: normalizeLogLevel(entry?.level), - scope: String(entry?.scope || 'app').trim() || 'app', - message: String(entry?.message || '').trim() || '-', - pid: Number(entry?.pid || process.pid), - detail: sanitize !== false ? sanitizeLogValue(entry?.detail) : entry?.detail, - } - const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color) - const level = colorize( - LEVEL_LABELS[normalizedEntry.level], - resolveLevelColor(normalizedEntry.level), - color, - ) - const scope = colorize(normalizedEntry.scope, ANSI.cyan, color) - const message = colorize(normalizedEntry.message, ANSI.bold, color) - const separator = colorize('|', ANSI.dim, color) - const { inline, block } = splitDetailPayload(normalizedEntry.detail) - const inlineFields = formatInlineFields({ - pid: normalizedEntry.pid, - ...inline, - }) - const firstLine = [timestamp, level, scope, message, separator, inlineFields] - .filter(Boolean) - .join(' ') - - if (!block) { - return firstLine - } - - const inspected = util.inspect(block, { - depth: 8, - colors: color, - compact: false, - breakLength: 120, - maxArrayLength: 100, - }) - - return `${firstLine}\n${indentBlock(inspected, ' ')}` -} - -export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()): string { - const normalizedChannel = normalizeLogChannelName(channel) - return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`) -} - -function normalizeLogChannelName(value: unknown): string { - const normalized = String(value || '') - .trim() - .toLowerCase() - if (!normalized) { - return 'app' - } - if (normalized === 'integration') { - return 'integration' - } - return /^[a-z0-9-]{1,40}$/.test(normalized) ? normalized : 'app' -} - -export function resolveExpiredLogFilenames( - fileNames: string[] = [], - referenceTime = new Date().toISOString(), - retentionDays = LOG_RETENTION_DAYS, -): string[] { - const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays) - const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) - - return (Array.isArray(fileNames) ? fileNames : []).filter((fileName) => - shouldDeleteLogFileByDate(fileName, cutoffDateKey), - ) -} - -export function formatLogTimestamp(value: unknown): string { - const date = new Date(value instanceof Date ? value : String(value || '')) - - if (Number.isNaN(date.getTime())) { - return String(value || '').trim() || new Date().toISOString() - } - - return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}` -} - -function resolveDataRoot(): string { - const configured = String(runtimeConfig.data?.root || '').trim() - return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data') -} - -function normalizeLogValue(value: unknown, sanitize = true): unknown { - if (typeof value === 'undefined') { - return undefined - } - - const normalizeCurrent = (current: unknown): unknown => { - if (current instanceof Error) { - const currentError = current as Error & HttpErrorLike - return { - name: currentError.name, - message: sanitize ? sanitizeLogString(currentError.message) : currentError.message, - stack: sanitize ? sanitizeLogString(currentError.stack) : currentError.stack, - statusCode: currentError.statusCode, - errorCode: currentError.errorCode, - context: sanitize ? sanitizeLogValue(currentError.context) : currentError.context, - } - } - - if (typeof current === 'bigint') { - return String(current) - } - - return current - } - - try { - return sanitize - ? sanitizeLogValue( - JSON.parse(JSON.stringify(value, (_key, current) => normalizeCurrent(current))), - ) - : JSON.parse(JSON.stringify(value, (_key, current) => normalizeCurrent(current))) - } catch { - return sanitize ? sanitizeLogString(String(value)) : String(value) - } -} - -function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { - if (typeof value === 'undefined') { - return undefined - } - - if (value === null) { - return null - } - - if (isSensitiveLogKey(key)) { - return maskSecret(value) - } - - if (typeof value === 'string') { - return sanitizeLogString(value) - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return value - } - - if (typeof value === 'bigint') { - return String(value) - } - - if (depth >= MAX_SANITIZE_DEPTH) { - return '[MaxDepth]' - } - - if (Array.isArray(value)) { - return value.map((item) => sanitizeLogValue(item, key, depth + 1)) - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([currentKey, currentValue]) => [ - currentKey, - sanitizeLogValue(currentValue, currentKey, depth + 1), - ]), - ) - } - - return sanitizeLogString(String(value)) -} - -function sanitizeLogString(value: unknown): string { - return String(value || '') - .replace( - /(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) - .replace( - SENSITIVE_URL_PARAM_PATTERN, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) - .replace( - SENSITIVE_JSON_FIELD_PATTERN, - (_matched, prefix, secret, suffix) => `${prefix}${maskSecret(secret)}${suffix}`, - ) - .replace( - /\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) -} - -function isSensitiveLogKey(key: unknown): boolean { - return SENSITIVE_KEY_PATTERN.test(String(key || '').trim()) -} - -function normalizeExternalHttpPacketDetail( - detail: ExternalHttpPacketDetail, -): ExternalHttpPacketDetail { - const source = asJsonObject(detail) - const normalized: ExternalHttpPacketDetail = {} - - for (const [key, value] of Object.entries(source)) { - normalized[key] = normalizeExternalPacketValue(value) - } - - return normalized -} - -function normalizeExternalPacketValue(value: unknown): unknown { - if (typeof value === 'undefined' || value === null) { - return value - } - - if (value instanceof Error) { - const currentError = value as Error & HttpErrorLike - return { - name: value.name, - message: value.message, - stack: value.stack, - statusCode: currentError.statusCode, - errorCode: currentError.errorCode, - context: normalizeExternalPacketValue(currentError.context), - } - } - - if (typeof value === 'string') { - return value - } - - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { - return value - } - - if (Array.isArray(value)) { - return value.map((item) => normalizeExternalPacketValue(item)) - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, currentValue]) => [ - key, - normalizeExternalPacketValue(currentValue), - ]), - ) - } - - return String(value) -} - -function splitDetailPayload(detail: unknown): { inline: InlineFields; block: unknown | null } { - if (typeof detail === 'undefined') { - return { - inline: {}, - block: null, - } - } - - if (isScalarLogValue(detail)) { - return { - inline: { detail }, - block: null, - } - } - - if (Array.isArray(detail)) { - return { - inline: {}, - block: detail, - } - } - - if (detail && typeof detail === 'object') { - const inline: InlineFields = {} - const block: Record = {} - - for (const [key, value] of Object.entries(detail)) { - if (isScalarLogValue(value)) { - inline[key] = value - continue - } - - block[key] = value - } - - return { - inline, - block: Object.keys(block).length > 0 ? block : null, - } - } - - return { - inline: { detail: String(detail) }, - block: null, - } -} - -function formatInlineFields(fields: InlineFields): string { - return Object.entries(fields) - .filter(([, value]) => typeof value !== 'undefined' && value !== '') - .map(([key, value]) => `${key}=${formatInlineValue(value)}`) - .join(' ') -} - -function formatInlineValue(value: unknown): string { - if (value === null) { - return 'null' - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - - const text = String(value || '') - return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text) -} - -function isScalarLogValue(value: unknown): value is string | number | boolean | null { - return value === null || ['string', 'number', 'boolean'].includes(typeof value) -} - -function indentBlock(text: unknown, indent: string): string { - return String(text || '') - .split('\n') - .map((line) => `${indent}${line}`) - .join('\n') -} - -function supportsAnsiColor(): boolean { - return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY) -} - -function resolveLevelColor(level: unknown): string { - switch (normalizeLogLevel(level)) { - case 'debug': - return ANSI.blue - case 'warn': - return ANSI.yellow - case 'error': - return ANSI.red - default: - return ANSI.cyan - } -} - -function colorize(text: string, ansiCode: string, enabled: boolean): string { - if (!enabled || !ansiCode) { - return text - } - - return `${ansiCode}${text}${ANSI.reset}` -} - -function formatLocalDate(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` -} - -function formatLocalTime(date: Date): string { - return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` -} - -function formatTimezoneOffset(date: Date): string { - const totalMinutes = -date.getTimezoneOffset() - const sign = totalMinutes >= 0 ? '+' : '-' - const absoluteMinutes = Math.abs(totalMinutes) - const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0') - const minutes = String(absoluteMinutes % 60).padStart(2, '0') - return `${sign}${hours}:${minutes}` -} - -async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { - if (!dateKey || dateKey === lastCleanupDateKey) { - return - } - - lastCleanupDateKey = dateKey - - try { - const fileNames = await fs.readdir(LOG_DIR) - const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS) - await Promise.all( - expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true })), - ) - } catch (error) { - const reason = error instanceof Error ? error.message : String(error || '未知错误') - console.error('[logger] failed to cleanup old log files:', reason) - } -} - -function extractLogDateKey(time: unknown): string { - return toDateKey(time) -} - -function toDateKey(value: unknown): string { - const date = parseLogDate(value) - - if (Number.isNaN(date.getTime())) { - return formatLocalDate(new Date()) - } - - return formatLocalDate(date) -} - -function offsetDate(value: unknown, offsetDays: unknown): Date { - const date = parseLogDate(value) - if (Number.isNaN(date.getTime())) { - return new Date() - } - - date.setDate(date.getDate() + Number(offsetDays || 0)) - return date -} - -function parseLogDate(value: unknown): Date { - if (value instanceof Date) { - return new Date(value.getTime()) - } - - const text = String(value || '').trim() - const matchedDateKey = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) - if (matchedDateKey) { - return new Date( - Number(matchedDateKey[1]), - Number(matchedDateKey[2]) - 1, - Number(matchedDateKey[3]), - ) - } - - return new Date(text) -} - -function normalizeLogRetentionDays(value: unknown): number { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return DEFAULT_LOG_RETENTION_DAYS - } - - return Math.max(1, Math.floor(parsed)) -} - -function normalizeLogMaxFileSizeMb(value: unknown): number { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return DEFAULT_LOG_MAX_FILE_SIZE_MB - } - - return Math.max(1, Math.floor(parsed)) -} - -function normalizeLogMaxFiles(value: unknown): number { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return DEFAULT_LOG_MAX_FILES - } - - return Math.max(2, Math.floor(parsed)) -} - -async function rotateLogFileIfNeeded(filePath: string, nextLineBytes: number): Promise { - if (!filePath.endsWith('.log') || LOG_MAX_FILE_SIZE_BYTES <= 0) { - return - } - - let currentSize = 0 - try { - currentSize = (await fs.stat(filePath)).size - } catch (error) { - if (isFileMissingError(error)) { - return - } - throw error - } - - if (currentSize === 0 || currentSize + nextLineBytes <= LOG_MAX_FILE_SIZE_BYTES) { - return - } - - for (let index = LOG_MAX_FILES - 1; index >= 1; index -= 1) { - await renameIfPresent( - rotatedLogFilePath(filePath, index), - rotatedLogFilePath(filePath, index + 1), - ) - } - await renameIfPresent(filePath, rotatedLogFilePath(filePath, 1)) -} - -function rotatedLogFilePath(filePath: string, index: number): string { - return filePath.replace(/\.log$/, `.${index}.log`) -} - -async function renameIfPresent(source: string, target: string): Promise { - try { - await fs.rename(source, target) - } catch (error) { - if (!isFileMissingError(error)) { - throw error - } - } -} - -function isFileMissingError(error: unknown): boolean { - return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') -} - -function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean { - if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { - return true - } - - const matched = /^([a-z0-9-]{1,40})-(\d{4}-\d{2}-\d{2})(?:\.\d+)?\.log$/.exec( - String(fileName || '').trim(), - ) - - if (!matched) { - return false - } - - return String(matched[2] || '') < cutoffDateKey -} - -function isLogLevel(value: string): value is LogLevel { - return ['debug', 'info', 'warn', 'error'].includes(value) -} diff --git a/log-optimization-artifacts/ROLLBACK.sh b/log-optimization-artifacts/ROLLBACK.sh deleted file mode 100755 index 7f3b7f1d..00000000 --- a/log-optimization-artifacts/ROLLBACK.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="${1:?usage: ROLLBACK.sh }" -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" - -cp "$SCRIPT_DIR/BASELINE_FILE.ts" "$ROOT/apps/backend/src/utils/logger.ts" -printf 'restored logger baseline in %s\n' "$ROOT" diff --git a/log-optimization-artifacts/VERIFICATION.txt b/log-optimization-artifacts/VERIFICATION.txt deleted file mode 100644 index c56ff3a3..00000000 --- a/log-optimization-artifacts/VERIFICATION.txt +++ /dev/null @@ -1,44 +0,0 @@ -Log volume optimization verification - -Changed branch/fields: -- access log level: successful GET/HEAD 2xx/3xx -> DEBUG; writes and 4xx/5xx -> INFO -- logging.maxFileSizeMb / LOG_MAX_FILE_SIZE_MB: default 20 -- logging.maxFiles / LOG_MAX_FILES: default 5 -- rotated files use app-YYYY-MM-DD.N.log and participate in retention cleanup - -Source evidence: -- Input log: /Users/yml/Downloads/logs/app-2026-08-29.log -- Baseline: 2,080,480 lines, 767,799,564 bytes (732MB) -- Access logs: 1,412,784 lines; successful access logs: 1,406,234 lines / 727,296,842 bytes -- Largest paths: /api/v1/files/object 555,995; /api/v1/worker/hall/orders 446,763 -- Applying the new GET/HEAD success filter to this sample would skip 1,354,940 lines / 702,650,714 bytes, a simulated 91.51% reduction. - -Artifacts: -- MODIFIED_FILE: /Users/yml/codes/order_site/log-optimization-artifacts/MODIFIED_FILE.ts -- DIFF_FILE: /Users/yml/codes/order_site/log-optimization-artifacts/DIFF_FILE.patch -- VERIFICATION.txt: /Users/yml/codes/order_site/log-optimization-artifacts/VERIFICATION.txt -- ROLLBACK.sh: /Users/yml/codes/order_site/log-optimization-artifacts/ROLLBACK.sh - -BASELINE -Command: git show HEAD:apps/backend/src/utils/logger.ts > log-optimization-artifacts/BASELINE_FILE.ts -Input: HEAD logger source before this change -Literal result: SHA-256 b197508d56c00362001ea58f4cdcff465d396c3d1a1127f53328dd791a8bdb57; exit status 0 - -MODIFIED -Command: node --import tsx --test src/middleware/access-log.test.ts src/utils/logger.test.ts src/config/env-overrides.test.ts src/config/runtime-validation.test.ts -Input: modified backend logger/access/config sources -Literal result: tests 28; pass 28; fail 0; skipped 0; exit status 0 - -Command: npm run lint:check && npm run build -Input: modified backend source tree -Literal result: ESLint passed; TypeScript build completed; exit status 0 - -Command: npm run check -Input: modified backend source tree -Literal result: format, SQL guard, lint, typecheck passed; tests 377; pass 374; fail 1; skipped 2; existing failure at src/repositories/worker-platform-repo.test.ts:115 (unrelated blank-search SQL assertion); exit status 1 - -ROLLBACK -Command: log-optimization-artifacts/ROLLBACK.sh log-optimization-artifacts/rollback-copy -Input: independent copy containing MODIFIED_FILE.ts -Literal result: restored logger baseline in log-optimization-artifacts/rollback-copy; restored SHA-256 b197508d56c00362001ea58f4cdcff465d396c3d1a1127f53328dd791a8bdb57; exit status 0 -Restored behavior/status: rollback-copy logger matches baseline; working source remains modified. diff --git a/log-optimization-artifacts/rollback-copy/apps/backend/src/utils/logger.ts b/log-optimization-artifacts/rollback-copy/apps/backend/src/utils/logger.ts deleted file mode 100644 index efeb1f97..00000000 --- a/log-optimization-artifacts/rollback-copy/apps/backend/src/utils/logger.ts +++ /dev/null @@ -1,685 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import process from 'node:process' -import util from 'node:util' - -import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js' -import type { HttpErrorLike } from './http.js' -import { maskSecret } from './masking.js' -import { asJsonObject } from '../types/json.js' - -type LogLevel = 'debug' | 'info' | 'warn' | 'error' -type LogChannel = 'app' | 'integration' -type LogDetail = unknown -type InlineFields = Record -type ExternalHttpPacketDetail = Record -type LogEntry = { - time: string - level: LogLevel - scope: string - message: string - pid: number - detail?: unknown -} - -const LOG_LEVEL_PRIORITY: Record = { - debug: 10, - info: 20, - warn: 30, - error: 40, -} - -const LEVEL_LABELS: Record = { - debug: 'DEBUG', - info: 'INFO ', - warn: 'WARN ', - error: 'ERROR', -} - -const ANSI = { - reset: '\u001B[0m', - dim: '\u001B[2m', - bold: '\u001B[1m', - gray: '\u001B[90m', - cyan: '\u001B[36m', - blue: '\u001B[34m', - yellow: '\u001B[33m', - red: '\u001B[31m', -} - -const DATA_ROOT = resolveDataRoot() -const LOG_DIR = path.join(DATA_ROOT, 'logs') -const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) -/** integration 通道独立阈值,默认 warn(仅错误/告警落盘) */ -const ACTIVE_INTEGRATION_LOG_LEVEL = normalizeLogLevel( - runtimeConfig.logging?.integrationLevel ?? 'warn', -) -const DEFAULT_LOG_RETENTION_DAYS = 30 -const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays) -const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log']) -const SENSITIVE_KEY_PATTERN = - /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session|x-sign|(?:^|[_-])sign(?:ature)?(?:[_-]|$)/i -const SENSITIVE_URL_PARAM_PATTERN = - /([?&](?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key|sign|signature|code)=)([^&\s,;"']+)/gi -const SENSITIVE_JSON_FIELD_PATTERN = - /("(?:token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardNo|cardPwd|apiKey|api_key|deviceKey|device_key|session|x-sign|sign|signature)"\s*:\s*")([^"]*)(")/gi -const MAX_SANITIZE_DEPTH = 8 - -let writeQueue = Promise.resolve() -let lastCleanupDateKey = '' - -export function createRequestId(prefix = 'req'): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` -} - -export function logDebug(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('debug', scope, message, detail) -} - -export function logInfo(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('info', scope, message, detail) -} - -export function logWarn(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('warn', scope, message, detail) -} - -export function logError(scope: unknown, message: unknown, detail: LogDetail = undefined) { - return writeLog('error', scope, message, detail) -} - -export function logIntegration( - scope: unknown, - message: unknown, - detail: LogDetail = undefined, - { level = 'info' }: { level?: LogLevel } = {}, -) { - // 默认 integration 阈值为 warn:失败类文案即使调用方传 info 也提升,避免漏记异常 - return writeLog(resolveIntegrationLogLevel(level, message), scope, message, detail, { - channel: 'integration', - }) -} - -function resolveIntegrationLogLevel(level: LogLevel, message: unknown): LogLevel { - const normalized = normalizeLogLevel(level) - if (normalized === 'warn' || normalized === 'error') { - return normalized - } - - const text = String(message || '') - if (/失败|异常|错误|超时|拒绝|expired|fail|error|timeout|denied/i.test(text)) { - return 'error' - } - - return normalized -} - -export function logExternalHttpPacket( - scope: unknown, - message: unknown, - detail: ExternalHttpPacketDetail = {}, - { level = 'info' }: { level?: LogLevel } = {}, -) { - return logIntegration(scope, message, normalizeExternalHttpPacketDetail(detail), { level }) -} - -export function logCapture( - scope: unknown, - message: unknown, - detail: LogDetail = undefined, - { channel = 'capture', sanitize = false }: { channel?: string; sanitize?: boolean } = {}, -): LogEntry | null { - const entry = { - time: new Date().toISOString(), - level: 'info' as LogLevel, - scope: String(scope || 'capture').trim() || 'capture', - message: String(message || '').trim() || '-', - pid: process.pid, - detail: normalizeLogValue(detail, sanitize !== false), - } - - enqueueFileWrite(entry, resolveLogFilePath(channel, entry.time), sanitize !== false, true) - return entry -} - -function writeLog( - level: LogLevel, - scope: unknown, - message: unknown, - detail: LogDetail, - { channel = 'app' }: { channel?: LogChannel } = {}, -): LogEntry | null { - const configuredLevel = - channel === 'integration' ? ACTIVE_INTEGRATION_LOG_LEVEL : ACTIVE_LOG_LEVEL - if (!shouldWriteLog(level, configuredLevel)) { - return null - } - - const entry = { - time: new Date().toISOString(), - level: normalizeLogLevel(level), - scope: String(scope || 'app').trim() || 'app', - message: String(message || '').trim() || '-', - pid: process.pid, - detail: normalizeLogValue(detail), - } - - writeConsole(entry) - enqueueFileWrite(entry, resolveLogFilePath(channel, entry.time), true) - return entry -} - -function enqueueFileWrite( - entry: LogEntry, - filePath: string, - sanitize: boolean, - jsonLine = false, -): void { - const line = jsonLine ? JSON.stringify(entry) : formatLogEntry(entry, { color: false, sanitize }) - const dateKey = extractLogDateKey(entry.time) - - writeQueue = writeQueue - .then(async () => { - await fs.mkdir(LOG_DIR, { recursive: true }) - await fs.appendFile(filePath, `${line}\n`, 'utf8') - await cleanupExpiredLogsIfNeeded(dateKey) - }) - .catch((error) => { - const reason = error instanceof Error ? error.message : String(error || '未知错误') - console.error('[logger] failed to write log file:', reason) - }) -} - -function writeConsole(entry: LogEntry): void { - const logger = resolveConsoleMethod(entry.level) - logger(formatLogEntry(entry, { color: supportsAnsiColor() })) -} - -function resolveConsoleMethod(level: unknown) { - const normalized = normalizeLogLevel(level) - - if (normalized === 'error') { - return console.error - } - - if (normalized === 'warn') { - return console.warn - } - - return console.log -} - -export function normalizeLogLevel(level: unknown): LogLevel { - const normalized = String(level || '') - .trim() - .toLowerCase() - return isLogLevel(normalized) ? normalized : 'info' -} - -export function shouldWriteLog( - level: unknown, - configuredLevel: unknown = ACTIVE_LOG_LEVEL, -): boolean { - const normalizedLevel = normalizeLogLevel(level) - const normalizedConfigured = normalizeLogLevel(configuredLevel) - - return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] -} - -export function formatLogEntry( - entry: Partial, - { color = false, sanitize = true }: { color?: boolean; sanitize?: boolean } = {}, -): string { - const normalizedEntry = { - time: String(entry?.time || new Date().toISOString()), - level: normalizeLogLevel(entry?.level), - scope: String(entry?.scope || 'app').trim() || 'app', - message: String(entry?.message || '').trim() || '-', - pid: Number(entry?.pid || process.pid), - detail: sanitize !== false ? sanitizeLogValue(entry?.detail) : entry?.detail, - } - const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color) - const level = colorize( - LEVEL_LABELS[normalizedEntry.level], - resolveLevelColor(normalizedEntry.level), - color, - ) - const scope = colorize(normalizedEntry.scope, ANSI.cyan, color) - const message = colorize(normalizedEntry.message, ANSI.bold, color) - const separator = colorize('|', ANSI.dim, color) - const { inline, block } = splitDetailPayload(normalizedEntry.detail) - const inlineFields = formatInlineFields({ - pid: normalizedEntry.pid, - ...inline, - }) - const firstLine = [timestamp, level, scope, message, separator, inlineFields] - .filter(Boolean) - .join(' ') - - if (!block) { - return firstLine - } - - const inspected = util.inspect(block, { - depth: 8, - colors: color, - compact: false, - breakLength: 120, - maxArrayLength: 100, - }) - - return `${firstLine}\n${indentBlock(inspected, ' ')}` -} - -export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()): string { - const normalizedChannel = normalizeLogChannelName(channel) - return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`) -} - -function normalizeLogChannelName(value: unknown): string { - const normalized = String(value || '') - .trim() - .toLowerCase() - if (!normalized) { - return 'app' - } - if (normalized === 'integration') { - return 'integration' - } - return /^[a-z0-9-]{1,40}$/.test(normalized) ? normalized : 'app' -} - -export function resolveExpiredLogFilenames( - fileNames: string[] = [], - referenceTime = new Date().toISOString(), - retentionDays = LOG_RETENTION_DAYS, -): string[] { - const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays) - const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) - - return (Array.isArray(fileNames) ? fileNames : []).filter((fileName) => - shouldDeleteLogFileByDate(fileName, cutoffDateKey), - ) -} - -export function formatLogTimestamp(value: unknown): string { - const date = new Date(value instanceof Date ? value : String(value || '')) - - if (Number.isNaN(date.getTime())) { - return String(value || '').trim() || new Date().toISOString() - } - - return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}` -} - -function resolveDataRoot(): string { - const configured = String(runtimeConfig.data?.root || '').trim() - return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data') -} - -function normalizeLogValue(value: unknown, sanitize = true): unknown { - if (typeof value === 'undefined') { - return undefined - } - - const normalizeCurrent = (current: unknown): unknown => { - if (current instanceof Error) { - const currentError = current as Error & HttpErrorLike - return { - name: currentError.name, - message: sanitize ? sanitizeLogString(currentError.message) : currentError.message, - stack: sanitize ? sanitizeLogString(currentError.stack) : currentError.stack, - statusCode: currentError.statusCode, - errorCode: currentError.errorCode, - context: sanitize ? sanitizeLogValue(currentError.context) : currentError.context, - } - } - - if (typeof current === 'bigint') { - return String(current) - } - - return current - } - - try { - return sanitize - ? sanitizeLogValue( - JSON.parse(JSON.stringify(value, (_key, current) => normalizeCurrent(current))), - ) - : JSON.parse(JSON.stringify(value, (_key, current) => normalizeCurrent(current))) - } catch { - return sanitize ? sanitizeLogString(String(value)) : String(value) - } -} - -function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { - if (typeof value === 'undefined') { - return undefined - } - - if (value === null) { - return null - } - - if (isSensitiveLogKey(key)) { - return maskSecret(value) - } - - if (typeof value === 'string') { - return sanitizeLogString(value) - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return value - } - - if (typeof value === 'bigint') { - return String(value) - } - - if (depth >= MAX_SANITIZE_DEPTH) { - return '[MaxDepth]' - } - - if (Array.isArray(value)) { - return value.map((item) => sanitizeLogValue(item, key, depth + 1)) - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([currentKey, currentValue]) => [ - currentKey, - sanitizeLogValue(currentValue, currentKey, depth + 1), - ]), - ) - } - - return sanitizeLogString(String(value)) -} - -function sanitizeLogString(value: unknown): string { - return String(value || '') - .replace( - /(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) - .replace( - SENSITIVE_URL_PARAM_PATTERN, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) - .replace( - SENSITIVE_JSON_FIELD_PATTERN, - (_matched, prefix, secret, suffix) => `${prefix}${maskSecret(secret)}${suffix}`, - ) - .replace( - /\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi, - (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, - ) -} - -function isSensitiveLogKey(key: unknown): boolean { - return SENSITIVE_KEY_PATTERN.test(String(key || '').trim()) -} - -function normalizeExternalHttpPacketDetail( - detail: ExternalHttpPacketDetail, -): ExternalHttpPacketDetail { - const source = asJsonObject(detail) - const normalized: ExternalHttpPacketDetail = {} - - for (const [key, value] of Object.entries(source)) { - normalized[key] = normalizeExternalPacketValue(value) - } - - return normalized -} - -function normalizeExternalPacketValue(value: unknown): unknown { - if (typeof value === 'undefined' || value === null) { - return value - } - - if (value instanceof Error) { - const currentError = value as Error & HttpErrorLike - return { - name: value.name, - message: value.message, - stack: value.stack, - statusCode: currentError.statusCode, - errorCode: currentError.errorCode, - context: normalizeExternalPacketValue(currentError.context), - } - } - - if (typeof value === 'string') { - return value - } - - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { - return value - } - - if (Array.isArray(value)) { - return value.map((item) => normalizeExternalPacketValue(item)) - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, currentValue]) => [ - key, - normalizeExternalPacketValue(currentValue), - ]), - ) - } - - return String(value) -} - -function splitDetailPayload(detail: unknown): { inline: InlineFields; block: unknown | null } { - if (typeof detail === 'undefined') { - return { - inline: {}, - block: null, - } - } - - if (isScalarLogValue(detail)) { - return { - inline: { detail }, - block: null, - } - } - - if (Array.isArray(detail)) { - return { - inline: {}, - block: detail, - } - } - - if (detail && typeof detail === 'object') { - const inline: InlineFields = {} - const block: Record = {} - - for (const [key, value] of Object.entries(detail)) { - if (isScalarLogValue(value)) { - inline[key] = value - continue - } - - block[key] = value - } - - return { - inline, - block: Object.keys(block).length > 0 ? block : null, - } - } - - return { - inline: { detail: String(detail) }, - block: null, - } -} - -function formatInlineFields(fields: InlineFields): string { - return Object.entries(fields) - .filter(([, value]) => typeof value !== 'undefined' && value !== '') - .map(([key, value]) => `${key}=${formatInlineValue(value)}`) - .join(' ') -} - -function formatInlineValue(value: unknown): string { - if (value === null) { - return 'null' - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - - const text = String(value || '') - return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text) -} - -function isScalarLogValue(value: unknown): value is string | number | boolean | null { - return value === null || ['string', 'number', 'boolean'].includes(typeof value) -} - -function indentBlock(text: unknown, indent: string): string { - return String(text || '') - .split('\n') - .map((line) => `${indent}${line}`) - .join('\n') -} - -function supportsAnsiColor(): boolean { - return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY) -} - -function resolveLevelColor(level: unknown): string { - switch (normalizeLogLevel(level)) { - case 'debug': - return ANSI.blue - case 'warn': - return ANSI.yellow - case 'error': - return ANSI.red - default: - return ANSI.cyan - } -} - -function colorize(text: string, ansiCode: string, enabled: boolean): string { - if (!enabled || !ansiCode) { - return text - } - - return `${ansiCode}${text}${ANSI.reset}` -} - -function formatLocalDate(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` -} - -function formatLocalTime(date: Date): string { - return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` -} - -function formatTimezoneOffset(date: Date): string { - const totalMinutes = -date.getTimezoneOffset() - const sign = totalMinutes >= 0 ? '+' : '-' - const absoluteMinutes = Math.abs(totalMinutes) - const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0') - const minutes = String(absoluteMinutes % 60).padStart(2, '0') - return `${sign}${hours}:${minutes}` -} - -async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { - if (!dateKey || dateKey === lastCleanupDateKey) { - return - } - - lastCleanupDateKey = dateKey - - try { - const fileNames = await fs.readdir(LOG_DIR) - const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS) - await Promise.all( - expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true })), - ) - } catch (error) { - const reason = error instanceof Error ? error.message : String(error || '未知错误') - console.error('[logger] failed to cleanup old log files:', reason) - } -} - -function extractLogDateKey(time: unknown): string { - return toDateKey(time) -} - -function toDateKey(value: unknown): string { - const date = parseLogDate(value) - - if (Number.isNaN(date.getTime())) { - return formatLocalDate(new Date()) - } - - return formatLocalDate(date) -} - -function offsetDate(value: unknown, offsetDays: unknown): Date { - const date = parseLogDate(value) - if (Number.isNaN(date.getTime())) { - return new Date() - } - - date.setDate(date.getDate() + Number(offsetDays || 0)) - return date -} - -function parseLogDate(value: unknown): Date { - if (value instanceof Date) { - return new Date(value.getTime()) - } - - const text = String(value || '').trim() - const matchedDateKey = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) - if (matchedDateKey) { - return new Date( - Number(matchedDateKey[1]), - Number(matchedDateKey[2]) - 1, - Number(matchedDateKey[3]), - ) - } - - return new Date(text) -} - -function normalizeLogRetentionDays(value: unknown): number { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return DEFAULT_LOG_RETENTION_DAYS - } - - return Math.max(1, Math.floor(parsed)) -} - -function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean { - if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { - return true - } - - const matched = /^([a-z0-9-]{1,40})-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim()) - - if (!matched) { - return false - } - - return String(matched[2] || '') < cutoffDateKey -} - -function isLogLevel(value: string): value is LogLevel { - return ['debug', 'info', 'warn', 'error'].includes(value) -} diff --git a/publish-atomic-artifacts/DIFF_FILE.patch b/publish-atomic-artifacts/DIFF_FILE.patch deleted file mode 100644 index 681f16c7..00000000 --- a/publish-atomic-artifacts/DIFF_FILE.patch +++ /dev/null @@ -1,262 +0,0 @@ -diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts -index 40937f3a..88652ebb 100644 ---- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts -+++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts -@@ -1,6 +1,7 @@ --import { query } from '../../db/client.js' -+import { query, withTransaction } from '../../db/client.js' - import { toJsonString, toPositiveInteger } from './shared.js' --import { getWorkOrderById } from './work-order-query-repo.js' -+import { getWorkOrderById, getWorkOrderByIdWithClient } from './work-order-query-repo.js' -+import { createWorkOrderEventWithClient } from './work-order-event-repo.js' - import { - normalizeWorkOrderAcceptanceMode, - normalizeWorkOrderGiftCooldownHours, -@@ -125,6 +126,64 @@ export async function updateWorkOrder( - return getWorkOrderById(workOrderId) - } - -+/** 将未分配工单原子发布到大厅,并在事务内记录发布事件。 */ -+export async function publishUnassignedWorkOrder(input: { -+ workOrderId: number | string -+ now: string -+ actorName?: string -+}): Promise<{ -+ order: WorkOrderRow | null -+ failureReason: 'work_order_not_publishable' | null -+}> { -+ return withTransaction(async (client) => { -+ const currentResult = await client.query( -+ ` -+ SELECT * -+ FROM work_orders -+ WHERE id = $1 -+ FOR UPDATE -+ `, -+ [Number(input.workOrderId)], -+ ) -+ const current = currentResult.rows[0] || null -+ if (!current || current.status !== 'unassigned' || current.assigned_worker_id) { -+ return { order: null, failureReason: 'work_order_not_publishable' } -+ } -+ -+ const updatedResult = await client.query<{ id: number }>( -+ ` -+ UPDATE work_orders -+ SET status = 'open', published_at = $1, hall_queued_at = $1, updated_at = $1 -+ WHERE id = $2 -+ AND status = 'unassigned' -+ AND assigned_worker_id IS NULL -+ AND reward_amount > 0 -+ RETURNING id -+ `, -+ [input.now, Number(input.workOrderId)], -+ ) -+ if (!updatedResult.rows[0]) { -+ return { order: null, failureReason: 'work_order_not_publishable' } -+ } -+ -+ await createWorkOrderEventWithClient(client, { -+ workOrderId: Number(input.workOrderId), -+ actorType: 'admin', -+ actorId: input.actorName || '', -+ eventType: 'published', -+ fromStatus: current.status, -+ toStatus: 'open', -+ payloadJson: JSON.stringify({ voucherConsumeDeferred: true }), -+ now: input.now, -+ }) -+ -+ return { -+ order: await getWorkOrderByIdWithClient(client, input.workOrderId), -+ failureReason: null, -+ } -+ }) -+} -+ - export async function updateWorkOrderBasic( - workOrderId: number | string, - patch: { -diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts -index bd47017d..1e1b74c4 100644 ---- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts -+++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts -@@ -4,6 +4,7 @@ import { - deleteWorkOrder, - listWorkOrderShares, - listWorkOrders, -+ publishUnassignedWorkOrder, - reopenCancelledWorkOrder, - resolveProblemWorkOrder, - updateWorkOrder, -@@ -15,7 +16,10 @@ import { createHttpError } from '../../utils/http.js' - import { nowIso } from '../../utils/time.js' - import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' - import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' --import { consumeIndustryVouchersBeforeWorkOrderPublish } from './publish-voucher-service.js' -+import { -+ consumeIndustryVouchersBeforeWorkOrderPublish, -+ type WorkOrderVoucherConsumeResult, -+} from './publish-voucher-service.js' - import { - mapWorkOrderAdmin, - normalizeAmountFen, -@@ -228,55 +232,47 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN - }) - } - -- const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder) -+ const now = nowIso() -+ const published = await publishUnassignedWorkOrder({ -+ workOrderId: workOrder.id, -+ now, -+ actorName, -+ }) -+ if (!published.order) { -+ throw createHttpError('订单状态已变化,无法发布,请刷新后重试', { -+ statusCode: 409, -+ errorCode: 'work_order_publish_conflict', -+ }) -+ } -+ -+ let voucherConsume: WorkOrderVoucherConsumeResult -+ try { -+ voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(published.order) -+ } catch (error) { -+ voucherConsume = { -+ ok: false, -+ voucherCount: 0, -+ consumedCount: 0, -+ alreadyConsumedCount: 0, -+ failedCount: 1, -+ voucherCodes: [], -+ errorMessage: error instanceof Error ? error.message : '电子凭证核销失败', -+ } -+ } - if (!voucherConsume.ok) { -- const failedAt = nowIso() - await createWorkOrderEvent({ - workOrderId: workOrder.id, -- actorType: 'admin', -- actorId: actorName, -+ actorType: 'system', -+ actorId: 'kuaishou_send_code', - eventType: 'publish_voucher_consume_failed', -- fromStatus: workOrder.status, -- toStatus: workOrder.status, -+ fromStatus: WORK_ORDER_STATUS.OPEN, -+ toStatus: WORK_ORDER_STATUS.OPEN, - payloadJson: JSON.stringify(voucherConsume), -- now: failedAt, -- }) -- throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', { -- statusCode: 409, -- errorCode: 'work_order_publish_voucher_consume_failed', -- context: voucherConsume, -+ now: nowIso(), - }) - } - -- const now = nowIso() -- const updated = await updateWorkOrder(workOrder.id, { -- status: WORK_ORDER_STATUS.OPEN, -- published_at: now, -- hall_queued_at: now, -- updated_at: now, -- }) -- if (!updated) { -- throw createHttpError( -- voucherConsume.voucherCount > 0 -- ? '电子凭证已核销,但订单发布失败,请重试发布' -- : '订单发布失败,请重试', -- { -- statusCode: 409, -- errorCode: 'work_order_publish_update_failed', -- context: voucherConsume, -- }, -- ) -- } -- await createWorkOrderEvent({ -- workOrderId: workOrder.id, -- actorType: 'admin', -- actorId: actorName, -- eventType: 'published', -- fromStatus: workOrder.status, -- toStatus: WORK_ORDER_STATUS.OPEN, -- payloadJson: JSON.stringify({ voucherConsume }), -- now, -- }) -+ const currentOrder = await getRequiredWorkOrder(workOrder.id) - publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true }) - const hallConfig = getWorkerHallConfig() - const hallEntry = await listWorkOrders({ -@@ -289,7 +285,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN - excludeFilledSharing: true, - }) - return { -- order: mapWorkOrderAdmin(updated), -+ order: mapWorkOrderAdmin(currentOrder), - voucherConsume, - hallVisible: hallEntry.total > 0, - } -diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx -index f3489181..95f9da5c 100644 ---- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx -+++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx -@@ -351,8 +351,9 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { - case 'voucher_backfilled': - return { title: '回填了电子凭证信息', color: 'default' } - case 'assign_voucher_consume_failed': -- case 'publish_voucher_consume_failed': - return { title: '电子凭证核销失败,本次操作未生效', color: 'red' } -+ case 'publish_voucher_consume_failed': -+ return { title: '订单已发布,但电子凭证核销未完成', color: 'orange' } - - default: - // 通用状态流转兜底 -diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx -index d31bd9b9..398c0d63 100644 ---- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx -+++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx -@@ -566,11 +566,13 @@ export default function WorkOrdersPanel() { - return runAction( - () => publishAdminWorkOrder(row.workOrderId), - (response) => -- response.data.hallVisible -- ? response.data.voucherConsume.voucherCount > 0 -- ? '电子凭证已自动核销,订单已展示在大厅' -- : '订单已展示在大厅' -- : '订单已进入大厅等待队列', -+ !response.data.voucherConsume.ok -+ ? '订单已发布,但电子凭证核销未完成' -+ : response.data.hallVisible -+ ? response.data.voucherConsume.voucherCount > 0 -+ ? '订单已发布,电子凭证已顺带核销' -+ : '订单已展示在大厅' -+ : '订单已进入大厅等待队列', - ) - } - -@@ -582,11 +584,11 @@ export default function WorkOrdersPanel() { - - let skipFutureConfirm = false - modal.confirm({ -- title: '确认发布并核销该订单?', -+ title: '确认发布该订单?', - content: ( -
- -- 如订单关联电子凭证,系统会先自动核销,成功后再发布到大厅。核销后即使下架也不会自动恢复凭证。 -+ 如订单关联电子凭证,系统会在发布后顺带尝试核销;核销失败不影响订单发布。 - - { -@@ -597,7 +599,7 @@ export default function WorkOrdersPanel() { - -
- ), -- okText: '发布并核销', -+ okText: '发布订单', - cancelText: '取消', - onOk: () => { - if (skipFutureConfirm) setSkipPublishConfirm() diff --git a/publish-atomic-artifacts/MODIFIED_FILE b/publish-atomic-artifacts/MODIFIED_FILE deleted file mode 100644 index 88652ebb..00000000 --- a/publish-atomic-artifacts/MODIFIED_FILE +++ /dev/null @@ -1,247 +0,0 @@ -import { query, withTransaction } from '../../db/client.js' -import { toJsonString, toPositiveInteger } from './shared.js' -import { getWorkOrderById, getWorkOrderByIdWithClient } from './work-order-query-repo.js' -import { createWorkOrderEventWithClient } from './work-order-event-repo.js' -import { - normalizeWorkOrderAcceptanceMode, - normalizeWorkOrderGiftCooldownHours, - normalizeWorkOrderGiftCooldownMinutes, - WORK_ORDER_ACCEPTANCE_MODE, - WORK_ORDER_GIFT_PHASE, -} from '../../domain/work-order-acceptance-mode.js' -import type { CreateWorkOrderInput, WorkOrderRow } from './types.js' - -export async function createWorkOrder(input: CreateWorkOrderInput): Promise { - const acceptanceMode = normalizeWorkOrderAcceptanceMode(input.acceptanceMode) - const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT - const result = await query<{ id: number }>( - ` - INSERT INTO work_orders ( - work_order_no, product_rule_id, order_id, order_item_id, task_id, platform_order_id, - product_name, category_id, status, reward_amount, required_deposit_amount, - deposit_threshold_amount, sharing_enabled, sharing_total_quantity, - sharing_unit_reward, timeout_minutes, timeout_policy, - acceptance_mode, gift_phase, gift_cooldown_hours, gift_cooldown_minutes, - material_json, requirement_json, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, $17, - $18, $19, $20, $21, - $22::jsonb, $23::jsonb, $24, $25 - ) - RETURNING id - `, - [ - input.workOrderNo, - input.productRuleId || null, - input.orderId || null, - input.orderItemId || null, - input.taskId || null, - input.platformOrderId, - input.productName, - input.categoryId || null, - input.status, - input.rewardAmount, - input.requiredDepositAmount, - input.depositThresholdAmount, - input.sharingEnabled === true, - toPositiveInteger(input.sharingTotalQuantity, 1), - toPositiveInteger(input.sharingUnitReward, 0), - toPositiveInteger(input.timeoutMinutes, 0), - String(input.timeoutPolicy || 'reopen').trim() || 'reopen', - acceptanceMode, - isFriendGift ? WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED : '', - normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours), - normalizeWorkOrderGiftCooldownMinutes(input.giftCooldownMinutes), - input.materialJson, - input.requirementJson, - input.now, - input.now, - ], - ) - return getWorkOrderById(result.rows[0]?.id || 0) -} - -export async function updateWorkOrder( - workOrderId: number | string, - patch: Partial< - Pick< - WorkOrderRow, - | 'status' - | 'assigned_worker_id' - | 'material_json' - | 'acceptance_json' - | 'draft_acceptance_json' - | 'problem_note' - | 'reward_amount' - | 'sharing_enabled' - | 'sharing_total_quantity' - | 'sharing_unit_reward' - | 'published_at' - | 'hall_queued_at' - | 'pinned_at' - | 'assigned_at' - | 'submitted_at' - | 'accepted_at' - | 'updated_at' - > - >, -): Promise { - const current = await getWorkOrderById(workOrderId) - if (!current) return null - const next = { ...current, ...patch } - await query( - ` - UPDATE work_orders - SET - status = $1, assigned_worker_id = $2, material_json = $3::jsonb, - acceptance_json = $4::jsonb, draft_acceptance_json = $5::jsonb, - problem_note = $6, reward_amount = $7, sharing_enabled = $8, - sharing_total_quantity = $9, sharing_unit_reward = $10, published_at = $11, - hall_queued_at = $12, pinned_at = $13, assigned_at = $14, submitted_at = $15, - accepted_at = $16, updated_at = $17 - WHERE id = $18 - `, - [ - next.status, - next.assigned_worker_id || null, - toJsonString(next.material_json), - toJsonString(next.acceptance_json), - toJsonString(next.draft_acceptance_json), - next.problem_note || '', - toPositiveInteger(next.reward_amount, 0), - next.sharing_enabled === true, - toPositiveInteger(next.sharing_total_quantity, 1), - toPositiveInteger(next.sharing_unit_reward, 0), - next.published_at || null, - next.hall_queued_at || null, - next.pinned_at || null, - next.assigned_at || null, - next.submitted_at || null, - next.accepted_at || null, - next.updated_at, - Number(workOrderId), - ], - ) - return getWorkOrderById(workOrderId) -} - -/** 将未分配工单原子发布到大厅,并在事务内记录发布事件。 */ -export async function publishUnassignedWorkOrder(input: { - workOrderId: number | string - now: string - actorName?: string -}): Promise<{ - order: WorkOrderRow | null - failureReason: 'work_order_not_publishable' | null -}> { - return withTransaction(async (client) => { - const currentResult = await client.query( - ` - SELECT * - FROM work_orders - WHERE id = $1 - FOR UPDATE - `, - [Number(input.workOrderId)], - ) - const current = currentResult.rows[0] || null - if (!current || current.status !== 'unassigned' || current.assigned_worker_id) { - return { order: null, failureReason: 'work_order_not_publishable' } - } - - const updatedResult = await client.query<{ id: number }>( - ` - UPDATE work_orders - SET status = 'open', published_at = $1, hall_queued_at = $1, updated_at = $1 - WHERE id = $2 - AND status = 'unassigned' - AND assigned_worker_id IS NULL - AND reward_amount > 0 - RETURNING id - `, - [input.now, Number(input.workOrderId)], - ) - if (!updatedResult.rows[0]) { - return { order: null, failureReason: 'work_order_not_publishable' } - } - - await createWorkOrderEventWithClient(client, { - workOrderId: Number(input.workOrderId), - actorType: 'admin', - actorId: input.actorName || '', - eventType: 'published', - fromStatus: current.status, - toStatus: 'open', - payloadJson: JSON.stringify({ voucherConsumeDeferred: true }), - now: input.now, - }) - - return { - order: await getWorkOrderByIdWithClient(client, input.workOrderId), - failureReason: null, - } - }) -} - -export async function updateWorkOrderBasic( - workOrderId: number | string, - patch: { - productName?: string - platformOrderId?: string - categoryId?: number | null - rewardAmount?: number - requiredDepositAmount?: number - depositThresholdAmount?: number - requirementJson?: string - timeoutMinutes?: number - timeoutPolicy?: string - updatedAt: string - }, -): Promise { - const current = await getWorkOrderById(workOrderId) - if (!current) return null - await query( - ` - UPDATE work_orders - SET - platform_order_id = $1, product_name = $2, category_id = $3, reward_amount = $4, - required_deposit_amount = $5, deposit_threshold_amount = $6, - requirement_json = $7::jsonb, timeout_minutes = $8, timeout_policy = $9, - updated_at = $10 - WHERE id = $11 - `, - [ - String((patch.platformOrderId ?? current.platform_order_id) || '').trim(), - String((patch.productName ?? current.product_name) || '').trim(), - patch.categoryId === undefined ? current.category_id : patch.categoryId, - toPositiveInteger(patch.rewardAmount ?? current.reward_amount, 0), - toPositiveInteger(patch.requiredDepositAmount ?? current.required_deposit_amount, 0), - toPositiveInteger(patch.depositThresholdAmount ?? current.deposit_threshold_amount, 0), - toJsonString(patch.requirementJson ?? current.requirement_json), - toPositiveInteger(patch.timeoutMinutes ?? current.timeout_minutes, 0), - String((patch.timeoutPolicy ?? current.timeout_policy) || 'reopen').trim(), - patch.updatedAt, - Number(workOrderId), - ], - ) - return getWorkOrderById(workOrderId) -} - -export async function updateWorkOrderAdminNote( - workOrderId: number | string, - note: string, - updatedAt: string, -): Promise { - const result = await query<{ id: number }>( - `UPDATE work_orders SET admin_note = $1, updated_at = $2 WHERE id = $3 RETURNING id`, - [String(note || '').trim(), updatedAt, Number(workOrderId)], - ) - if (!result.rows[0]) return null - return getWorkOrderById(result.rows[0].id) -} - -export async function deleteWorkOrder(workOrderId: number | string): Promise { - const result = await query('DELETE FROM work_orders WHERE id = $1', [Number(workOrderId)]) - return Number(result.rowCount || 0) > 0 -} diff --git a/publish-atomic-artifacts/ROLLBACK.sh b/publish-atomic-artifacts/ROLLBACK.sh deleted file mode 100755 index c8e09f85..00000000 --- a/publish-atomic-artifacts/ROLLBACK.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -eu - -target="${1:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/MODIFIED_FILE}" -repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" - -git -C "$repo" show HEAD:apps/backend/src/repositories/worker-platform/work-order-management-repo.ts > "$target" -printf 'rollback restored HEAD content: %s\n' "$target" diff --git a/publish-atomic-artifacts/VERIFICATION.txt b/publish-atomic-artifacts/VERIFICATION.txt deleted file mode 100644 index 8f6147ac..00000000 --- a/publish-atomic-artifacts/VERIFICATION.txt +++ /dev/null @@ -1,35 +0,0 @@ -发布与核销解耦原子修复验证 - -改变分支/字段: -- apps/backend/src/services/worker-platform/admin-work-order-management-service.ts:发布先完成,随后尽力核销;核销失败仅记录 publish_voucher_consume_failed,不改变已发布状态。 -- apps/backend/src/repositories/worker-platform/work-order-management-repo.ts:新增 publishUnassignedWorkOrder,使用 status='unassigned' AND assigned_worker_id IS NULL 的原子条件迁移,并在同一事务写 published 事件。 -- apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx:发布后核销提示。 -- apps/frontend/src/components/WorkOrderEventTimeline.tsx:核销失败文案改为“订单已发布,但电子凭证核销未完成”。 - -四个工件: -- MODIFIED_FILE: /Users/yml/codes/order_site/publish-atomic-artifacts/MODIFIED_FILE -- DIFF_FILE: /Users/yml/codes/order_site/publish-atomic-artifacts/DIFF_FILE.patch -- VERIFICATION.txt: /Users/yml/codes/order_site/publish-atomic-artifacts/VERIFICATION.txt -- ROLLBACK.sh: /Users/yml/codes/order_site/publish-atomic-artifacts/ROLLBACK.sh - -原始文件哈希: -- git HEAD work-order-management-repo.ts blob: 40937f3a95998868a217a5c3da78389f6c799224 -- 修改副本 sha256: a7e256197ff04b4682a4b2817da30e7344ac0cda66818ecc9fe5711042957a85 - -BASELINE: -- 命令:cd /Users/yml/codes/order_site/apps/backend && npm test -- 输入:HEAD 基线工作树 -- 字面结果:tests 379;pass 377;fail 0;cancelled 0;skipped 2;exit 0 -- 状态:通过 - -MODIFIED: -- 命令:cd /Users/yml/codes/order_site/apps/backend && npm test -- 输入:当前修改工作树 -- 字面结果:tests 379;pass 377;fail 0;cancelled 0;skipped 2;exit 0 -- 状态:通过 - -ROLLBACK: -- 命令:cp /Users/yml/codes/order_site/publish-atomic-artifacts/MODIFIED_FILE /tmp/publish-atomic-rollback-test.ts && /Users/yml/codes/order_site/publish-atomic-artifacts/ROLLBACK.sh /tmp/publish-atomic-rollback-test.ts && cmp /tmp/publish-atomic-rollback-test.ts <(git -C /Users/yml/codes/order_site show HEAD:apps/backend/src/repositories/worker-platform/work-order-management-repo.ts) -- 输入:修改副本 /tmp/publish-atomic-rollback-test.ts -- 字面结果:rollback restored HEAD content;cmp exit 0;ROLLBACK.sh exit 0 -- 恢复状态:另一份副本恢复为 HEAD 内容,MODIFIED_FILE 保持修改内容。 diff --git a/security-artifacts/BASELINE_admin_auth.ts b/security-artifacts/BASELINE_admin_auth.ts deleted file mode 100644 index c8b17c6c..00000000 --- a/security-artifacts/BASELINE_admin_auth.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Router } from 'express' - -import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' -import { - getAdminSessionSummary, - loginAdmin, - logoutAdmin, -} from '../../services/admin/admin-auth-service.js' -import { - resolveClientIp, - resolveClientLocation, - resolveUserAgent, -} from '../../services/admin/admin-login-log-service.js' -import { createJsonHandler, extractBearerToken } from './session.js' - -const router = Router() - -router.post( - '/auth/login', - createRateLimitMiddleware({ - scope: 'admin:login', - windowMs: 60_000, - max: 10, - key: getBodyFieldRateLimitKey('username'), - }), - createJsonHandler( - (req) => - loginAdmin(req.body?.username, req.body?.password, { - ip: resolveClientIp(req), - userAgent: resolveUserAgent(req), - location: resolveClientLocation(req), - }), - { - successMessage: '登录成功', - errorMessage: '后台登录失败', - scope: '[admin/auth/login]', - }, - ), -) - -router.get( - '/auth/session', - createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { - successMessage: 'ok', - errorMessage: '读取后台登录态失败', - scope: '[admin/auth/session]', - }), -) - -router.post( - '/auth/logout', - createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), { - successMessage: '已退出登录', - errorMessage: '后台退出失败', - scope: '[admin/auth/logout]', - }), -) - -export default router diff --git a/security-artifacts/BASELINE_rate-limit.test.ts b/security-artifacts/BASELINE_rate-limit.test.ts deleted file mode 100644 index a7231e3c..00000000 --- a/security-artifacts/BASELINE_rate-limit.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import type { NextFunction, Request, Response } from 'express' - -import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js' - -test('createRateLimitMiddleware allows requests within the window', () => { - resetRateLimitBucketsForTest() - - const limiter = createRateLimitMiddleware({ - scope: 'test:allow', - windowMs: 60_000, - max: 2, - }) - const req = createMockRequest() - const res = createMockResponse() - let nextCount = 0 - const next: NextFunction = () => { - nextCount += 1 - } - - limiter(req, res, next) - limiter(req, res, next) - - assert.equal(nextCount, 2) - assert.equal(res.statusCode, 200) -}) - -test('createRateLimitMiddleware blocks requests over the limit', () => { - resetRateLimitBucketsForTest() - - const limiter = createRateLimitMiddleware({ - scope: 'test:block', - windowMs: 60_000, - max: 1, - }) - const req = createMockRequest() - const res = createMockResponse() - let nextCount = 0 - const next: NextFunction = () => { - nextCount += 1 - } - - limiter(req, res, next) - limiter(req, res, next) - - assert.equal(nextCount, 1) - assert.equal(res.statusCode, 429) - assert.equal(res.headers['Retry-After'], '60') - assert.equal(res.body.errorCode, 'rate_limited') -}) - -test('createRateLimitMiddleware supports custom limit responses', () => { - resetRateLimitBucketsForTest() - - const limiter = createRateLimitMiddleware({ - scope: 'test:custom', - windowMs: 60_000, - max: 1, - onLimit: (_req, res) => { - res.status(200).json({ code: 429, message: 'limited' }) - }, - }) - const req = createMockRequest() - const res = createMockResponse() - - limiter(req, res, () => {}) - limiter(req, res, () => {}) - - assert.equal(res.statusCode, 200) - assert.deepEqual(res.body, { code: 429, message: 'limited' }) -}) - -function createMockRequest(): Request { - return { - ip: '127.0.0.1', - originalUrl: '/test', - headers: {}, - socket: { - remoteAddress: '127.0.0.1', - }, - } as Request -} - -function createMockResponse(): Response & { - statusCode: number - headers: Record - body: any -} { - const res = { - statusCode: 200, - headers: {} as Record, - body: null as any, - setHeader(name: string, value: string) { - this.headers[name] = value - return this - }, - status(statusCode: number) { - this.statusCode = statusCode - return this - }, - json(body: any) { - this.body = body - return this - }, - } - - return res as Response & { - statusCode: number - headers: Record - body: any - } -} diff --git a/security-artifacts/DIFF_FILE.patch b/security-artifacts/DIFF_FILE.patch deleted file mode 100644 index 64c47add..00000000 --- a/security-artifacts/DIFF_FILE.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts ---- a/apps/backend/src/routes/admin/auth.ts -+++ b/apps/backend/src/routes/admin/auth.ts -@@ - const router = Router() -+ -+const adminLoginIpRateLimit = createRateLimitMiddleware({ -+ scope: 'admin:login:ip', -+ windowMs: 60_000, -+ max: 20, -+}) -+ -+const adminLoginAccountRateLimit = createRateLimitMiddleware({ -+ scope: 'admin:login:account', -+ windowMs: 60_000, -+ max: 10, -+ key: getBodyFieldRateLimitKey('username'), -+}) - - router.post( - '/auth/login', -- createRateLimitMiddleware({ -- scope: 'admin:login', -- windowMs: 60_000, -- max: 10, -- key: getBodyFieldRateLimitKey('username'), -- }), -+ adminLoginIpRateLimit, -+ adminLoginAccountRateLimit, diff --git a/security-artifacts/MODIFIED_FILE.ts b/security-artifacts/MODIFIED_FILE.ts deleted file mode 100644 index 723e5c06..00000000 --- a/security-artifacts/MODIFIED_FILE.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Router } from 'express' - -import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' -import { - getAdminSessionSummary, - loginAdmin, - logoutAdmin, -} from '../../services/admin/admin-auth-service.js' -import { - resolveClientIp, - resolveClientLocation, - resolveUserAgent, -} from '../../services/admin/admin-login-log-service.js' -import { createJsonHandler, extractBearerToken } from './session.js' - -const router = Router() - -const adminLoginIpRateLimit = createRateLimitMiddleware({ - scope: 'admin:login:ip', - windowMs: 60_000, - max: 20, -}) - -const adminLoginAccountRateLimit = createRateLimitMiddleware({ - scope: 'admin:login:account', - windowMs: 60_000, - max: 10, - key: getBodyFieldRateLimitKey('username'), -}) - -router.post( - '/auth/login', - adminLoginIpRateLimit, - adminLoginAccountRateLimit, - createJsonHandler( - (req) => - loginAdmin(req.body?.username, req.body?.password, { - ip: resolveClientIp(req), - userAgent: resolveUserAgent(req), - location: resolveClientLocation(req), - }), - { - successMessage: '登录成功', - errorMessage: '后台登录失败', - scope: '[admin/auth/login]', - }, - ), -) - -router.get( - '/auth/session', - createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { - successMessage: 'ok', - errorMessage: '读取后台登录态失败', - scope: '[admin/auth/session]', - }), -) - -router.post( - '/auth/logout', - createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), { - successMessage: '已退出登录', - errorMessage: '后台退出失败', - scope: '[admin/auth/logout]', - }), -) - -export default router diff --git a/security-artifacts/ROLLBACK.sh b/security-artifacts/ROLLBACK.sh deleted file mode 100755 index 23cc84ac..00000000 --- a/security-artifacts/ROLLBACK.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="${1:?usage: ROLLBACK.sh }" -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" - -cp "$SCRIPT_DIR/BASELINE_admin_auth.ts" "$ROOT/apps/backend/src/routes/admin/auth.ts" -cp "$SCRIPT_DIR/BASELINE_rate-limit.test.ts" "$ROOT/apps/backend/src/middleware/rate-limit.test.ts" -printf 'restored admin auth route and rate-limit test in %s\n' "$ROOT" diff --git a/security-artifacts/VERIFICATION.txt b/security-artifacts/VERIFICATION.txt deleted file mode 100644 index 36b7aae6..00000000 --- a/security-artifacts/VERIFICATION.txt +++ /dev/null @@ -1,37 +0,0 @@ -Security review: admin login brute-force protection - -Changed branch/field: -- apps/backend/src/routes/admin/auth.ts, POST /auth/login middleware chain -- Added admin:login:ip (20 requests / 60 seconds per client IP) -- Kept admin:login:account (10 requests / 60 seconds per client IP + username) - -Artifacts: -- MODIFIED_FILE: /Users/yml/codes/order_site/security-artifacts/MODIFIED_FILE.ts -- DIFF_FILE: /Users/yml/codes/order_site/security-artifacts/DIFF_FILE.patch -- VERIFICATION.txt: /Users/yml/codes/order_site/security-artifacts/VERIFICATION.txt -- ROLLBACK.sh: /Users/yml/codes/order_site/security-artifacts/ROLLBACK.sh - -BASELINE -Command: npm test -- --test-name-pattern='createRateLimitMiddleware' -Input: repository HEAD sources before the route/test edits -Literal result: tests 371; pass 369; fail 0; skipped 2; exit status 0 - -MODIFIED -Command: node --import tsx --test src/middleware/rate-limit.test.ts -Input: modified rate-limit middleware test source -Literal result: tests 4; pass 4; fail 0; skipped 0; exit status 0 - -Command: npm run check -Input: modified backend source tree -Literal result: format check passed; SQL guard passed; ESLint passed; TypeScript passed; tests 373; pass 371; fail 0; skipped 2; build completed; exit status 0 - -ROLLBACK -Command: security-artifacts/ROLLBACK.sh security-artifacts/rollback-copy -Input: independent copy containing MODIFIED_FILE.ts and BASELINE_rate-limit.test.ts -Literal result: restored admin auth route and rate-limit test in security-artifacts/rollback-copy; baseline admin auth SHA-256 d1afed851de0dce8704f4233e1b9339a33a88d1ae9c89d6639aa104be7ff8aff; exit status 0 -Restored behavior/status: rollback-copy/apps/backend/src/routes/admin/auth.ts matches the baseline hash; the working source remains modified. - -Original source hash before modification: -d1afed851de0dce8704f4233e1b9339a33a88d1ae9c89d6639aa104be7ff8aff -Modified copy hash: -c70ca222c13245a6e1714f1c90f3592f5c4fd3713a1ef3af5fb4073eb9b9db95 diff --git a/security-artifacts/rollback-copy/apps/backend/src/middleware/rate-limit.test.ts b/security-artifacts/rollback-copy/apps/backend/src/middleware/rate-limit.test.ts deleted file mode 100644 index a7231e3c..00000000 --- a/security-artifacts/rollback-copy/apps/backend/src/middleware/rate-limit.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import type { NextFunction, Request, Response } from 'express' - -import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js' - -test('createRateLimitMiddleware allows requests within the window', () => { - resetRateLimitBucketsForTest() - - const limiter = createRateLimitMiddleware({ - scope: 'test:allow', - windowMs: 60_000, - max: 2, - }) - const req = createMockRequest() - const res = createMockResponse() - let nextCount = 0 - const next: NextFunction = () => { - nextCount += 1 - } - - limiter(req, res, next) - limiter(req, res, next) - - assert.equal(nextCount, 2) - assert.equal(res.statusCode, 200) -}) - -test('createRateLimitMiddleware blocks requests over the limit', () => { - resetRateLimitBucketsForTest() - - const limiter = createRateLimitMiddleware({ - scope: 'test:block', - windowMs: 60_000, - max: 1, - }) - const req = createMockRequest() - const res = createMockResponse() - let nextCount = 0 - const next: NextFunction = () => { - nextCount += 1 - } - - limiter(req, res, next) - limiter(req, res, next) - - assert.equal(nextCount, 1) - assert.equal(res.statusCode, 429) - assert.equal(res.headers['Retry-After'], '60') - assert.equal(res.body.errorCode, 'rate_limited') -}) - -test('createRateLimitMiddleware supports custom limit responses', () => { - resetRateLimitBucketsForTest() - - const limiter = createRateLimitMiddleware({ - scope: 'test:custom', - windowMs: 60_000, - max: 1, - onLimit: (_req, res) => { - res.status(200).json({ code: 429, message: 'limited' }) - }, - }) - const req = createMockRequest() - const res = createMockResponse() - - limiter(req, res, () => {}) - limiter(req, res, () => {}) - - assert.equal(res.statusCode, 200) - assert.deepEqual(res.body, { code: 429, message: 'limited' }) -}) - -function createMockRequest(): Request { - return { - ip: '127.0.0.1', - originalUrl: '/test', - headers: {}, - socket: { - remoteAddress: '127.0.0.1', - }, - } as Request -} - -function createMockResponse(): Response & { - statusCode: number - headers: Record - body: any -} { - const res = { - statusCode: 200, - headers: {} as Record, - body: null as any, - setHeader(name: string, value: string) { - this.headers[name] = value - return this - }, - status(statusCode: number) { - this.statusCode = statusCode - return this - }, - json(body: any) { - this.body = body - return this - }, - } - - return res as Response & { - statusCode: number - headers: Record - body: any - } -} diff --git a/security-artifacts/rollback-copy/apps/backend/src/routes/admin/auth.ts b/security-artifacts/rollback-copy/apps/backend/src/routes/admin/auth.ts deleted file mode 100644 index c8b17c6c..00000000 --- a/security-artifacts/rollback-copy/apps/backend/src/routes/admin/auth.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Router } from 'express' - -import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' -import { - getAdminSessionSummary, - loginAdmin, - logoutAdmin, -} from '../../services/admin/admin-auth-service.js' -import { - resolveClientIp, - resolveClientLocation, - resolveUserAgent, -} from '../../services/admin/admin-login-log-service.js' -import { createJsonHandler, extractBearerToken } from './session.js' - -const router = Router() - -router.post( - '/auth/login', - createRateLimitMiddleware({ - scope: 'admin:login', - windowMs: 60_000, - max: 10, - key: getBodyFieldRateLimitKey('username'), - }), - createJsonHandler( - (req) => - loginAdmin(req.body?.username, req.body?.password, { - ip: resolveClientIp(req), - userAgent: resolveUserAgent(req), - location: resolveClientLocation(req), - }), - { - successMessage: '登录成功', - errorMessage: '后台登录失败', - scope: '[admin/auth/login]', - }, - ), -) - -router.get( - '/auth/session', - createJsonHandler((req) => getAdminSessionSummary(extractBearerToken(req)), { - successMessage: 'ok', - errorMessage: '读取后台登录态失败', - scope: '[admin/auth/session]', - }), -) - -router.post( - '/auth/logout', - createJsonHandler((req) => logoutAdmin(extractBearerToken(req)), { - successMessage: '已退出登录', - errorMessage: '后台退出失败', - scope: '[admin/auth/logout]', - }), -) - -export default router diff --git a/test-fix-artifacts/BASELINE_postgres_integration.test.ts b/test-fix-artifacts/BASELINE_postgres_integration.test.ts deleted file mode 100644 index c9d28eeb..00000000 --- a/test-fix-artifacts/BASELINE_postgres_integration.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { Pool } from 'pg' - -const connectionString = process.env.TEST_POSTGRES_URL - -test('real PostgreSQL transaction rolls back atomically', { skip: !connectionString }, async () => { - const pool = new Pool({ connectionString }) - const client = await pool.connect() - const tableName = `integration_tx_${Date.now()}` - try { - await client.query(`CREATE TEMP TABLE ${tableName} (id integer primary key, value text)`) - await client.query('BEGIN') - await client.query(`INSERT INTO ${tableName} (id, value) VALUES (1, 'before-error')`) - try { - await client.query(`INSERT INTO ${tableName} (id, value) VALUES (1, 'duplicate')`) - } catch { - await client.query('ROLLBACK') - } - const result = await client.query(`SELECT COUNT(*)::int AS count FROM ${tableName}`) - assert.equal(result.rows[0].count, 0) - } finally { - client.release() - await pool.end() - } -}) - -test( - 'real PostgreSQL enforces callback replay idempotency under concurrency', - { - skip: !connectionString, - }, - async () => { - const pool = new Pool({ connectionString }) - const tableName = `integration_webhook_${Date.now()}` - try { - await pool.query( - `CREATE TEMP TABLE ${tableName} ( - id serial primary key, - provider text not null, - event_id text not null, - unique(provider, event_id) - )`, - ) - await Promise.all( - Array.from({ length: 8 }, () => - pool.query( - `INSERT INTO ${tableName} (provider, event_id) - VALUES ('integration', 'replay-1') - ON CONFLICT (provider, event_id) DO NOTHING`, - ), - ), - ) - const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${tableName}`) - assert.equal(result.rows[0].count, 1) - } finally { - await pool.end() - } - }, -) diff --git a/test-fix-artifacts/BASELINE_worker_query.ts b/test-fix-artifacts/BASELINE_worker_query.ts deleted file mode 100644 index 15a19d68..00000000 --- a/test-fix-artifacts/BASELINE_worker_query.ts +++ /dev/null @@ -1,960 +0,0 @@ -import { query } from '../../db/client.js' -import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js' -import type { PoolClient } from 'pg' -import { - normalizeWorkOrderAcceptanceMode, - WORK_ORDER_GIFT_PHASE, -} from '../../domain/work-order-acceptance-mode.js' -export const WORK_ORDER_SELECT = ` - SELECT - wo.*, - COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity, - COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count, - wc.name AS category_name, - wu.username AS worker_username, - wu.display_name AS worker_display_name, - cancel_info.cancel_reason, - cancel_info.cancel_event_type, - cancel_info.cancel_actor_type, - cancel_info.cancel_actor_id, - cancel_info.cancelled_at - FROM work_orders wo - LEFT JOIN work_categories wc ON wc.id = wo.category_id - LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id - LEFT JOIN LATERAL ( - SELECT - ev.event_type AS cancel_event_type, - ev.actor_type AS cancel_actor_type, - ev.actor_id AS cancel_actor_id, - ev.created_at AS cancelled_at, - BTRIM(COALESCE( - NULLIF(BTRIM(COALESCE(ev.payload_json->>'reason', '')), ''), - NULLIF(BTRIM(COALESCE(ev.payload_json->>'note', '')), ''), - CASE ev.event_type - WHEN 'timeout_cancel_release' THEN '任务超时未完成,系统自动取消并退还押金' - WHEN 'timeout_cancel_deduct' THEN '任务超时未完成,系统自动取消并扣除押金' - ELSE '' - END - )) AS cancel_reason - FROM work_order_events ev - WHERE ev.work_order_id = wo.id - AND wo.status = 'cancelled' - AND ev.event_type IN ( - 'cancelled_by_admin', - 'problem_cancel_release', - 'problem_cancel_deduct', - 'timeout_cancel_release', - 'timeout_cancel_deduct' - ) - ORDER BY ev.id DESC - LIMIT 1 - ) cancel_info ON TRUE - LEFT JOIN LATERAL ( - SELECT - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - ) share_summary ON TRUE -` - -// Keep the page and total in one filtered query. This avoids running the same -// joins and share aggregation twice for every list request. -const LIST_WORK_ORDER_SELECT = WORK_ORDER_SELECT.replace( - ' SELECT\n', - ' SELECT\n COUNT(*) OVER()::int AS total_count,\n', -) - -type WorkerOrderOverviewRow = { - status: string - order_count: number - settlement_amount: number - vip_evidence_pending_count: number - vip_evidence_pending_settlement_amount: number -} - -type WorkOrderStatisticsRow = { - status: string - total_count: number - user_payment_amount: number | string - reward_amount: number | string - refund_amount: number | string -} - -type TimeoutCountCacheEntry = { - expiresAt: number - counts: Map -} - -// 后台打手列表会在短时间内重复请求同一页;超时次数只是展示字段,允许短 TTL。 -const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000 -const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256 -const timeoutCountCache = new Map() - -/** 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 */ -function adminOperationalStatusExpression(alias = 'wo', shareSummaryAlias = '') { - if (shareSummaryAlias) { - return `CASE - WHEN ${alias}.status = 'open' - AND ${alias}.sharing_enabled IS TRUE - AND COALESCE(${shareSummaryAlias}.active_share_quantity, 0) > 0 - THEN CASE - WHEN COALESCE(${shareSummaryAlias}.active_share_quantity, 0) >= ${alias}.sharing_total_quantity - AND COALESCE(${shareSummaryAlias}.pending_share_count, 0) = 0 - THEN 'pending_acceptance' - ELSE 'in_progress' - END - ELSE ${alias}.status - END` - } - - return `CASE - WHEN ${alias}.status = 'open' - AND ${alias}.sharing_enabled IS TRUE - AND COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) > 0 - THEN CASE - WHEN COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) >= ${alias}.sharing_total_quantity - AND COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0) = 0 - THEN 'pending_acceptance' - ELSE 'in_progress' - END - ELSE ${alias}.status - END` -} - -/** 工单统一搜索字段,兼容当前资料结构和历史资料字段。 */ -function workOrderSearchExpression(alias = 'wo') { - return `LOWER( - COALESCE(${alias}.work_order_no, '') || ' ' || - COALESCE(${alias}.platform_order_id, '') || ' ' || - COALESCE(${alias}.product_name, '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameNickname', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameAccount', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'roleName', '') || ' ' || - COALESCE(${alias}.material_json->'fields'->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->'fields'->>'gameNickname', '') || ' ' || - COALESCE(${alias}.material_json->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->>'gameNickname', '') - )` -} - -/** - * 打手订单个人状态:拼单按个人份额状态计算。 - * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, - * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 - */ -export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { - const baseExpression = `CASE - WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status - ELSE COALESCE(CASE ${workerShareAlias}.status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo.status - END, wo.status) - END` - const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) - if (normalizedWorkerId <= 0) return baseExpression - const pendingHandlingExists = `( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = wo.id - AND wcr.worker_id = ${normalizedWorkerId} - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = wo.id - AND wf.worker_id = ${normalizedWorkerId} - AND wf.status = 'pending' - ) - )` - return `CASE - WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem' - ELSE ${baseExpression} - END` -} -export async function getWorkOrderByOrderItemId( - orderItemId: number | string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} WHERE wo.order_item_id = $1 LIMIT 1`, - [Number(orderItemId)], - ) - return result.rows[0] || null -} - -export async function getWorkOrderById(workOrderId: number | string): Promise { - const result = await query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ - Number(workOrderId), - ]) - return result.rows[0] || null -} - -export async function findPendingMaterialWorkOrderByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' - ORDER BY wo.id DESC - LIMIT 1`, - [String(platformOrderId || '').trim()], - ) - return result.rows[0] || null -} - -export async function listPendingMaterialWorkOrdersByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' - ORDER BY wo.id ASC`, - [String(platformOrderId || '').trim()], - ) - return result.rows -} - -export async function listWorkOrdersByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 - ORDER BY wo.id ASC`, - [String(platformOrderId || '').trim()], - ) - return result.rows -} - -export async function listWorkOrders({ - page = 1, - pageSize = 20, - statuses = [], - status = '', - workOrderId = 0, - keyword = '', - workerId = 0, - workerLevelIds = [], - categoryId = 0, - workerSharingId = 0, - hallCandidateLimit = 0, - hallCategoryLimits = {}, - visibleAfterIso = '', - excludeFilledSharing = false, - vipEvidencePending = false, - acceptanceMode = '', - giftPhase = '', - useOperationalStatus = false, - pinnedFirst = false, - sort = 'id_desc', - createdFrom = '', - createdTo = '', -}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { - const effectiveStatuses = statuses.length > 0 ? statuses : status.trim() ? [status.trim()] : [] - const { joins, whereClause, params } = buildWorkOrderWhere({ - statuses: effectiveStatuses, - workOrderId, - keyword, - workerId, - workerLevelIds, - categoryId, - workerSharingId, - hallCandidateLimit, - hallCategoryLimits, - visibleAfterIso, - excludeFilledSharing, - vipEvidencePending, - acceptanceMode, - giftPhase, - useOperationalStatus, - createdFrom, - createdTo, - }) - const offset = (page - 1) * pageSize - const orderBy = - sort === 'worker_claimed_at_desc' && workerSharingId - ? `COALESCE( - CASE - WHEN wo.assigned_worker_id = $1 THEN wo.assigned_at - END, - CASE - WHEN worker_share.status != 'cancelled' THEN worker_share.created_at - END - ) DESC NULLS LAST, wo.id DESC` - : 'wo.id DESC' - const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy - params.push(pageSize, offset) - const itemsResult = await query( - `${LIST_WORK_ORDER_SELECT} - ${joins} - ${whereClause} - ORDER BY ${effectiveOrderBy} - LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, - ) - // A page beyond the end has no row from which to read the window count. - // Keep that uncommon pagination edge case correct without restoring a - // second query for normal requests. - let total = Number( - (itemsResult.rows[0] as WorkOrderRow & { total_count?: number })?.total_count || 0, - ) - if (itemsResult.rows.length === 0 && page > 1) { - const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM work_orders wo ${joins} ${whereClause}`, - params.slice(0, -2), - ) - total = Number(totalResult.rows[0]?.total || 0) - } - return { - items: itemsResult.rows, - total, - } -} - -/** 按筛选条件汇总工单,统计范围不受当前分页影响。 */ -export async function getWorkOrderStatistics(input: ListInput = {}): Promise { - const { joins, whereClause, params } = buildWorkOrderWhere(input) - const vipEvidencePendingWhere = ` - wo.status = 'accepted' - AND wo.acceptance_json->>'reviewMode' = 'vip_auto' - AND wo.acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ` - const operationalStatus = input.useOperationalStatus - ? adminOperationalStatusExpression('wo', 'operational_share_summary') - : 'wo.status' - const statusExpression = input.vipEvidencePending - ? `CASE WHEN ${vipEvidencePendingWhere} THEN 'vip_evidence_pending' ELSE ${operationalStatus} END` - : operationalStatus - const paymentExpression = ` - CASE - WHEN COALESCE(wo.material_json->'source'->>'orderAmountFen', '') ~ '^-?[0-9]+$' - THEN (wo.material_json->'source'->>'orderAmountFen')::bigint - ELSE COALESCE(o.total_amount, 0) - END - ` - const result = await query( - ` - SELECT - ${statusExpression} AS status, - COUNT(*)::int AS total_count, - COALESCE(SUM(${paymentExpression}), 0)::bigint AS user_payment_amount, - COALESCE(SUM(wo.reward_amount), 0)::bigint AS reward_amount, - COALESCE(SUM( - CASE - WHEN o.pay_status = 'refunded' OR o.order_status = 'refunded' - THEN ${paymentExpression} - ELSE 0 - END - ), 0)::bigint AS refund_amount - FROM work_orders wo - LEFT JOIN orders o ON o.id = wo.order_id - ${joins} - ${whereClause} - GROUP BY ${statusExpression} - `, - params, - ) - const statistics: WorkOrderStatistics = { - totalCount: 0, - userPaymentAmount: 0, - rewardAmount: 0, - refundAmount: 0, - statusCounts: {}, - } - for (const row of result.rows) { - const count = Number(row.total_count || 0) - statistics.totalCount += count - statistics.userPaymentAmount += Number(row.user_payment_amount || 0) - statistics.rewardAmount += Number(row.reward_amount || 0) - statistics.refundAmount += Number(row.refund_amount || 0) - statistics.statusCounts[row.status] = count - } - return statistics -} - -/** 汇总打手本人各订单状态的可结算金额,拼单按个人份额计算。 */ -export async function getWorkerOrderOverview(workerId: number | string): Promise< - Array<{ - status: string - orderCount: number - settlementAmount: number - vipEvidencePendingCount: number - vipEvidencePendingSettlementAmount: number - }> -> { - const normalizedWorkerId = Number(workerId) - const result = await query( - ` - WITH visible_orders AS ( - SELECT wo.id AS work_order_id - FROM work_orders wo - WHERE wo.assigned_worker_id = $1 - UNION - SELECT wo.id AS work_order_id - FROM work_orders wo - WHERE wo.last_assigned_worker_id = $1 - AND ( - wo.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = wo.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) - UNION - SELECT wos.work_order_id - FROM work_order_shares wos - WHERE wos.worker_id = $1 - AND wos.status != 'cancelled' - ), - worker_base AS ( - SELECT - wo.id, - wo.status AS wo_status, - wos.status AS wos_status, - wos.id IS NOT NULL AS is_worker_share, - CASE - WHEN wos.id IS NOT NULL THEN wos.share_reward - WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount - ELSE 0 - END AS worker_settlement_amount, - wo.acceptance_json - FROM work_orders wo - INNER JOIN visible_orders vo ON vo.work_order_id = wo.id - LEFT JOIN work_order_shares wos - ON wos.work_order_id = wo.id - AND wos.worker_id = $1 - AND wos.status != 'cancelled' - ), - worker_orders AS ( - SELECT - CASE - WHEN ( - CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - ) = 'in_progress' AND ( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = worker_base.id - AND wcr.worker_id = $1 - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = worker_base.id - AND wf.worker_id = $1 - AND wf.status = 'pending' - ) - ) THEN 'problem' - ELSE CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - END AS status, - acceptance_json, - is_worker_share, - worker_settlement_amount - FROM worker_base - ) - SELECT - status, - COUNT(*)::int AS order_count, - COALESCE( - SUM( - CASE - WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') - OR (status = 'open' AND is_worker_share) - THEN worker_settlement_amount - ELSE 0 - END - ), - 0 - )::int AS settlement_amount, - COUNT(*) FILTER ( - WHERE status = 'accepted' - AND acceptance_json->>'reviewMode' = 'vip_auto' - AND acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - )::int AS vip_evidence_pending_count, - COALESCE( - SUM(worker_settlement_amount) FILTER ( - WHERE status = 'accepted' - AND acceptance_json->>'reviewMode' = 'vip_auto' - AND acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ), - 0 - )::int AS vip_evidence_pending_settlement_amount - FROM worker_orders - GROUP BY status - `, - [normalizedWorkerId], - ) - return result.rows.map((row) => ({ - status: row.status, - orderCount: Number(row.order_count || 0), - settlementAmount: Number(row.settlement_amount || 0), - vipEvidencePendingCount: Number(row.vip_evidence_pending_count || 0), - vipEvidencePendingSettlementAmount: Number(row.vip_evidence_pending_settlement_amount || 0), - })) -} - -/** 查询已超过接单时限、仍待处置的工单。好友赠送单在“待确认好友”阶段等待后台操作,不计打手超时。 */ -export async function listOverdueWorkOrders({ - limit = 50, - workerId = 0, -}: { limit?: number; workerId?: number } = {}): Promise { - const params: unknown[] = [] - let workerClause = '' - if (workerId > 0) { - params.push(workerId) - workerClause = `AND wo.assigned_worker_id = $${params.length}` - } - params.push(limit) - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.status = 'in_progress' - AND wo.deadline_at IS NOT NULL - AND wo.deadline_at < NOW() - AND NOT (wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = 'material_submitted') - ${workerClause} - ORDER BY wo.deadline_at ASC - LIMIT $${params.length}`, - params, - ) - return result.rows -} - -export async function countWorkerActiveOrders(workerId: number | string): Promise { - const result = await query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int - FROM work_orders - WHERE assigned_worker_id = $1 - AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 - AND wos.status = 'joined' - AND wo.status = 'open' - ) AS total - `, - [Number(workerId)], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function countWorkerActiveOrdersWithClient( - client: PoolClient, - workerId: number, -): Promise { - const result = await client.query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int FROM work_orders - WHERE assigned_worker_id = $1 AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 AND wos.status = 'joined' AND wo.status = 'open' - ) AS total - `, - [workerId], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function countTimeoutEventsByWorkerIds( - workerIds: number[], -): Promise> { - const uniqueIds = [...new Set(workerIds.map((id) => Number(id)).filter((id) => id > 0))] - if (uniqueIds.length === 0) return new Map() - uniqueIds.sort((left, right) => left - right) - const cacheKey = uniqueIds.join(',') - const now = Date.now() - const cached = timeoutCountCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return new Map(cached.counts) - } - - const counts = new Map() - const result = await query<{ worker_id: string; total: number }>( - ` - SELECT payload_json->>'workerId' AS worker_id, COUNT(*)::int AS total - FROM work_order_events - WHERE event_type LIKE 'timeout_%' - AND payload_json->>'workerId' IS NOT NULL - AND payload_json->>'workerId' != '' - AND payload_json->>'workerId' = ANY($1::text[]) - GROUP BY payload_json->>'workerId' - `, - [uniqueIds.map(String)], - ) - for (const row of result.rows) { - const workerId = Number(row.worker_id) - if (Number.isFinite(workerId) && workerId > 0) { - counts.set(workerId, Number(row.total || 0)) - } - } - if (timeoutCountCache.size >= TIMEOUT_COUNT_CACHE_MAX_ENTRIES) { - const oldestKey = timeoutCountCache.keys().next().value - if (oldestKey) timeoutCountCache.delete(oldestKey) - } - timeoutCountCache.set(cacheKey, { - expiresAt: now + TIMEOUT_COUNT_CACHE_TTL_MS, - counts: new Map(counts), - }) - return counts -} - -export async function countWorkerTimeoutEvents(workerId: number | string): Promise { - const counts = await countTimeoutEventsByWorkerIds([Number(workerId)]) - return counts.get(Number(workerId)) || 0 -} - -export async function countWorkerCancellationsSince( - workerId: number | string, - sinceIso: string, -): Promise { - const result = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM work_order_events - WHERE actor_type = 'worker' - AND actor_id = $1 - AND event_type = 'cancelled_by_worker' - AND created_at >= $2 - `, - [String(workerId), sinceIso], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function getWorkOrderByIdWithClient( - client: PoolClient, - workOrderId: number | string, -): Promise { - const result = await client.query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ - Number(workOrderId), - ]) - return result.rows[0] || null -} - -export function buildWorkOrderWhere({ - statuses = [], - workOrderId = 0, - keyword = '', - workerId = 0, - workerLevelIds = [], - categoryId = 0, - workerSharingId = 0, - hallCandidateLimit = 0, - hallCategoryLimits = {}, - visibleAfterIso = '', - excludeFilledSharing = false, - vipEvidencePending = false, - acceptanceMode = '', - giftPhase = '', - useOperationalStatus = false, - createdFrom = '', - createdTo = '', -}: ListInput) { - const filters: string[] = [] - const params: unknown[] = [] - const joins: string[] = [] - if (createdFrom) { - params.push(createdFrom) - filters.push( - `wo.created_at >= ($${params.length}::date)::timestamp AT TIME ZONE 'Asia/Shanghai'`, - ) - } - if (createdTo) { - params.push(createdTo) - filters.push( - `wo.created_at < (($${params.length}::date + 1)::timestamp AT TIME ZONE 'Asia/Shanghai')`, - ) - } - let workerSharePlaceholder = '' - - if (excludeFilledSharing) { - joins.push(` - LEFT JOIN ( - SELECT - wos.work_order_id, - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity - FROM work_order_shares wos - GROUP BY wos.work_order_id - ) filter_share_summary ON filter_share_summary.work_order_id = wo.id`) - } - - // 先从打手的整单、历史单和拼单记录组成可见工单集合,再关联工单详情。 - // 这样 COUNT 和列表都不会先扫描全量工单,再逐行检查该打手的拼单状态。 - if (workerSharingId) { - params.push(workerSharingId) - workerSharePlaceholder = `$${params.length}` - joins.push(` - INNER JOIN ( - SELECT assigned.id AS work_order_id - FROM work_orders assigned - WHERE assigned.assigned_worker_id = ${workerSharePlaceholder} - UNION - SELECT historical.id AS work_order_id - FROM work_orders historical - WHERE historical.last_assigned_worker_id = ${workerSharePlaceholder} - AND ( - historical.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = historical.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) - UNION - SELECT share_source.work_order_id - FROM work_order_shares share_source - WHERE share_source.worker_id = ${workerSharePlaceholder} - AND share_source.status != 'cancelled' - ) worker_visible ON worker_visible.work_order_id = wo.id - LEFT JOIN work_order_shares worker_share - ON worker_share.work_order_id = wo.id - AND worker_share.worker_id = ${workerSharePlaceholder}`) - } - - if (useOperationalStatus) { - if (workerSharingId) { - // 已限定打手时只汇总其候选工单,利用 work_order_id 索引避免扫描整张拼单表。 - joins.push(` - LEFT JOIN LATERAL ( - SELECT - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - ) operational_share_summary ON TRUE`) - } else { - // 无打手范围时一次聚合全部拼单,避免对每笔工单重复执行关联子查询。 - joins.push(` - LEFT JOIN ( - SELECT - wos.work_order_id, - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - GROUP BY wos.work_order_id - ) operational_share_summary ON operational_share_summary.work_order_id = wo.id`) - } - } - const normalizedStatuses = [ - ...new Set(statuses.map((item) => String(item || '').trim()).filter(Boolean)), - ] - if (vipEvidencePending) { - const vipEvidencePendingWhere = ` - wo.status = 'accepted' - AND wo.acceptance_json->>'reviewMode' = 'vip_auto' - AND wo.acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ` - if (normalizedStatuses.length > 0) { - params.push(normalizedStatuses) - filters.push( - `(${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`, - ) - } else { - filters.push(`(${vipEvidencePendingWhere})`) - } - } else if (normalizedStatuses.length > 0) { - params.push(normalizedStatuses) - const statusesPlaceholder = `$${params.length}` - if (workerSharingId) { - filters.push( - `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`, - ) - } else { - filters.push( - `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, - ) - } - } - if (workOrderId) { - params.push(workOrderId) - filters.push(`wo.id = $${params.length}`) - } - if (keyword) { - params.push(`%${keyword}%`) - filters.push(`${workOrderSearchExpression()} LIKE LOWER($${params.length})`) - } - if (workerId) { - params.push(workerId) - filters.push(`( - wo.assigned_worker_id = $${params.length} - OR EXISTS ( - SELECT 1 - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - AND wos.worker_id = $${params.length} - AND wos.status != 'cancelled' - ) - )`) - } - if (workerLevelIds.length > 0) { - params.push(workerLevelIds) - filters.push(`( - EXISTS ( - SELECT 1 - FROM worker_users wu - WHERE wu.id = wo.assigned_worker_id - AND wu.level_id = ANY($${params.length}::bigint[]) - ) - OR EXISTS ( - SELECT 1 - FROM work_order_shares wos - INNER JOIN worker_users wu ON wu.id = wos.worker_id - WHERE wos.work_order_id = wo.id - AND wos.status != 'cancelled' - AND wu.level_id = ANY($${params.length}::bigint[]) - ) - )`) - } - if (categoryId) { - params.push(categoryId) - filters.push(`wo.category_id = $${params.length}`) - } - if (acceptanceMode) { - params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode)) - filters.push(`wo.acceptance_mode = $${params.length}`) - } - if (giftPhase) { - const normalizedGiftPhase = String(giftPhase).trim() - if (normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY) { - // gift_ready 是冷却到点后的实时阶段,由时间条件计算而不是落库状态。 - filters.push(`( - wo.acceptance_mode = 'friend_gift' - AND wo.gift_phase = 'friend_countdown' - AND wo.gift_available_at IS NOT NULL - AND wo.gift_available_at <= NOW() - )`) - } else if (Object.values(WORK_ORDER_GIFT_PHASE).includes(normalizedGiftPhase as never)) { - params.push(normalizedGiftPhase) - const giftPhaseFilter = `wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}` - // 待打手提交资料仅代表已接单后的赠送流程;待抢单/未分配模板单不应混入该筛选。 - filters.push( - normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED - ? `(${giftPhaseFilter} AND wo.assigned_worker_id IS NOT NULL AND wo.status IN ('in_progress', 'problem'))` - : giftPhaseFilter, - ) - } - } - if (hallCandidateLimit > 0) { - params.push(hallCandidateLimit) - const limitPlaceholder = `$${params.length}` - params.push(JSON.stringify(hallCategoryLimits)) - filters.push(`wo.id IN (${buildHallCandidateIdsSql(limitPlaceholder, `$${params.length}`)})`) - } - if (visibleAfterIso) { - params.push(visibleAfterIso) - filters.push(`(wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $${params.length})`) - } - if (excludeFilledSharing) { - filters.push(`( - wo.sharing_enabled IS NOT TRUE - OR COALESCE(filter_share_summary.active_share_quantity, 0) < wo.sharing_total_quantity - )`) - } - return { - joins: joins.join('\n'), - whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - -/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ -function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) { - return ` - SELECT ranked.id - FROM ( - SELECT candidate.id, candidate.category_id, candidate.hall_queued_at, - ROW_NUMBER() OVER ( - PARTITION BY candidate.category_id - ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC - ) AS category_rank - FROM work_orders candidate - LEFT JOIN ( - SELECT - work_order_id, - SUM(quantity) FILTER (WHERE status != 'cancelled') AS active_share_quantity - FROM work_order_shares - GROUP BY work_order_id - ) candidate_shares ON candidate_shares.work_order_id = candidate.id - WHERE candidate.status = 'open' - AND ( - candidate.sharing_enabled IS NOT TRUE - OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity - ) - ) ranked - WHERE ranked.category_rank <= COALESCE( - NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int, - ${limitPlaceholder} - ) - ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC - LIMIT ${limitPlaceholder} - ` -} - -/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */ -export async function isWorkOrderWithinHallCapacityWithClient( - client: PoolClient, - input: { - workOrderId: number - hallCandidateLimit: number - hallCategoryLimits?: Record - visibleAfterIso?: string | undefined - }, -) { - const result = await client.query<{ visible: boolean }>( - ` - SELECT EXISTS( - SELECT 1 - FROM work_orders wo - WHERE wo.id = $1 - AND wo.id IN (${buildHallCandidateIdsSql('$2', '$3')}) - AND ($4::timestamptz IS NULL OR wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $4) - ) AS visible - `, - [ - input.workOrderId, - input.hallCandidateLimit, - JSON.stringify(input.hallCategoryLimits || {}), - input.visibleAfterIso ? input.visibleAfterIso : null, - ], - ) - return result.rows[0]?.visible === true -} diff --git a/test-fix-artifacts/DIFF_FILE.patch b/test-fix-artifacts/DIFF_FILE.patch deleted file mode 100644 index e78b528c..00000000 --- a/test-fix-artifacts/DIFF_FILE.patch +++ /dev/null @@ -1,9 +0,0 @@ -Test fix diff summary - -1. worker-platform/work-order-query-repo.ts - - Trim keyword input inside buildWorkOrderWhere before adding LIKE filters. - - Whitespace-only keywords now add no filter and no SQL parameter. - -2. db/postgres.integration.test.ts - - Use a unique regular table for the pool-concurrency test. - - Drop the table in finally so concurrent pool connections can see it and cleanup is deterministic. diff --git a/test-fix-artifacts/MODIFIED_FILE.ts b/test-fix-artifacts/MODIFIED_FILE.ts deleted file mode 100644 index b84151c2..00000000 --- a/test-fix-artifacts/MODIFIED_FILE.ts +++ /dev/null @@ -1,961 +0,0 @@ -import { query } from '../../db/client.js' -import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js' -import type { PoolClient } from 'pg' -import { - normalizeWorkOrderAcceptanceMode, - WORK_ORDER_GIFT_PHASE, -} from '../../domain/work-order-acceptance-mode.js' -export const WORK_ORDER_SELECT = ` - SELECT - wo.*, - COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity, - COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count, - wc.name AS category_name, - wu.username AS worker_username, - wu.display_name AS worker_display_name, - cancel_info.cancel_reason, - cancel_info.cancel_event_type, - cancel_info.cancel_actor_type, - cancel_info.cancel_actor_id, - cancel_info.cancelled_at - FROM work_orders wo - LEFT JOIN work_categories wc ON wc.id = wo.category_id - LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id - LEFT JOIN LATERAL ( - SELECT - ev.event_type AS cancel_event_type, - ev.actor_type AS cancel_actor_type, - ev.actor_id AS cancel_actor_id, - ev.created_at AS cancelled_at, - BTRIM(COALESCE( - NULLIF(BTRIM(COALESCE(ev.payload_json->>'reason', '')), ''), - NULLIF(BTRIM(COALESCE(ev.payload_json->>'note', '')), ''), - CASE ev.event_type - WHEN 'timeout_cancel_release' THEN '任务超时未完成,系统自动取消并退还押金' - WHEN 'timeout_cancel_deduct' THEN '任务超时未完成,系统自动取消并扣除押金' - ELSE '' - END - )) AS cancel_reason - FROM work_order_events ev - WHERE ev.work_order_id = wo.id - AND wo.status = 'cancelled' - AND ev.event_type IN ( - 'cancelled_by_admin', - 'problem_cancel_release', - 'problem_cancel_deduct', - 'timeout_cancel_release', - 'timeout_cancel_deduct' - ) - ORDER BY ev.id DESC - LIMIT 1 - ) cancel_info ON TRUE - LEFT JOIN LATERAL ( - SELECT - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - ) share_summary ON TRUE -` - -// Keep the page and total in one filtered query. This avoids running the same -// joins and share aggregation twice for every list request. -const LIST_WORK_ORDER_SELECT = WORK_ORDER_SELECT.replace( - ' SELECT\n', - ' SELECT\n COUNT(*) OVER()::int AS total_count,\n', -) - -type WorkerOrderOverviewRow = { - status: string - order_count: number - settlement_amount: number - vip_evidence_pending_count: number - vip_evidence_pending_settlement_amount: number -} - -type WorkOrderStatisticsRow = { - status: string - total_count: number - user_payment_amount: number | string - reward_amount: number | string - refund_amount: number | string -} - -type TimeoutCountCacheEntry = { - expiresAt: number - counts: Map -} - -// 后台打手列表会在短时间内重复请求同一页;超时次数只是展示字段,允许短 TTL。 -const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000 -const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256 -const timeoutCountCache = new Map() - -/** 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 */ -function adminOperationalStatusExpression(alias = 'wo', shareSummaryAlias = '') { - if (shareSummaryAlias) { - return `CASE - WHEN ${alias}.status = 'open' - AND ${alias}.sharing_enabled IS TRUE - AND COALESCE(${shareSummaryAlias}.active_share_quantity, 0) > 0 - THEN CASE - WHEN COALESCE(${shareSummaryAlias}.active_share_quantity, 0) >= ${alias}.sharing_total_quantity - AND COALESCE(${shareSummaryAlias}.pending_share_count, 0) = 0 - THEN 'pending_acceptance' - ELSE 'in_progress' - END - ELSE ${alias}.status - END` - } - - return `CASE - WHEN ${alias}.status = 'open' - AND ${alias}.sharing_enabled IS TRUE - AND COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) > 0 - THEN CASE - WHEN COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) >= ${alias}.sharing_total_quantity - AND COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0) = 0 - THEN 'pending_acceptance' - ELSE 'in_progress' - END - ELSE ${alias}.status - END` -} - -/** 工单统一搜索字段,兼容当前资料结构和历史资料字段。 */ -function workOrderSearchExpression(alias = 'wo') { - return `LOWER( - COALESCE(${alias}.work_order_no, '') || ' ' || - COALESCE(${alias}.platform_order_id, '') || ' ' || - COALESCE(${alias}.product_name, '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameNickname', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameAccount', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'roleName', '') || ' ' || - COALESCE(${alias}.material_json->'fields'->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->'fields'->>'gameNickname', '') || ' ' || - COALESCE(${alias}.material_json->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->>'gameNickname', '') - )` -} - -/** - * 打手订单个人状态:拼单按个人份额状态计算。 - * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, - * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 - */ -export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { - const baseExpression = `CASE - WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status - ELSE COALESCE(CASE ${workerShareAlias}.status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo.status - END, wo.status) - END` - const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) - if (normalizedWorkerId <= 0) return baseExpression - const pendingHandlingExists = `( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = wo.id - AND wcr.worker_id = ${normalizedWorkerId} - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = wo.id - AND wf.worker_id = ${normalizedWorkerId} - AND wf.status = 'pending' - ) - )` - return `CASE - WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem' - ELSE ${baseExpression} - END` -} -export async function getWorkOrderByOrderItemId( - orderItemId: number | string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} WHERE wo.order_item_id = $1 LIMIT 1`, - [Number(orderItemId)], - ) - return result.rows[0] || null -} - -export async function getWorkOrderById(workOrderId: number | string): Promise { - const result = await query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ - Number(workOrderId), - ]) - return result.rows[0] || null -} - -export async function findPendingMaterialWorkOrderByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' - ORDER BY wo.id DESC - LIMIT 1`, - [String(platformOrderId || '').trim()], - ) - return result.rows[0] || null -} - -export async function listPendingMaterialWorkOrdersByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' - ORDER BY wo.id ASC`, - [String(platformOrderId || '').trim()], - ) - return result.rows -} - -export async function listWorkOrdersByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 - ORDER BY wo.id ASC`, - [String(platformOrderId || '').trim()], - ) - return result.rows -} - -export async function listWorkOrders({ - page = 1, - pageSize = 20, - statuses = [], - status = '', - workOrderId = 0, - keyword = '', - workerId = 0, - workerLevelIds = [], - categoryId = 0, - workerSharingId = 0, - hallCandidateLimit = 0, - hallCategoryLimits = {}, - visibleAfterIso = '', - excludeFilledSharing = false, - vipEvidencePending = false, - acceptanceMode = '', - giftPhase = '', - useOperationalStatus = false, - pinnedFirst = false, - sort = 'id_desc', - createdFrom = '', - createdTo = '', -}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { - const effectiveStatuses = statuses.length > 0 ? statuses : status.trim() ? [status.trim()] : [] - const { joins, whereClause, params } = buildWorkOrderWhere({ - statuses: effectiveStatuses, - workOrderId, - keyword, - workerId, - workerLevelIds, - categoryId, - workerSharingId, - hallCandidateLimit, - hallCategoryLimits, - visibleAfterIso, - excludeFilledSharing, - vipEvidencePending, - acceptanceMode, - giftPhase, - useOperationalStatus, - createdFrom, - createdTo, - }) - const offset = (page - 1) * pageSize - const orderBy = - sort === 'worker_claimed_at_desc' && workerSharingId - ? `COALESCE( - CASE - WHEN wo.assigned_worker_id = $1 THEN wo.assigned_at - END, - CASE - WHEN worker_share.status != 'cancelled' THEN worker_share.created_at - END - ) DESC NULLS LAST, wo.id DESC` - : 'wo.id DESC' - const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy - params.push(pageSize, offset) - const itemsResult = await query( - `${LIST_WORK_ORDER_SELECT} - ${joins} - ${whereClause} - ORDER BY ${effectiveOrderBy} - LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, - ) - // A page beyond the end has no row from which to read the window count. - // Keep that uncommon pagination edge case correct without restoring a - // second query for normal requests. - let total = Number( - (itemsResult.rows[0] as WorkOrderRow & { total_count?: number })?.total_count || 0, - ) - if (itemsResult.rows.length === 0 && page > 1) { - const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM work_orders wo ${joins} ${whereClause}`, - params.slice(0, -2), - ) - total = Number(totalResult.rows[0]?.total || 0) - } - return { - items: itemsResult.rows, - total, - } -} - -/** 按筛选条件汇总工单,统计范围不受当前分页影响。 */ -export async function getWorkOrderStatistics(input: ListInput = {}): Promise { - const { joins, whereClause, params } = buildWorkOrderWhere(input) - const vipEvidencePendingWhere = ` - wo.status = 'accepted' - AND wo.acceptance_json->>'reviewMode' = 'vip_auto' - AND wo.acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ` - const operationalStatus = input.useOperationalStatus - ? adminOperationalStatusExpression('wo', 'operational_share_summary') - : 'wo.status' - const statusExpression = input.vipEvidencePending - ? `CASE WHEN ${vipEvidencePendingWhere} THEN 'vip_evidence_pending' ELSE ${operationalStatus} END` - : operationalStatus - const paymentExpression = ` - CASE - WHEN COALESCE(wo.material_json->'source'->>'orderAmountFen', '') ~ '^-?[0-9]+$' - THEN (wo.material_json->'source'->>'orderAmountFen')::bigint - ELSE COALESCE(o.total_amount, 0) - END - ` - const result = await query( - ` - SELECT - ${statusExpression} AS status, - COUNT(*)::int AS total_count, - COALESCE(SUM(${paymentExpression}), 0)::bigint AS user_payment_amount, - COALESCE(SUM(wo.reward_amount), 0)::bigint AS reward_amount, - COALESCE(SUM( - CASE - WHEN o.pay_status = 'refunded' OR o.order_status = 'refunded' - THEN ${paymentExpression} - ELSE 0 - END - ), 0)::bigint AS refund_amount - FROM work_orders wo - LEFT JOIN orders o ON o.id = wo.order_id - ${joins} - ${whereClause} - GROUP BY ${statusExpression} - `, - params, - ) - const statistics: WorkOrderStatistics = { - totalCount: 0, - userPaymentAmount: 0, - rewardAmount: 0, - refundAmount: 0, - statusCounts: {}, - } - for (const row of result.rows) { - const count = Number(row.total_count || 0) - statistics.totalCount += count - statistics.userPaymentAmount += Number(row.user_payment_amount || 0) - statistics.rewardAmount += Number(row.reward_amount || 0) - statistics.refundAmount += Number(row.refund_amount || 0) - statistics.statusCounts[row.status] = count - } - return statistics -} - -/** 汇总打手本人各订单状态的可结算金额,拼单按个人份额计算。 */ -export async function getWorkerOrderOverview(workerId: number | string): Promise< - Array<{ - status: string - orderCount: number - settlementAmount: number - vipEvidencePendingCount: number - vipEvidencePendingSettlementAmount: number - }> -> { - const normalizedWorkerId = Number(workerId) - const result = await query( - ` - WITH visible_orders AS ( - SELECT wo.id AS work_order_id - FROM work_orders wo - WHERE wo.assigned_worker_id = $1 - UNION - SELECT wo.id AS work_order_id - FROM work_orders wo - WHERE wo.last_assigned_worker_id = $1 - AND ( - wo.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = wo.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) - UNION - SELECT wos.work_order_id - FROM work_order_shares wos - WHERE wos.worker_id = $1 - AND wos.status != 'cancelled' - ), - worker_base AS ( - SELECT - wo.id, - wo.status AS wo_status, - wos.status AS wos_status, - wos.id IS NOT NULL AS is_worker_share, - CASE - WHEN wos.id IS NOT NULL THEN wos.share_reward - WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount - ELSE 0 - END AS worker_settlement_amount, - wo.acceptance_json - FROM work_orders wo - INNER JOIN visible_orders vo ON vo.work_order_id = wo.id - LEFT JOIN work_order_shares wos - ON wos.work_order_id = wo.id - AND wos.worker_id = $1 - AND wos.status != 'cancelled' - ), - worker_orders AS ( - SELECT - CASE - WHEN ( - CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - ) = 'in_progress' AND ( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = worker_base.id - AND wcr.worker_id = $1 - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = worker_base.id - AND wf.worker_id = $1 - AND wf.status = 'pending' - ) - ) THEN 'problem' - ELSE CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - END AS status, - acceptance_json, - is_worker_share, - worker_settlement_amount - FROM worker_base - ) - SELECT - status, - COUNT(*)::int AS order_count, - COALESCE( - SUM( - CASE - WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') - OR (status = 'open' AND is_worker_share) - THEN worker_settlement_amount - ELSE 0 - END - ), - 0 - )::int AS settlement_amount, - COUNT(*) FILTER ( - WHERE status = 'accepted' - AND acceptance_json->>'reviewMode' = 'vip_auto' - AND acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - )::int AS vip_evidence_pending_count, - COALESCE( - SUM(worker_settlement_amount) FILTER ( - WHERE status = 'accepted' - AND acceptance_json->>'reviewMode' = 'vip_auto' - AND acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ), - 0 - )::int AS vip_evidence_pending_settlement_amount - FROM worker_orders - GROUP BY status - `, - [normalizedWorkerId], - ) - return result.rows.map((row) => ({ - status: row.status, - orderCount: Number(row.order_count || 0), - settlementAmount: Number(row.settlement_amount || 0), - vipEvidencePendingCount: Number(row.vip_evidence_pending_count || 0), - vipEvidencePendingSettlementAmount: Number(row.vip_evidence_pending_settlement_amount || 0), - })) -} - -/** 查询已超过接单时限、仍待处置的工单。好友赠送单在“待确认好友”阶段等待后台操作,不计打手超时。 */ -export async function listOverdueWorkOrders({ - limit = 50, - workerId = 0, -}: { limit?: number; workerId?: number } = {}): Promise { - const params: unknown[] = [] - let workerClause = '' - if (workerId > 0) { - params.push(workerId) - workerClause = `AND wo.assigned_worker_id = $${params.length}` - } - params.push(limit) - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.status = 'in_progress' - AND wo.deadline_at IS NOT NULL - AND wo.deadline_at < NOW() - AND NOT (wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = 'material_submitted') - ${workerClause} - ORDER BY wo.deadline_at ASC - LIMIT $${params.length}`, - params, - ) - return result.rows -} - -export async function countWorkerActiveOrders(workerId: number | string): Promise { - const result = await query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int - FROM work_orders - WHERE assigned_worker_id = $1 - AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 - AND wos.status = 'joined' - AND wo.status = 'open' - ) AS total - `, - [Number(workerId)], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function countWorkerActiveOrdersWithClient( - client: PoolClient, - workerId: number, -): Promise { - const result = await client.query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int FROM work_orders - WHERE assigned_worker_id = $1 AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 AND wos.status = 'joined' AND wo.status = 'open' - ) AS total - `, - [workerId], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function countTimeoutEventsByWorkerIds( - workerIds: number[], -): Promise> { - const uniqueIds = [...new Set(workerIds.map((id) => Number(id)).filter((id) => id > 0))] - if (uniqueIds.length === 0) return new Map() - uniqueIds.sort((left, right) => left - right) - const cacheKey = uniqueIds.join(',') - const now = Date.now() - const cached = timeoutCountCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return new Map(cached.counts) - } - - const counts = new Map() - const result = await query<{ worker_id: string; total: number }>( - ` - SELECT payload_json->>'workerId' AS worker_id, COUNT(*)::int AS total - FROM work_order_events - WHERE event_type LIKE 'timeout_%' - AND payload_json->>'workerId' IS NOT NULL - AND payload_json->>'workerId' != '' - AND payload_json->>'workerId' = ANY($1::text[]) - GROUP BY payload_json->>'workerId' - `, - [uniqueIds.map(String)], - ) - for (const row of result.rows) { - const workerId = Number(row.worker_id) - if (Number.isFinite(workerId) && workerId > 0) { - counts.set(workerId, Number(row.total || 0)) - } - } - if (timeoutCountCache.size >= TIMEOUT_COUNT_CACHE_MAX_ENTRIES) { - const oldestKey = timeoutCountCache.keys().next().value - if (oldestKey) timeoutCountCache.delete(oldestKey) - } - timeoutCountCache.set(cacheKey, { - expiresAt: now + TIMEOUT_COUNT_CACHE_TTL_MS, - counts: new Map(counts), - }) - return counts -} - -export async function countWorkerTimeoutEvents(workerId: number | string): Promise { - const counts = await countTimeoutEventsByWorkerIds([Number(workerId)]) - return counts.get(Number(workerId)) || 0 -} - -export async function countWorkerCancellationsSince( - workerId: number | string, - sinceIso: string, -): Promise { - const result = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM work_order_events - WHERE actor_type = 'worker' - AND actor_id = $1 - AND event_type = 'cancelled_by_worker' - AND created_at >= $2 - `, - [String(workerId), sinceIso], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function getWorkOrderByIdWithClient( - client: PoolClient, - workOrderId: number | string, -): Promise { - const result = await client.query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ - Number(workOrderId), - ]) - return result.rows[0] || null -} - -export function buildWorkOrderWhere({ - statuses = [], - workOrderId = 0, - keyword = '', - workerId = 0, - workerLevelIds = [], - categoryId = 0, - workerSharingId = 0, - hallCandidateLimit = 0, - hallCategoryLimits = {}, - visibleAfterIso = '', - excludeFilledSharing = false, - vipEvidencePending = false, - acceptanceMode = '', - giftPhase = '', - useOperationalStatus = false, - createdFrom = '', - createdTo = '', -}: ListInput) { - const filters: string[] = [] - const params: unknown[] = [] - const joins: string[] = [] - const normalizedKeyword = String(keyword || '').trim() - if (createdFrom) { - params.push(createdFrom) - filters.push( - `wo.created_at >= ($${params.length}::date)::timestamp AT TIME ZONE 'Asia/Shanghai'`, - ) - } - if (createdTo) { - params.push(createdTo) - filters.push( - `wo.created_at < (($${params.length}::date + 1)::timestamp AT TIME ZONE 'Asia/Shanghai')`, - ) - } - let workerSharePlaceholder = '' - - if (excludeFilledSharing) { - joins.push(` - LEFT JOIN ( - SELECT - wos.work_order_id, - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity - FROM work_order_shares wos - GROUP BY wos.work_order_id - ) filter_share_summary ON filter_share_summary.work_order_id = wo.id`) - } - - // 先从打手的整单、历史单和拼单记录组成可见工单集合,再关联工单详情。 - // 这样 COUNT 和列表都不会先扫描全量工单,再逐行检查该打手的拼单状态。 - if (workerSharingId) { - params.push(workerSharingId) - workerSharePlaceholder = `$${params.length}` - joins.push(` - INNER JOIN ( - SELECT assigned.id AS work_order_id - FROM work_orders assigned - WHERE assigned.assigned_worker_id = ${workerSharePlaceholder} - UNION - SELECT historical.id AS work_order_id - FROM work_orders historical - WHERE historical.last_assigned_worker_id = ${workerSharePlaceholder} - AND ( - historical.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = historical.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) - UNION - SELECT share_source.work_order_id - FROM work_order_shares share_source - WHERE share_source.worker_id = ${workerSharePlaceholder} - AND share_source.status != 'cancelled' - ) worker_visible ON worker_visible.work_order_id = wo.id - LEFT JOIN work_order_shares worker_share - ON worker_share.work_order_id = wo.id - AND worker_share.worker_id = ${workerSharePlaceholder}`) - } - - if (useOperationalStatus) { - if (workerSharingId) { - // 已限定打手时只汇总其候选工单,利用 work_order_id 索引避免扫描整张拼单表。 - joins.push(` - LEFT JOIN LATERAL ( - SELECT - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - ) operational_share_summary ON TRUE`) - } else { - // 无打手范围时一次聚合全部拼单,避免对每笔工单重复执行关联子查询。 - joins.push(` - LEFT JOIN ( - SELECT - wos.work_order_id, - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - GROUP BY wos.work_order_id - ) operational_share_summary ON operational_share_summary.work_order_id = wo.id`) - } - } - const normalizedStatuses = [ - ...new Set(statuses.map((item) => String(item || '').trim()).filter(Boolean)), - ] - if (vipEvidencePending) { - const vipEvidencePendingWhere = ` - wo.status = 'accepted' - AND wo.acceptance_json->>'reviewMode' = 'vip_auto' - AND wo.acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ` - if (normalizedStatuses.length > 0) { - params.push(normalizedStatuses) - filters.push( - `(${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`, - ) - } else { - filters.push(`(${vipEvidencePendingWhere})`) - } - } else if (normalizedStatuses.length > 0) { - params.push(normalizedStatuses) - const statusesPlaceholder = `$${params.length}` - if (workerSharingId) { - filters.push( - `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`, - ) - } else { - filters.push( - `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, - ) - } - } - if (workOrderId) { - params.push(workOrderId) - filters.push(`wo.id = $${params.length}`) - } - if (normalizedKeyword) { - params.push(`%${normalizedKeyword}%`) - filters.push(`${workOrderSearchExpression()} LIKE LOWER($${params.length})`) - } - if (workerId) { - params.push(workerId) - filters.push(`( - wo.assigned_worker_id = $${params.length} - OR EXISTS ( - SELECT 1 - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - AND wos.worker_id = $${params.length} - AND wos.status != 'cancelled' - ) - )`) - } - if (workerLevelIds.length > 0) { - params.push(workerLevelIds) - filters.push(`( - EXISTS ( - SELECT 1 - FROM worker_users wu - WHERE wu.id = wo.assigned_worker_id - AND wu.level_id = ANY($${params.length}::bigint[]) - ) - OR EXISTS ( - SELECT 1 - FROM work_order_shares wos - INNER JOIN worker_users wu ON wu.id = wos.worker_id - WHERE wos.work_order_id = wo.id - AND wos.status != 'cancelled' - AND wu.level_id = ANY($${params.length}::bigint[]) - ) - )`) - } - if (categoryId) { - params.push(categoryId) - filters.push(`wo.category_id = $${params.length}`) - } - if (acceptanceMode) { - params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode)) - filters.push(`wo.acceptance_mode = $${params.length}`) - } - if (giftPhase) { - const normalizedGiftPhase = String(giftPhase).trim() - if (normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY) { - // gift_ready 是冷却到点后的实时阶段,由时间条件计算而不是落库状态。 - filters.push(`( - wo.acceptance_mode = 'friend_gift' - AND wo.gift_phase = 'friend_countdown' - AND wo.gift_available_at IS NOT NULL - AND wo.gift_available_at <= NOW() - )`) - } else if (Object.values(WORK_ORDER_GIFT_PHASE).includes(normalizedGiftPhase as never)) { - params.push(normalizedGiftPhase) - const giftPhaseFilter = `wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}` - // 待打手提交资料仅代表已接单后的赠送流程;待抢单/未分配模板单不应混入该筛选。 - filters.push( - normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED - ? `(${giftPhaseFilter} AND wo.assigned_worker_id IS NOT NULL AND wo.status IN ('in_progress', 'problem'))` - : giftPhaseFilter, - ) - } - } - if (hallCandidateLimit > 0) { - params.push(hallCandidateLimit) - const limitPlaceholder = `$${params.length}` - params.push(JSON.stringify(hallCategoryLimits)) - filters.push(`wo.id IN (${buildHallCandidateIdsSql(limitPlaceholder, `$${params.length}`)})`) - } - if (visibleAfterIso) { - params.push(visibleAfterIso) - filters.push(`(wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $${params.length})`) - } - if (excludeFilledSharing) { - filters.push(`( - wo.sharing_enabled IS NOT TRUE - OR COALESCE(filter_share_summary.active_share_quantity, 0) < wo.sharing_total_quantity - )`) - } - return { - joins: joins.join('\n'), - whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - -/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ -function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) { - return ` - SELECT ranked.id - FROM ( - SELECT candidate.id, candidate.category_id, candidate.hall_queued_at, - ROW_NUMBER() OVER ( - PARTITION BY candidate.category_id - ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC - ) AS category_rank - FROM work_orders candidate - LEFT JOIN ( - SELECT - work_order_id, - SUM(quantity) FILTER (WHERE status != 'cancelled') AS active_share_quantity - FROM work_order_shares - GROUP BY work_order_id - ) candidate_shares ON candidate_shares.work_order_id = candidate.id - WHERE candidate.status = 'open' - AND ( - candidate.sharing_enabled IS NOT TRUE - OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity - ) - ) ranked - WHERE ranked.category_rank <= COALESCE( - NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int, - ${limitPlaceholder} - ) - ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC - LIMIT ${limitPlaceholder} - ` -} - -/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */ -export async function isWorkOrderWithinHallCapacityWithClient( - client: PoolClient, - input: { - workOrderId: number - hallCandidateLimit: number - hallCategoryLimits?: Record - visibleAfterIso?: string | undefined - }, -) { - const result = await client.query<{ visible: boolean }>( - ` - SELECT EXISTS( - SELECT 1 - FROM work_orders wo - WHERE wo.id = $1 - AND wo.id IN (${buildHallCandidateIdsSql('$2', '$3')}) - AND ($4::timestamptz IS NULL OR wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $4) - ) AS visible - `, - [ - input.workOrderId, - input.hallCandidateLimit, - JSON.stringify(input.hallCategoryLimits || {}), - input.visibleAfterIso ? input.visibleAfterIso : null, - ], - ) - return result.rows[0]?.visible === true -} diff --git a/test-fix-artifacts/ROLLBACK.sh b/test-fix-artifacts/ROLLBACK.sh deleted file mode 100755 index ef84396f..00000000 --- a/test-fix-artifacts/ROLLBACK.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="${1:?usage: ROLLBACK.sh }" -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" - -cp "$SCRIPT_DIR/BASELINE_worker_query.ts" "$ROOT/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts" -cp "$SCRIPT_DIR/BASELINE_postgres_integration.test.ts" "$ROOT/apps/backend/src/db/postgres.integration.test.ts" -printf 'restored worker query and PostgreSQL integration test baselines in %s\n' "$ROOT" diff --git a/test-fix-artifacts/VERIFICATION.txt b/test-fix-artifacts/VERIFICATION.txt deleted file mode 100644 index c5aa2ab8..00000000 --- a/test-fix-artifacts/VERIFICATION.txt +++ /dev/null @@ -1,27 +0,0 @@ -Test failure fix verification - -Changed branch/fields: -- buildWorkOrderWhere keyword normalization: whitespace-only input no longer emits LIKE SQL. -- PostgreSQL concurrency test table scope: regular unique table visible to all pool connections, dropped in finally. - -Artifacts: -- MODIFIED_FILE: /Users/yml/codes/order_site/test-fix-artifacts/MODIFIED_FILE.ts -- DIFF_FILE: /Users/yml/codes/order_site/test-fix-artifacts/DIFF_FILE.patch -- VERIFICATION.txt: /Users/yml/codes/order_site/test-fix-artifacts/VERIFICATION.txt -- ROLLBACK.sh: /Users/yml/codes/order_site/test-fix-artifacts/ROLLBACK.sh - -BASELINE -Command: git show HEAD:apps/backend/src/repositories/worker-platform/work-order-query-repo.ts -Input: HEAD source before this fix -Literal result: SHA-256 ff715033b3939b18788dab93e13fa0b3eaf054e469eb721c1b8fa7611554b13e; exit status 0 - -MODIFIED -Command: TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site npm run check -Input: modified worker query and PostgreSQL integration test sources -Literal result: format passed; SQL guard passed; lint passed; typecheck passed; tests 377; pass 377; fail 0; skipped 0; build completed; exit status 0 - -ROLLBACK -Command: test-fix-artifacts/ROLLBACK.sh test-fix-artifacts/rollback-copy -Input: independent copy containing MODIFIED_FILE.ts and modified PostgreSQL integration test -Literal result: restored worker query and PostgreSQL integration test baselines; restored worker query SHA-256 ff715033b3939b18788dab93e13fa0b3eaf054e469eb721c1b8fa7611554b13e; exit status 0 -Restored behavior/status: rollback-copy worker query matches baseline; working source remains fixed. diff --git a/test-fix-artifacts/rollback-copy/apps/backend/src/db/postgres.integration.test.ts b/test-fix-artifacts/rollback-copy/apps/backend/src/db/postgres.integration.test.ts deleted file mode 100644 index c9d28eeb..00000000 --- a/test-fix-artifacts/rollback-copy/apps/backend/src/db/postgres.integration.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { Pool } from 'pg' - -const connectionString = process.env.TEST_POSTGRES_URL - -test('real PostgreSQL transaction rolls back atomically', { skip: !connectionString }, async () => { - const pool = new Pool({ connectionString }) - const client = await pool.connect() - const tableName = `integration_tx_${Date.now()}` - try { - await client.query(`CREATE TEMP TABLE ${tableName} (id integer primary key, value text)`) - await client.query('BEGIN') - await client.query(`INSERT INTO ${tableName} (id, value) VALUES (1, 'before-error')`) - try { - await client.query(`INSERT INTO ${tableName} (id, value) VALUES (1, 'duplicate')`) - } catch { - await client.query('ROLLBACK') - } - const result = await client.query(`SELECT COUNT(*)::int AS count FROM ${tableName}`) - assert.equal(result.rows[0].count, 0) - } finally { - client.release() - await pool.end() - } -}) - -test( - 'real PostgreSQL enforces callback replay idempotency under concurrency', - { - skip: !connectionString, - }, - async () => { - const pool = new Pool({ connectionString }) - const tableName = `integration_webhook_${Date.now()}` - try { - await pool.query( - `CREATE TEMP TABLE ${tableName} ( - id serial primary key, - provider text not null, - event_id text not null, - unique(provider, event_id) - )`, - ) - await Promise.all( - Array.from({ length: 8 }, () => - pool.query( - `INSERT INTO ${tableName} (provider, event_id) - VALUES ('integration', 'replay-1') - ON CONFLICT (provider, event_id) DO NOTHING`, - ), - ), - ) - const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${tableName}`) - assert.equal(result.rows[0].count, 1) - } finally { - await pool.end() - } - }, -) diff --git a/test-fix-artifacts/rollback-copy/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/test-fix-artifacts/rollback-copy/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts deleted file mode 100644 index 15a19d68..00000000 --- a/test-fix-artifacts/rollback-copy/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ /dev/null @@ -1,960 +0,0 @@ -import { query } from '../../db/client.js' -import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js' -import type { PoolClient } from 'pg' -import { - normalizeWorkOrderAcceptanceMode, - WORK_ORDER_GIFT_PHASE, -} from '../../domain/work-order-acceptance-mode.js' -export const WORK_ORDER_SELECT = ` - SELECT - wo.*, - COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity, - COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count, - wc.name AS category_name, - wu.username AS worker_username, - wu.display_name AS worker_display_name, - cancel_info.cancel_reason, - cancel_info.cancel_event_type, - cancel_info.cancel_actor_type, - cancel_info.cancel_actor_id, - cancel_info.cancelled_at - FROM work_orders wo - LEFT JOIN work_categories wc ON wc.id = wo.category_id - LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id - LEFT JOIN LATERAL ( - SELECT - ev.event_type AS cancel_event_type, - ev.actor_type AS cancel_actor_type, - ev.actor_id AS cancel_actor_id, - ev.created_at AS cancelled_at, - BTRIM(COALESCE( - NULLIF(BTRIM(COALESCE(ev.payload_json->>'reason', '')), ''), - NULLIF(BTRIM(COALESCE(ev.payload_json->>'note', '')), ''), - CASE ev.event_type - WHEN 'timeout_cancel_release' THEN '任务超时未完成,系统自动取消并退还押金' - WHEN 'timeout_cancel_deduct' THEN '任务超时未完成,系统自动取消并扣除押金' - ELSE '' - END - )) AS cancel_reason - FROM work_order_events ev - WHERE ev.work_order_id = wo.id - AND wo.status = 'cancelled' - AND ev.event_type IN ( - 'cancelled_by_admin', - 'problem_cancel_release', - 'problem_cancel_deduct', - 'timeout_cancel_release', - 'timeout_cancel_deduct' - ) - ORDER BY ev.id DESC - LIMIT 1 - ) cancel_info ON TRUE - LEFT JOIN LATERAL ( - SELECT - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - ) share_summary ON TRUE -` - -// Keep the page and total in one filtered query. This avoids running the same -// joins and share aggregation twice for every list request. -const LIST_WORK_ORDER_SELECT = WORK_ORDER_SELECT.replace( - ' SELECT\n', - ' SELECT\n COUNT(*) OVER()::int AS total_count,\n', -) - -type WorkerOrderOverviewRow = { - status: string - order_count: number - settlement_amount: number - vip_evidence_pending_count: number - vip_evidence_pending_settlement_amount: number -} - -type WorkOrderStatisticsRow = { - status: string - total_count: number - user_payment_amount: number | string - reward_amount: number | string - refund_amount: number | string -} - -type TimeoutCountCacheEntry = { - expiresAt: number - counts: Map -} - -// 后台打手列表会在短时间内重复请求同一页;超时次数只是展示字段,允许短 TTL。 -const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000 -const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256 -const timeoutCountCache = new Map() - -/** 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 */ -function adminOperationalStatusExpression(alias = 'wo', shareSummaryAlias = '') { - if (shareSummaryAlias) { - return `CASE - WHEN ${alias}.status = 'open' - AND ${alias}.sharing_enabled IS TRUE - AND COALESCE(${shareSummaryAlias}.active_share_quantity, 0) > 0 - THEN CASE - WHEN COALESCE(${shareSummaryAlias}.active_share_quantity, 0) >= ${alias}.sharing_total_quantity - AND COALESCE(${shareSummaryAlias}.pending_share_count, 0) = 0 - THEN 'pending_acceptance' - ELSE 'in_progress' - END - ELSE ${alias}.status - END` - } - - return `CASE - WHEN ${alias}.status = 'open' - AND ${alias}.sharing_enabled IS TRUE - AND COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) > 0 - THEN CASE - WHEN COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) >= ${alias}.sharing_total_quantity - AND COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0) = 0 - THEN 'pending_acceptance' - ELSE 'in_progress' - END - ELSE ${alias}.status - END` -} - -/** 工单统一搜索字段,兼容当前资料结构和历史资料字段。 */ -function workOrderSearchExpression(alias = 'wo') { - return `LOWER( - COALESCE(${alias}.work_order_no, '') || ' ' || - COALESCE(${alias}.platform_order_id, '') || ' ' || - COALESCE(${alias}.product_name, '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameNickname', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'gameAccount', '') || ' ' || - COALESCE(${alias}.material_json->'collect'->'fields'->>'roleName', '') || ' ' || - COALESCE(${alias}.material_json->'fields'->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->'fields'->>'gameNickname', '') || ' ' || - COALESCE(${alias}.material_json->>'gameId', '') || ' ' || - COALESCE(${alias}.material_json->>'gameNickname', '') - )` -} - -/** - * 打手订单个人状态:拼单按个人份额状态计算。 - * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, - * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 - */ -export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { - const baseExpression = `CASE - WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status - ELSE COALESCE(CASE ${workerShareAlias}.status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo.status - END, wo.status) - END` - const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) - if (normalizedWorkerId <= 0) return baseExpression - const pendingHandlingExists = `( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = wo.id - AND wcr.worker_id = ${normalizedWorkerId} - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = wo.id - AND wf.worker_id = ${normalizedWorkerId} - AND wf.status = 'pending' - ) - )` - return `CASE - WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem' - ELSE ${baseExpression} - END` -} -export async function getWorkOrderByOrderItemId( - orderItemId: number | string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} WHERE wo.order_item_id = $1 LIMIT 1`, - [Number(orderItemId)], - ) - return result.rows[0] || null -} - -export async function getWorkOrderById(workOrderId: number | string): Promise { - const result = await query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ - Number(workOrderId), - ]) - return result.rows[0] || null -} - -export async function findPendingMaterialWorkOrderByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' - ORDER BY wo.id DESC - LIMIT 1`, - [String(platformOrderId || '').trim()], - ) - return result.rows[0] || null -} - -export async function listPendingMaterialWorkOrdersByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material' - ORDER BY wo.id ASC`, - [String(platformOrderId || '').trim()], - ) - return result.rows -} - -export async function listWorkOrdersByPlatformOrderId( - platformOrderId: string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.platform_order_id = $1 - ORDER BY wo.id ASC`, - [String(platformOrderId || '').trim()], - ) - return result.rows -} - -export async function listWorkOrders({ - page = 1, - pageSize = 20, - statuses = [], - status = '', - workOrderId = 0, - keyword = '', - workerId = 0, - workerLevelIds = [], - categoryId = 0, - workerSharingId = 0, - hallCandidateLimit = 0, - hallCategoryLimits = {}, - visibleAfterIso = '', - excludeFilledSharing = false, - vipEvidencePending = false, - acceptanceMode = '', - giftPhase = '', - useOperationalStatus = false, - pinnedFirst = false, - sort = 'id_desc', - createdFrom = '', - createdTo = '', -}: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { - const effectiveStatuses = statuses.length > 0 ? statuses : status.trim() ? [status.trim()] : [] - const { joins, whereClause, params } = buildWorkOrderWhere({ - statuses: effectiveStatuses, - workOrderId, - keyword, - workerId, - workerLevelIds, - categoryId, - workerSharingId, - hallCandidateLimit, - hallCategoryLimits, - visibleAfterIso, - excludeFilledSharing, - vipEvidencePending, - acceptanceMode, - giftPhase, - useOperationalStatus, - createdFrom, - createdTo, - }) - const offset = (page - 1) * pageSize - const orderBy = - sort === 'worker_claimed_at_desc' && workerSharingId - ? `COALESCE( - CASE - WHEN wo.assigned_worker_id = $1 THEN wo.assigned_at - END, - CASE - WHEN worker_share.status != 'cancelled' THEN worker_share.created_at - END - ) DESC NULLS LAST, wo.id DESC` - : 'wo.id DESC' - const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy - params.push(pageSize, offset) - const itemsResult = await query( - `${LIST_WORK_ORDER_SELECT} - ${joins} - ${whereClause} - ORDER BY ${effectiveOrderBy} - LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, - ) - // A page beyond the end has no row from which to read the window count. - // Keep that uncommon pagination edge case correct without restoring a - // second query for normal requests. - let total = Number( - (itemsResult.rows[0] as WorkOrderRow & { total_count?: number })?.total_count || 0, - ) - if (itemsResult.rows.length === 0 && page > 1) { - const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM work_orders wo ${joins} ${whereClause}`, - params.slice(0, -2), - ) - total = Number(totalResult.rows[0]?.total || 0) - } - return { - items: itemsResult.rows, - total, - } -} - -/** 按筛选条件汇总工单,统计范围不受当前分页影响。 */ -export async function getWorkOrderStatistics(input: ListInput = {}): Promise { - const { joins, whereClause, params } = buildWorkOrderWhere(input) - const vipEvidencePendingWhere = ` - wo.status = 'accepted' - AND wo.acceptance_json->>'reviewMode' = 'vip_auto' - AND wo.acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ` - const operationalStatus = input.useOperationalStatus - ? adminOperationalStatusExpression('wo', 'operational_share_summary') - : 'wo.status' - const statusExpression = input.vipEvidencePending - ? `CASE WHEN ${vipEvidencePendingWhere} THEN 'vip_evidence_pending' ELSE ${operationalStatus} END` - : operationalStatus - const paymentExpression = ` - CASE - WHEN COALESCE(wo.material_json->'source'->>'orderAmountFen', '') ~ '^-?[0-9]+$' - THEN (wo.material_json->'source'->>'orderAmountFen')::bigint - ELSE COALESCE(o.total_amount, 0) - END - ` - const result = await query( - ` - SELECT - ${statusExpression} AS status, - COUNT(*)::int AS total_count, - COALESCE(SUM(${paymentExpression}), 0)::bigint AS user_payment_amount, - COALESCE(SUM(wo.reward_amount), 0)::bigint AS reward_amount, - COALESCE(SUM( - CASE - WHEN o.pay_status = 'refunded' OR o.order_status = 'refunded' - THEN ${paymentExpression} - ELSE 0 - END - ), 0)::bigint AS refund_amount - FROM work_orders wo - LEFT JOIN orders o ON o.id = wo.order_id - ${joins} - ${whereClause} - GROUP BY ${statusExpression} - `, - params, - ) - const statistics: WorkOrderStatistics = { - totalCount: 0, - userPaymentAmount: 0, - rewardAmount: 0, - refundAmount: 0, - statusCounts: {}, - } - for (const row of result.rows) { - const count = Number(row.total_count || 0) - statistics.totalCount += count - statistics.userPaymentAmount += Number(row.user_payment_amount || 0) - statistics.rewardAmount += Number(row.reward_amount || 0) - statistics.refundAmount += Number(row.refund_amount || 0) - statistics.statusCounts[row.status] = count - } - return statistics -} - -/** 汇总打手本人各订单状态的可结算金额,拼单按个人份额计算。 */ -export async function getWorkerOrderOverview(workerId: number | string): Promise< - Array<{ - status: string - orderCount: number - settlementAmount: number - vipEvidencePendingCount: number - vipEvidencePendingSettlementAmount: number - }> -> { - const normalizedWorkerId = Number(workerId) - const result = await query( - ` - WITH visible_orders AS ( - SELECT wo.id AS work_order_id - FROM work_orders wo - WHERE wo.assigned_worker_id = $1 - UNION - SELECT wo.id AS work_order_id - FROM work_orders wo - WHERE wo.last_assigned_worker_id = $1 - AND ( - wo.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = wo.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) - UNION - SELECT wos.work_order_id - FROM work_order_shares wos - WHERE wos.worker_id = $1 - AND wos.status != 'cancelled' - ), - worker_base AS ( - SELECT - wo.id, - wo.status AS wo_status, - wos.status AS wos_status, - wos.id IS NOT NULL AS is_worker_share, - CASE - WHEN wos.id IS NOT NULL THEN wos.share_reward - WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount - ELSE 0 - END AS worker_settlement_amount, - wo.acceptance_json - FROM work_orders wo - INNER JOIN visible_orders vo ON vo.work_order_id = wo.id - LEFT JOIN work_order_shares wos - ON wos.work_order_id = wo.id - AND wos.worker_id = $1 - AND wos.status != 'cancelled' - ), - worker_orders AS ( - SELECT - CASE - WHEN ( - CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - ) = 'in_progress' AND ( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = worker_base.id - AND wcr.worker_id = $1 - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = worker_base.id - AND wf.worker_id = $1 - AND wf.status = 'pending' - ) - ) THEN 'problem' - ELSE CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - END AS status, - acceptance_json, - is_worker_share, - worker_settlement_amount - FROM worker_base - ) - SELECT - status, - COUNT(*)::int AS order_count, - COALESCE( - SUM( - CASE - WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') - OR (status = 'open' AND is_worker_share) - THEN worker_settlement_amount - ELSE 0 - END - ), - 0 - )::int AS settlement_amount, - COUNT(*) FILTER ( - WHERE status = 'accepted' - AND acceptance_json->>'reviewMode' = 'vip_auto' - AND acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - )::int AS vip_evidence_pending_count, - COALESCE( - SUM(worker_settlement_amount) FILTER ( - WHERE status = 'accepted' - AND acceptance_json->>'reviewMode' = 'vip_auto' - AND acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ), - 0 - )::int AS vip_evidence_pending_settlement_amount - FROM worker_orders - GROUP BY status - `, - [normalizedWorkerId], - ) - return result.rows.map((row) => ({ - status: row.status, - orderCount: Number(row.order_count || 0), - settlementAmount: Number(row.settlement_amount || 0), - vipEvidencePendingCount: Number(row.vip_evidence_pending_count || 0), - vipEvidencePendingSettlementAmount: Number(row.vip_evidence_pending_settlement_amount || 0), - })) -} - -/** 查询已超过接单时限、仍待处置的工单。好友赠送单在“待确认好友”阶段等待后台操作,不计打手超时。 */ -export async function listOverdueWorkOrders({ - limit = 50, - workerId = 0, -}: { limit?: number; workerId?: number } = {}): Promise { - const params: unknown[] = [] - let workerClause = '' - if (workerId > 0) { - params.push(workerId) - workerClause = `AND wo.assigned_worker_id = $${params.length}` - } - params.push(limit) - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.status = 'in_progress' - AND wo.deadline_at IS NOT NULL - AND wo.deadline_at < NOW() - AND NOT (wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = 'material_submitted') - ${workerClause} - ORDER BY wo.deadline_at ASC - LIMIT $${params.length}`, - params, - ) - return result.rows -} - -export async function countWorkerActiveOrders(workerId: number | string): Promise { - const result = await query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int - FROM work_orders - WHERE assigned_worker_id = $1 - AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 - AND wos.status = 'joined' - AND wo.status = 'open' - ) AS total - `, - [Number(workerId)], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function countWorkerActiveOrdersWithClient( - client: PoolClient, - workerId: number, -): Promise { - const result = await client.query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int FROM work_orders - WHERE assigned_worker_id = $1 AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 AND wos.status = 'joined' AND wo.status = 'open' - ) AS total - `, - [workerId], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function countTimeoutEventsByWorkerIds( - workerIds: number[], -): Promise> { - const uniqueIds = [...new Set(workerIds.map((id) => Number(id)).filter((id) => id > 0))] - if (uniqueIds.length === 0) return new Map() - uniqueIds.sort((left, right) => left - right) - const cacheKey = uniqueIds.join(',') - const now = Date.now() - const cached = timeoutCountCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return new Map(cached.counts) - } - - const counts = new Map() - const result = await query<{ worker_id: string; total: number }>( - ` - SELECT payload_json->>'workerId' AS worker_id, COUNT(*)::int AS total - FROM work_order_events - WHERE event_type LIKE 'timeout_%' - AND payload_json->>'workerId' IS NOT NULL - AND payload_json->>'workerId' != '' - AND payload_json->>'workerId' = ANY($1::text[]) - GROUP BY payload_json->>'workerId' - `, - [uniqueIds.map(String)], - ) - for (const row of result.rows) { - const workerId = Number(row.worker_id) - if (Number.isFinite(workerId) && workerId > 0) { - counts.set(workerId, Number(row.total || 0)) - } - } - if (timeoutCountCache.size >= TIMEOUT_COUNT_CACHE_MAX_ENTRIES) { - const oldestKey = timeoutCountCache.keys().next().value - if (oldestKey) timeoutCountCache.delete(oldestKey) - } - timeoutCountCache.set(cacheKey, { - expiresAt: now + TIMEOUT_COUNT_CACHE_TTL_MS, - counts: new Map(counts), - }) - return counts -} - -export async function countWorkerTimeoutEvents(workerId: number | string): Promise { - const counts = await countTimeoutEventsByWorkerIds([Number(workerId)]) - return counts.get(Number(workerId)) || 0 -} - -export async function countWorkerCancellationsSince( - workerId: number | string, - sinceIso: string, -): Promise { - const result = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM work_order_events - WHERE actor_type = 'worker' - AND actor_id = $1 - AND event_type = 'cancelled_by_worker' - AND created_at >= $2 - `, - [String(workerId), sinceIso], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function getWorkOrderByIdWithClient( - client: PoolClient, - workOrderId: number | string, -): Promise { - const result = await client.query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ - Number(workOrderId), - ]) - return result.rows[0] || null -} - -export function buildWorkOrderWhere({ - statuses = [], - workOrderId = 0, - keyword = '', - workerId = 0, - workerLevelIds = [], - categoryId = 0, - workerSharingId = 0, - hallCandidateLimit = 0, - hallCategoryLimits = {}, - visibleAfterIso = '', - excludeFilledSharing = false, - vipEvidencePending = false, - acceptanceMode = '', - giftPhase = '', - useOperationalStatus = false, - createdFrom = '', - createdTo = '', -}: ListInput) { - const filters: string[] = [] - const params: unknown[] = [] - const joins: string[] = [] - if (createdFrom) { - params.push(createdFrom) - filters.push( - `wo.created_at >= ($${params.length}::date)::timestamp AT TIME ZONE 'Asia/Shanghai'`, - ) - } - if (createdTo) { - params.push(createdTo) - filters.push( - `wo.created_at < (($${params.length}::date + 1)::timestamp AT TIME ZONE 'Asia/Shanghai')`, - ) - } - let workerSharePlaceholder = '' - - if (excludeFilledSharing) { - joins.push(` - LEFT JOIN ( - SELECT - wos.work_order_id, - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity - FROM work_order_shares wos - GROUP BY wos.work_order_id - ) filter_share_summary ON filter_share_summary.work_order_id = wo.id`) - } - - // 先从打手的整单、历史单和拼单记录组成可见工单集合,再关联工单详情。 - // 这样 COUNT 和列表都不会先扫描全量工单,再逐行检查该打手的拼单状态。 - if (workerSharingId) { - params.push(workerSharingId) - workerSharePlaceholder = `$${params.length}` - joins.push(` - INNER JOIN ( - SELECT assigned.id AS work_order_id - FROM work_orders assigned - WHERE assigned.assigned_worker_id = ${workerSharePlaceholder} - UNION - SELECT historical.id AS work_order_id - FROM work_orders historical - WHERE historical.last_assigned_worker_id = ${workerSharePlaceholder} - AND ( - historical.status <> 'open' - OR EXISTS ( - SELECT 1 - FROM work_order_events timeout_event - WHERE timeout_event.work_order_id = historical.id - AND timeout_event.event_type = 'timeout_reopen' - ) - ) - UNION - SELECT share_source.work_order_id - FROM work_order_shares share_source - WHERE share_source.worker_id = ${workerSharePlaceholder} - AND share_source.status != 'cancelled' - ) worker_visible ON worker_visible.work_order_id = wo.id - LEFT JOIN work_order_shares worker_share - ON worker_share.work_order_id = wo.id - AND worker_share.worker_id = ${workerSharePlaceholder}`) - } - - if (useOperationalStatus) { - if (workerSharingId) { - // 已限定打手时只汇总其候选工单,利用 work_order_id 索引避免扫描整张拼单表。 - joins.push(` - LEFT JOIN LATERAL ( - SELECT - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - ) operational_share_summary ON TRUE`) - } else { - // 无打手范围时一次聚合全部拼单,避免对每笔工单重复执行关联子查询。 - joins.push(` - LEFT JOIN ( - SELECT - wos.work_order_id, - SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count - FROM work_order_shares wos - GROUP BY wos.work_order_id - ) operational_share_summary ON operational_share_summary.work_order_id = wo.id`) - } - } - const normalizedStatuses = [ - ...new Set(statuses.map((item) => String(item || '').trim()).filter(Boolean)), - ] - if (vipEvidencePending) { - const vipEvidencePendingWhere = ` - wo.status = 'accepted' - AND wo.acceptance_json->>'reviewMode' = 'vip_auto' - AND wo.acceptance_json->>'evidenceStatus' = 'pending' - AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() - ` - if (normalizedStatuses.length > 0) { - params.push(normalizedStatuses) - filters.push( - `(${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`, - ) - } else { - filters.push(`(${vipEvidencePendingWhere})`) - } - } else if (normalizedStatuses.length > 0) { - params.push(normalizedStatuses) - const statusesPlaceholder = `$${params.length}` - if (workerSharingId) { - filters.push( - `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`, - ) - } else { - filters.push( - `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, - ) - } - } - if (workOrderId) { - params.push(workOrderId) - filters.push(`wo.id = $${params.length}`) - } - if (keyword) { - params.push(`%${keyword}%`) - filters.push(`${workOrderSearchExpression()} LIKE LOWER($${params.length})`) - } - if (workerId) { - params.push(workerId) - filters.push(`( - wo.assigned_worker_id = $${params.length} - OR EXISTS ( - SELECT 1 - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - AND wos.worker_id = $${params.length} - AND wos.status != 'cancelled' - ) - )`) - } - if (workerLevelIds.length > 0) { - params.push(workerLevelIds) - filters.push(`( - EXISTS ( - SELECT 1 - FROM worker_users wu - WHERE wu.id = wo.assigned_worker_id - AND wu.level_id = ANY($${params.length}::bigint[]) - ) - OR EXISTS ( - SELECT 1 - FROM work_order_shares wos - INNER JOIN worker_users wu ON wu.id = wos.worker_id - WHERE wos.work_order_id = wo.id - AND wos.status != 'cancelled' - AND wu.level_id = ANY($${params.length}::bigint[]) - ) - )`) - } - if (categoryId) { - params.push(categoryId) - filters.push(`wo.category_id = $${params.length}`) - } - if (acceptanceMode) { - params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode)) - filters.push(`wo.acceptance_mode = $${params.length}`) - } - if (giftPhase) { - const normalizedGiftPhase = String(giftPhase).trim() - if (normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY) { - // gift_ready 是冷却到点后的实时阶段,由时间条件计算而不是落库状态。 - filters.push(`( - wo.acceptance_mode = 'friend_gift' - AND wo.gift_phase = 'friend_countdown' - AND wo.gift_available_at IS NOT NULL - AND wo.gift_available_at <= NOW() - )`) - } else if (Object.values(WORK_ORDER_GIFT_PHASE).includes(normalizedGiftPhase as never)) { - params.push(normalizedGiftPhase) - const giftPhaseFilter = `wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}` - // 待打手提交资料仅代表已接单后的赠送流程;待抢单/未分配模板单不应混入该筛选。 - filters.push( - normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED - ? `(${giftPhaseFilter} AND wo.assigned_worker_id IS NOT NULL AND wo.status IN ('in_progress', 'problem'))` - : giftPhaseFilter, - ) - } - } - if (hallCandidateLimit > 0) { - params.push(hallCandidateLimit) - const limitPlaceholder = `$${params.length}` - params.push(JSON.stringify(hallCategoryLimits)) - filters.push(`wo.id IN (${buildHallCandidateIdsSql(limitPlaceholder, `$${params.length}`)})`) - } - if (visibleAfterIso) { - params.push(visibleAfterIso) - filters.push(`(wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $${params.length})`) - } - if (excludeFilledSharing) { - filters.push(`( - wo.sharing_enabled IS NOT TRUE - OR COALESCE(filter_share_summary.active_share_quantity, 0) < wo.sharing_total_quantity - )`) - } - return { - joins: joins.join('\n'), - whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - -/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ -function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) { - return ` - SELECT ranked.id - FROM ( - SELECT candidate.id, candidate.category_id, candidate.hall_queued_at, - ROW_NUMBER() OVER ( - PARTITION BY candidate.category_id - ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC - ) AS category_rank - FROM work_orders candidate - LEFT JOIN ( - SELECT - work_order_id, - SUM(quantity) FILTER (WHERE status != 'cancelled') AS active_share_quantity - FROM work_order_shares - GROUP BY work_order_id - ) candidate_shares ON candidate_shares.work_order_id = candidate.id - WHERE candidate.status = 'open' - AND ( - candidate.sharing_enabled IS NOT TRUE - OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity - ) - ) ranked - WHERE ranked.category_rank <= COALESCE( - NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int, - ${limitPlaceholder} - ) - ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC - LIMIT ${limitPlaceholder} - ` -} - -/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */ -export async function isWorkOrderWithinHallCapacityWithClient( - client: PoolClient, - input: { - workOrderId: number - hallCandidateLimit: number - hallCategoryLimits?: Record - visibleAfterIso?: string | undefined - }, -) { - const result = await client.query<{ visible: boolean }>( - ` - SELECT EXISTS( - SELECT 1 - FROM work_orders wo - WHERE wo.id = $1 - AND wo.id IN (${buildHallCandidateIdsSql('$2', '$3')}) - AND ($4::timestamptz IS NULL OR wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $4) - ) AS visible - `, - [ - input.workOrderId, - input.hallCandidateLimit, - JSON.stringify(input.hallCategoryLimits || {}), - input.visibleAfterIso ? input.visibleAfterIso : null, - ], - ) - return result.rows[0]?.visible === true -}