移动恢复文件
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -1,35 +0,0 @@
|
||||
发布与核销解耦原子修复验证
|
||||
|
||||
改变分支/字段:
|
||||
- apps/backend/src/services/worker-platform/admin-work-order-management-service.ts:发布先完成,随后尽力核销;核销失败仅记录 publish_voucher_consume_failed,不改变已发布状态。
|
||||
- apps/backend/src/repositories/worker-platform/work-order-management-repo.ts:新增 publishUnassignedWorkOrder,使用 status='unassigned' AND assigned_worker_id IS NULL 的原子条件迁移,并在同一事务写 published 事件。
|
||||
- apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx:发布后核销提示。
|
||||
- apps/frontend/src/components/WorkOrderEventTimeline.tsx:核销失败文案改为“订单已发布,但电子凭证核销未完成”。
|
||||
|
||||
四个工件:
|
||||
- MODIFIED_FILE: /Users/yml/codes/order_site/publish-atomic-artifacts/MODIFIED_FILE
|
||||
- DIFF_FILE: /Users/yml/codes/order_site/publish-atomic-artifacts/DIFF_FILE.patch
|
||||
- VERIFICATION.txt: /Users/yml/codes/order_site/publish-atomic-artifacts/VERIFICATION.txt
|
||||
- ROLLBACK.sh: /Users/yml/codes/order_site/publish-atomic-artifacts/ROLLBACK.sh
|
||||
|
||||
原始文件哈希:
|
||||
- git HEAD work-order-management-repo.ts blob: 40937f3a95998868a217a5c3da78389f6c799224
|
||||
- 修改副本 sha256: a7e256197ff04b4682a4b2817da30e7344ac0cda66818ecc9fe5711042957a85
|
||||
|
||||
BASELINE:
|
||||
- 命令:cd /Users/yml/codes/order_site/apps/backend && npm test
|
||||
- 输入:HEAD 基线工作树
|
||||
- 字面结果:tests 379;pass 377;fail 0;cancelled 0;skipped 2;exit 0
|
||||
- 状态:通过
|
||||
|
||||
MODIFIED:
|
||||
- 命令:cd /Users/yml/codes/order_site/apps/backend && npm test
|
||||
- 输入:当前修改工作树
|
||||
- 字面结果:tests 379;pass 377;fail 0;cancelled 0;skipped 2;exit 0
|
||||
- 状态:通过
|
||||
|
||||
ROLLBACK:
|
||||
- 命令:cp /Users/yml/codes/order_site/publish-atomic-artifacts/MODIFIED_FILE /tmp/publish-atomic-rollback-test.ts && /Users/yml/codes/order_site/publish-atomic-artifacts/ROLLBACK.sh /tmp/publish-atomic-rollback-test.ts && cmp /tmp/publish-atomic-rollback-test.ts <(git -C /Users/yml/codes/order_site show HEAD:apps/backend/src/repositories/worker-platform/work-order-management-repo.ts)
|
||||
- 输入:修改副本 /tmp/publish-atomic-rollback-test.ts
|
||||
- 字面结果:rollback restored HEAD content;cmp exit 0;ROLLBACK.sh exit 0
|
||||
- 恢复状态:另一份副本恢复为 HEAD 内容,MODIFIED_FILE 保持修改内容。
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
-960
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user