移动恢复文件

This commit is contained in:
yml2213
2026-09-01 14:15:16 +08:00
parent c6dd6a8b7c
commit cc83898ec7
26 changed files with 0 additions and 6278 deletions
-685
View File
@@ -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<string, string | number | boolean | null | undefined>
type ExternalHttpPacketDetail = Record<string, unknown>
type LogEntry = {
time: string
level: LogLevel
scope: string
message: string
pid: number
detail?: unknown
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
}
const LEVEL_LABELS: Record<LogLevel, string> = {
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<LogEntry>,
{ 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<string, unknown> = {}
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<void> {
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)
}
@@ -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.
-757
View File
@@ -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<string, string | number | boolean | null | undefined>
type ExternalHttpPacketDetail = Record<string, unknown>
type LogEntry = {
time: string
level: LogLevel
scope: string
message: string
pid: number
detail?: unknown
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
}
const LEVEL_LABELS: Record<LogLevel, string> = {
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<LogEntry>,
{ 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<string, unknown> = {}
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<void> {
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<void> {
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<void> {
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)
}
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${1:?usage: ROLLBACK.sh <workspace-copy>}"
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"
@@ -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.
@@ -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<string, string | number | boolean | null | undefined>
type ExternalHttpPacketDetail = Record<string, unknown>
type LogEntry = {
time: string
level: LogLevel
scope: string
message: string
pid: number
detail?: unknown
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
}
const LEVEL_LABELS: Record<LogLevel, string> = {
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<LogEntry>,
{ 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<string, unknown> = {}
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<void> {
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)
}
-262
View File
@@ -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<WorkOrderRow>(
+ `
+ 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: (
<div>
<Typography.Paragraph>
- 如订单关联电子凭证,系统会先自动核销,成功后再发布到大厅。核销后即使下架也不会自动恢复凭证。
+ 如订单关联电子凭证,系统会在发布后顺带尝试核销;核销失败不影响订单发布。
</Typography.Paragraph>
<Checkbox
onChange={(event) => {
@@ -597,7 +599,7 @@ export default function WorkOrdersPanel() {
</Checkbox>
</div>
),
- okText: '发布并核销',
+ okText: '发布订单',
cancelText: '取消',
onOk: () => {
if (skipFutureConfirm) setSkipPublishConfirm()
-247
View File
@@ -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<WorkOrderRow | null> {
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<WorkOrderRow | null> {
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<WorkOrderRow>(
`
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<WorkOrderRow | null> {
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<WorkOrderRow | null> {
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<boolean> {
const result = await query('DELETE FROM work_orders WHERE id = $1', [Number(workOrderId)])
return Number(result.rowCount || 0) > 0
}
-8
View File
@@ -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"
-35
View File
@@ -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 379pass 377fail 0cancelled 0skipped 2exit 0
- 状态:通过
MODIFIED
- 命令:cd /Users/yml/codes/order_site/apps/backend && npm test
- 输入:当前修改工作树
- 字面结果:tests 379pass 377fail 0cancelled 0skipped 2exit 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 contentcmp exit 0ROLLBACK.sh exit 0
- 恢复状态:另一份副本恢复为 HEAD 内容,MODIFIED_FILE 保持修改内容。
-59
View File
@@ -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
@@ -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<string, string>
body: any
} {
const res = {
statusCode: 200,
headers: {} as Record<string, string>,
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<string, string>
body: any
}
}
-29
View File
@@ -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,
-68
View File
@@ -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
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${1:?usage: ROLLBACK.sh <workspace-copy>}"
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"
-37
View File
@@ -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
@@ -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<string, string>
body: any
} {
const res = {
statusCode: 200,
headers: {} as Record<string, string>,
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<string, string>
body: any
}
}
@@ -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
@@ -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()
}
},
)
-960
View File
@@ -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<number, number>
}
// 后台打手列表会在短时间内重复请求同一页;超时次数只是展示字段,允许短 TTL。
const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000
const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256
const timeoutCountCache = new Map<string, TimeoutCountCacheEntry>()
/** 后台按母工单拼单参与情况计算业务状态,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<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [
Number(workOrderId),
])
return result.rows[0] || null
}
export async function findPendingMaterialWorkOrderByPlatformOrderId(
platformOrderId: string,
): Promise<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow>(
`${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<WorkOrderStatistics> {
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<WorkOrderStatisticsRow>(
`
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<WorkerOrderOverviewRow>(
`
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<WorkOrderRow[]> {
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<WorkOrderRow>(
`${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<number> {
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<number> {
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<Map<number, number>> {
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<number, number>()
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<number> {
const counts = await countTimeoutEventsByWorkerIds([Number(workerId)])
return counts.get(Number(workerId)) || 0
}
export async function countWorkerCancellationsSince(
workerId: number | string,
sinceIso: string,
): Promise<number> {
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<WorkOrderRow | null> {
const result = await client.query<WorkOrderRow>(`${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<string, number>
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
}
-9
View File
@@ -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.
-961
View File
@@ -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<number, number>
}
// 后台打手列表会在短时间内重复请求同一页;超时次数只是展示字段,允许短 TTL。
const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000
const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256
const timeoutCountCache = new Map<string, TimeoutCountCacheEntry>()
/** 后台按母工单拼单参与情况计算业务状态,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<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [
Number(workOrderId),
])
return result.rows[0] || null
}
export async function findPendingMaterialWorkOrderByPlatformOrderId(
platformOrderId: string,
): Promise<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow>(
`${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<WorkOrderStatistics> {
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<WorkOrderStatisticsRow>(
`
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<WorkerOrderOverviewRow>(
`
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<WorkOrderRow[]> {
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<WorkOrderRow>(
`${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<number> {
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<number> {
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<Map<number, number>> {
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<number, number>()
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<number> {
const counts = await countTimeoutEventsByWorkerIds([Number(workerId)])
return counts.get(Number(workerId)) || 0
}
export async function countWorkerCancellationsSince(
workerId: number | string,
sinceIso: string,
): Promise<number> {
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<WorkOrderRow | null> {
const result = await client.query<WorkOrderRow>(`${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<string, number>
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
}
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${1:?usage: ROLLBACK.sh <workspace-copy>}"
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"
-27
View File
@@ -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.
@@ -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()
}
},
)
@@ -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<number, number>
}
// 后台打手列表会在短时间内重复请求同一页;超时次数只是展示字段,允许短 TTL。
const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000
const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256
const timeoutCountCache = new Map<string, TimeoutCountCacheEntry>()
/** 后台按母工单拼单参与情况计算业务状态,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<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [
Number(workOrderId),
])
return result.rows[0] || null
}
export async function findPendingMaterialWorkOrderByPlatformOrderId(
platformOrderId: string,
): Promise<WorkOrderRow | null> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow[]> {
const result = await query<WorkOrderRow>(
`${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<WorkOrderRow>(
`${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<WorkOrderStatistics> {
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<WorkOrderStatisticsRow>(
`
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<WorkerOrderOverviewRow>(
`
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<WorkOrderRow[]> {
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<WorkOrderRow>(
`${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<number> {
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<number> {
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<Map<number, number>> {
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<number, number>()
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<number> {
const counts = await countTimeoutEventsByWorkerIds([Number(workerId)])
return counts.get(Number(workerId)) || 0
}
export async function countWorkerCancellationsSince(
workerId: number | string,
sinceIso: string,
): Promise<number> {
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<WorkOrderRow | null> {
const result = await client.query<WorkOrderRow>(`${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<string, number>
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
}