feat(ops): 数据保留清理、CORS 白名单与上传失败回滚
- 新增每日数据保留清理任务:按保留期清空原始报文、删除过期 事件/审计记录,批量幂等可重跑;067 迁移补齐清理索引并清理 冗余索引,check-sql-guard 增加防线 - 生产 CORS 改为显式来源白名单(CORS_ALLOWED_ORIGINS),禁用 通配符,非通配响应补充 Vary: Origin - 文件上传任一步失败时回滚已上传对象,清理失败记录 warn 日志 - 新增 HTTP/CORS 与 PostgreSQL 集成测试;若干文件仅 prettier 重排
This commit is contained in:
@@ -40,6 +40,12 @@ LOG_LEVEL=info
|
|||||||
LOG_INTEGRATION_LEVEL=warn
|
LOG_INTEGRATION_LEVEL=warn
|
||||||
LOG_RETENTION_DAYS=30
|
LOG_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# 数据库敏感报文/事件保留策略(天)。清理任务每天执行一次。
|
||||||
|
RAW_PAYLOAD_RETENTION_DAYS=180
|
||||||
|
TASK_EVENT_RETENTION_DAYS=180
|
||||||
|
WEBHOOK_EVENT_RETENTION_DAYS=180
|
||||||
|
AUDIT_LOG_RETENTION_DAYS=365
|
||||||
|
|
||||||
# 文件存储:生产使用阿里云 OSS(生产编排已移除 MinIO,仅本地开发使用)。
|
# 文件存储:生产使用阿里云 OSS(生产编排已移除 MinIO,仅本地开发使用)。
|
||||||
# STORAGE_MODE 取值 minio | dual | oss;dual 仅在 MinIO→OSS 迁移/回滚期间使用。
|
# STORAGE_MODE 取值 minio | dual | oss;dual 仅在 MinIO→OSS 迁移/回滚期间使用。
|
||||||
STORAGE_MODE=oss
|
STORAGE_MODE=oss
|
||||||
@@ -74,6 +80,9 @@ DB_BACKUP_KEEP=7
|
|||||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||||
|
|
||||||
|
# 生产环境必须使用显式来源白名单,多个来源用逗号分隔。
|
||||||
|
CORS_ALLOWED_ORIGINS=https://order.khhao.com,https://worker.khhao.com
|
||||||
|
|
||||||
# 91Kaquan
|
# 91Kaquan
|
||||||
KAQUAN91_USER_ID=
|
KAQUAN91_USER_ID=
|
||||||
KAQUAN91_SECRET=
|
KAQUAN91_SECRET=
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ const checks: Array<{ name: string; file: string; patterns: string[] }> = [
|
|||||||
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-share-repo.ts'),
|
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-share-repo.ts'),
|
||||||
patterns: ['workOrderIds: number[]', 'wos.work_order_id = ANY($2::bigint[])'],
|
patterns: ['workOrderIds: number[]', 'wos.work_order_id = ANY($2::bigint[])'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '数据保留清理扫描索引',
|
||||||
|
file: path.join(BACKEND_ROOT, 'src/db/migrations/067_data_retention_indexes.sql'),
|
||||||
|
patterns: [
|
||||||
|
'idx_orders_updated_at',
|
||||||
|
'idx_kuaishou_industry_vouchers_updated_at',
|
||||||
|
'idx_task_events_created_at',
|
||||||
|
'idx_webhook_events_created_at',
|
||||||
|
'DROP INDEX IF EXISTS idx_work_product_match_logs_created_at',
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const failures: string[] = []
|
const failures: string[] = []
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { once } from 'node:events'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { createApp } from './app.js'
|
||||||
|
import { createDefaultRuntimeConfig } from './config/defaults.js'
|
||||||
|
import { createStartupState } from './startup/state.js'
|
||||||
|
|
||||||
|
test('HTTP health route and explicit CORS preflight', async () => {
|
||||||
|
const startupState = createStartupState()
|
||||||
|
startupState.core.ready = true
|
||||||
|
const config = createDefaultRuntimeConfig('/tmp/order-site')
|
||||||
|
config.cors.allowedOrigins = ['https://order.example.test']
|
||||||
|
const server = createApp({
|
||||||
|
startupState,
|
||||||
|
isShutdownStarted: () => false,
|
||||||
|
config,
|
||||||
|
}).listen(0, '127.0.0.1')
|
||||||
|
await once(server, 'listening')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address()
|
||||||
|
assert.ok(address && typeof address === 'object')
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}`
|
||||||
|
const health = await fetch(`${baseUrl}/health/live`)
|
||||||
|
assert.equal(health.status, 200)
|
||||||
|
assert.equal((await health.json()).data.status, 'alive')
|
||||||
|
|
||||||
|
const preflight = await fetch(`${baseUrl}/api/v1/health`, {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: { Origin: 'https://order.example.test' },
|
||||||
|
})
|
||||||
|
assert.equal(preflight.status, 204)
|
||||||
|
assert.equal(preflight.headers.get('access-control-allow-origin'), 'https://order.example.test')
|
||||||
|
assert.equal(preflight.headers.get('vary'), 'Origin')
|
||||||
|
} finally {
|
||||||
|
server.close()
|
||||||
|
await once(server, 'close')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -21,6 +21,14 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
retentionDays: 30,
|
retentionDays: 30,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
retention: {
|
||||||
|
// 原始报文只保留追溯所需窗口,清理后保留订单/事件摘要。
|
||||||
|
rawPayloadDays: 180,
|
||||||
|
taskEventDays: 180,
|
||||||
|
webhookEventDays: 180,
|
||||||
|
auditLogDays: 365,
|
||||||
|
},
|
||||||
|
|
||||||
database: {
|
database: {
|
||||||
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
|
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
|
||||||
ssl: false,
|
ssl: false,
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
|||||||
'NPM_CONFIG_REGISTRY',
|
'NPM_CONFIG_REGISTRY',
|
||||||
'POSTGRES_DB',
|
'POSTGRES_DB',
|
||||||
'POSTGRES_LOG_MIN_DURATION_STATEMENT_MS',
|
'POSTGRES_LOG_MIN_DURATION_STATEMENT_MS',
|
||||||
|
'POSTGRES_MEM_LIMIT',
|
||||||
|
'POSTGRES_CPUS',
|
||||||
|
'POSTGRES_SHARED_BUFFERS',
|
||||||
|
'POSTGRES_EFFECTIVE_CACHE_SIZE',
|
||||||
|
'POSTGRES_WORK_MEM',
|
||||||
|
'POSTGRES_MAINTENANCE_WORK_MEM',
|
||||||
|
'BACKEND_MEM_LIMIT',
|
||||||
|
'BACKEND_CPUS',
|
||||||
|
'WEB_MEM_LIMIT',
|
||||||
|
'WEB_CPUS',
|
||||||
'POSTGRES_PASSWORD',
|
'POSTGRES_PASSWORD',
|
||||||
'POSTGRES_PORT',
|
'POSTGRES_PORT',
|
||||||
'POSTGRES_USER',
|
'POSTGRES_USER',
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
stringEnv('LOG_LEVEL', ['logging', 'level']),
|
stringEnv('LOG_LEVEL', ['logging', 'level']),
|
||||||
stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']),
|
stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']),
|
||||||
integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']),
|
integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']),
|
||||||
|
integerEnv('RAW_PAYLOAD_RETENTION_DAYS', ['retention', 'rawPayloadDays']),
|
||||||
|
integerEnv('TASK_EVENT_RETENTION_DAYS', ['retention', 'taskEventDays']),
|
||||||
|
integerEnv('WEBHOOK_EVENT_RETENTION_DAYS', ['retention', 'webhookEventDays']),
|
||||||
|
integerEnv('AUDIT_LOG_RETENTION_DAYS', ['retention', 'auditLogDays']),
|
||||||
stringEnv('DATABASE_URL', ['database', 'url']),
|
stringEnv('DATABASE_URL', ['database', 'url']),
|
||||||
booleanEnv('DATABASE_SSL', ['database', 'ssl']),
|
booleanEnv('DATABASE_SSL', ['database', 'ssl']),
|
||||||
integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']),
|
integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']),
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ test('validateRuntimeConfig accepts complete production config', () => {
|
|||||||
assert.deepEqual(issues, [])
|
assert.deepEqual(issues, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('validateRuntimeConfig rejects wildcard CORS in production', () => {
|
||||||
|
const issues = validateRuntimeConfig(createRuntimeConfig({ cors: { allowedOrigins: ['*'] } }), {
|
||||||
|
env: { NODE_ENV: 'production' },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
issues.map((issue) => issue.path),
|
||||||
|
['cors.allowedOrigins'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
||||||
const issues = validateRuntimeConfig(
|
const issues = validateRuntimeConfig(
|
||||||
createRuntimeConfig({
|
createRuntimeConfig({
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ export function validateRuntimeConfig(
|
|||||||
min: 1,
|
min: 1,
|
||||||
max: 100,
|
max: 100,
|
||||||
})
|
})
|
||||||
|
if (config.retention) {
|
||||||
|
requireInteger(issues, 'retention.rawPayloadDays', config.retention.rawPayloadDays, { min: 1 })
|
||||||
|
requireInteger(issues, 'retention.taskEventDays', config.retention.taskEventDays, { min: 1 })
|
||||||
|
requireInteger(issues, 'retention.webhookEventDays', config.retention.webhookEventDays, {
|
||||||
|
min: 1,
|
||||||
|
})
|
||||||
|
requireInteger(issues, 'retention.auditLogDays', config.retention.auditLogDays, { min: 1 })
|
||||||
|
}
|
||||||
const storageMode = normalizeStorageMode(config.storage?.mode)
|
const storageMode = normalizeStorageMode(config.storage?.mode)
|
||||||
if (!storageMode) {
|
if (!storageMode) {
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -137,6 +145,24 @@ export function validateRuntimeConfig(
|
|||||||
validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
||||||
validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 })
|
validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 })
|
||||||
validateAdminDefaultUsers(issues, config.admin?.defaultUsers)
|
validateAdminDefaultUsers(issues, config.admin?.defaultUsers)
|
||||||
|
const allowedOrigins = config.cors?.allowedOrigins
|
||||||
|
if (Array.isArray(allowedOrigins)) {
|
||||||
|
if (allowedOrigins.length === 0 || allowedOrigins.includes('*')) {
|
||||||
|
issues.push({
|
||||||
|
path: 'cors.allowedOrigins',
|
||||||
|
message: '生产环境必须配置显式 CORS 域名白名单,不能使用 *',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
allowedOrigins.forEach((origin, index) => {
|
||||||
|
if (!isHttpUrl(String(origin || '').trim())) {
|
||||||
|
issues.push({
|
||||||
|
path: `cors.allowedOrigins[${index}]`,
|
||||||
|
message: '必须是 http 或 https 来源 URL',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 067_data_retention_indexes.sql —— 数据保留清理所需索引。
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at
|
||||||
|
ON webhook_events(created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_updated_at
|
||||||
|
ON orders(updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_updated_at
|
||||||
|
ON kuaishou_industry_vouchers(updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_events_created_at
|
||||||
|
ON task_events(created_at DESC);
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_work_product_match_logs_created_at;
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_webhook_events_created_at IS '支持 webhook 回调去重数据按创建时间清理';
|
||||||
|
COMMENT ON INDEX idx_orders_updated_at IS '支持订单原始报文按更新时间清理';
|
||||||
|
COMMENT ON INDEX idx_kuaishou_industry_vouchers_updated_at IS '支持电子凭证原始报文按更新时间清理';
|
||||||
|
COMMENT ON INDEX idx_task_events_created_at IS '支持任务事件按创建时间清理';
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -11,6 +11,7 @@ export function createCorsMiddleware(
|
|||||||
if (isWildcard) {
|
if (isWildcard) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
} else {
|
} else {
|
||||||
|
res.setHeader('Vary', 'Origin')
|
||||||
const origin = req.headers.origin
|
const origin = req.headers.origin
|
||||||
if (origin && allowedOrigins.includes(origin)) {
|
if (origin && allowedOrigins.includes(origin)) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
|
|||||||
@@ -837,7 +837,9 @@ export async function cancelWorkOrderSharing(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancellableShares = shares.filter((share) => ['joined', 'submitted'].includes(share.status))
|
const cancellableShares = shares.filter((share) =>
|
||||||
|
['joined', 'submitted'].includes(share.status),
|
||||||
|
)
|
||||||
let releasedDepositAmount = 0
|
let releasedDepositAmount = 0
|
||||||
for (const share of cancellableShares) {
|
for (const share of cancellableShares) {
|
||||||
const workerId = Number(share.worker_id || 0)
|
const workerId = Number(share.worker_id || 0)
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export function resolveWorkOrderShareDepositAmount(input: {
|
|||||||
}) {
|
}) {
|
||||||
const totalQuantity = Math.max(1, Number(input.totalQuantity || 0))
|
const totalQuantity = Math.max(1, Number(input.totalQuantity || 0))
|
||||||
const nextRequiredDeposit = Math.round(
|
const nextRequiredDeposit = Math.round(
|
||||||
(Math.max(0, Number(input.requiredDepositAmount || 0)) * Math.max(0, Number(input.nextQuantity || 0))) /
|
(Math.max(0, Number(input.requiredDepositAmount || 0)) *
|
||||||
|
Math.max(0, Number(input.nextQuantity || 0))) /
|
||||||
totalQuantity,
|
totalQuantity,
|
||||||
)
|
)
|
||||||
const requiredShareDeposit = Math.max(
|
const requiredShareDeposit = Math.max(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createFileAsset, type FileAssetRow } from '../../repositories/file-asse
|
|||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
||||||
|
import { logWarn } from '../../utils/logger.js'
|
||||||
|
|
||||||
const IMAGE_VARIANT_THUMB = 'thumb'
|
const IMAGE_VARIANT_THUMB = 'thumb'
|
||||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||||
@@ -64,74 +65,98 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
|||||||
const scene = normalizeScene(input.scene)
|
const scene = normalizeScene(input.scene)
|
||||||
const objectKey = createObjectKey(scene, file.originalname, contentType)
|
const objectKey = createObjectKey(scene, file.originalname, contentType)
|
||||||
const storage = getObjectStorage()
|
const storage = getObjectStorage()
|
||||||
await storage.putObject({
|
const uploadedKeys: string[] = []
|
||||||
key: objectKey,
|
try {
|
||||||
content: file.buffer,
|
uploadedKeys.push(objectKey)
|
||||||
contentType,
|
await storage.putObject({
|
||||||
metadata: {
|
key: objectKey,
|
||||||
'original-filename': file.originalname,
|
content: file.buffer,
|
||||||
},
|
contentType,
|
||||||
})
|
metadata: {
|
||||||
|
'original-filename': file.originalname,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const variants = await generateImageVariants(objectKey, file.buffer, contentType)
|
||||||
|
let thumbnailObjectKey = ''
|
||||||
|
let mediumObjectKey = ''
|
||||||
|
|
||||||
const variants = await generateImageVariants(objectKey, file.buffer, contentType)
|
for (const variant of variants) {
|
||||||
let thumbnailObjectKey = ''
|
try {
|
||||||
let mediumObjectKey = ''
|
uploadedKeys.push(variant.key)
|
||||||
|
await storage.putObject({
|
||||||
for (const variant of variants) {
|
key: variant.key,
|
||||||
try {
|
content: variant.content,
|
||||||
await storage.putObject({
|
contentType: variant.contentType,
|
||||||
key: variant.key,
|
metadata: {
|
||||||
content: variant.content,
|
'source-object': objectKey,
|
||||||
contentType: variant.contentType,
|
},
|
||||||
metadata: {
|
})
|
||||||
'source-object': objectKey,
|
if (variant.name === IMAGE_VARIANT_THUMB) {
|
||||||
},
|
thumbnailObjectKey = variant.key
|
||||||
})
|
}
|
||||||
if (variant.name === IMAGE_VARIANT_THUMB) {
|
if (variant.name === IMAGE_VARIANT_MEDIUM) {
|
||||||
thumbnailObjectKey = variant.key
|
mediumObjectKey = variant.key
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 缩略图失败不阻断原图上传;预览接口会回退到原图。
|
||||||
}
|
}
|
||||||
if (variant.name === IMAGE_VARIANT_MEDIUM) {
|
|
||||||
mediumObjectKey = variant.key
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 缩略图失败不阻断原图上传;预览接口会回退到原图。
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const fileUrl = buildUnsignedFileUrl(objectKey)
|
const fileUrl = buildUnsignedFileUrl(objectKey)
|
||||||
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
|
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
|
||||||
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
|
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
|
||||||
const asset = await createFileAsset({
|
const asset = await createFileAsset({
|
||||||
objectKey,
|
|
||||||
scene,
|
|
||||||
originalFilename: file.originalname,
|
|
||||||
contentType,
|
|
||||||
sizeBytes: file.buffer.length,
|
|
||||||
thumbnailObjectKey,
|
|
||||||
mediumObjectKey,
|
|
||||||
url: fileUrl,
|
|
||||||
thumbnailUrl,
|
|
||||||
mediumUrl,
|
|
||||||
uploaderType: String(input.uploaderType || '').trim(),
|
|
||||||
uploaderId: String(input.uploaderId || '').trim(),
|
|
||||||
metadataJson: JSON.stringify({
|
|
||||||
originalMimeType: file.mimetype,
|
|
||||||
originalSize: file.size,
|
|
||||||
}),
|
|
||||||
now,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
file: mapUploadedFile(asset, {
|
|
||||||
objectKey,
|
objectKey,
|
||||||
fileUrl,
|
scene,
|
||||||
|
originalFilename: file.originalname,
|
||||||
|
contentType,
|
||||||
|
sizeBytes: file.buffer.length,
|
||||||
|
thumbnailObjectKey,
|
||||||
|
mediumObjectKey,
|
||||||
|
url: fileUrl,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
mediumUrl,
|
mediumUrl,
|
||||||
filename: file.originalname,
|
uploaderType: String(input.uploaderType || '').trim(),
|
||||||
contentType,
|
uploaderId: String(input.uploaderId || '').trim(),
|
||||||
size: file.buffer.length,
|
metadataJson: JSON.stringify({
|
||||||
}),
|
originalMimeType: file.mimetype,
|
||||||
|
originalSize: file.size,
|
||||||
|
}),
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error('文件元数据写入失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: mapUploadedFile(asset, {
|
||||||
|
objectKey,
|
||||||
|
fileUrl,
|
||||||
|
thumbnailUrl,
|
||||||
|
mediumUrl,
|
||||||
|
filename: file.originalname,
|
||||||
|
contentType,
|
||||||
|
size: file.buffer.length,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const cleanupResults = await Promise.allSettled(
|
||||||
|
uploadedKeys.map((key) => storage.deleteObject(key)),
|
||||||
|
)
|
||||||
|
const cleanupFailures = cleanupResults.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||||
|
)
|
||||||
|
if (cleanupFailures.length > 0) {
|
||||||
|
void logWarn('[file-storage]', '上传失败后的对象清理未完全成功', {
|
||||||
|
objectKeys: uploadedKeys,
|
||||||
|
failedCount: cleanupFailures.length,
|
||||||
|
errors: cleanupFailures.map((result) =>
|
||||||
|
result.reason instanceof Error ? result.reason.message : String(result.reason || ''),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Readable } from 'node:stream'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreateBucketCommand,
|
CreateBucketCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
HeadBucketCommand,
|
HeadBucketCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
@@ -88,6 +89,13 @@ export class ObjectStorage {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteObject(key: string): Promise<void> {
|
||||||
|
await this.ensureBucketReady()
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteObjectCommand({ Bucket: this.bucket, Key: normalizeObjectKey(key) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async getObject(key: string): Promise<StoredObject> {
|
async getObject(key: string): Promise<StoredObject> {
|
||||||
await this.ensureBucketReady()
|
await this.ensureBucketReady()
|
||||||
const objectKey = normalizeObjectKey(key)
|
const objectKey = normalizeObjectKey(key)
|
||||||
@@ -233,6 +241,18 @@ export class StorageRouter {
|
|||||||
return this.oss!.getObject(key)
|
return this.oss!.getObject(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteObject(key: string): Promise<void> {
|
||||||
|
if (this.mode === 'minio') {
|
||||||
|
await this.legacy.deleteObject(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.mode === 'oss') {
|
||||||
|
await this.oss!.deleteObject(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await Promise.allSettled([this.legacy.deleteObject(key), this.oss!.deleteObject(key)])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let storageInstance: StorageRouter | null = null
|
let storageInstance: StorageRouter | null = null
|
||||||
|
|||||||
@@ -92,3 +92,16 @@ test('normalizeScheduledJobsConfig 支持多账号去重并保留 0 阈值', ()
|
|||||||
assert.equal(config.jobs[0].intervalSeconds, 60)
|
assert.equal(config.jobs[0].intervalSeconds, 60)
|
||||||
assert.equal(config.jobs[0].cooldownSeconds, 60)
|
assert.equal(config.jobs[0].cooldownSeconds, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('normalizeScheduledJobsConfig 自动补齐数据保留清理任务', () => {
|
||||||
|
const config = normalizeScheduledJobsConfig({ enabled: true, jobs: [] })
|
||||||
|
const retentionJob = config.jobs.find((job) => job.type === 'data_retention')
|
||||||
|
|
||||||
|
assert.deepEqual(retentionJob, {
|
||||||
|
id: 'data-retention',
|
||||||
|
type: 'data_retention',
|
||||||
|
enabled: true,
|
||||||
|
intervalSeconds: 86400,
|
||||||
|
config: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs
|
|||||||
const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health'
|
const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health'
|
||||||
const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
|
const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
|
||||||
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
|
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
|
||||||
|
const DATA_RETENTION_JOB_ID = 'data-retention'
|
||||||
|
|
||||||
export function getScheduledJobsFilePath() {
|
export function getScheduledJobsFilePath() {
|
||||||
return SCHEDULED_JOBS_FILE_PATH
|
return SCHEDULED_JOBS_FILE_PATH
|
||||||
@@ -73,6 +74,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
|||||||
const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID)
|
const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID)
|
||||||
const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID)
|
const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID)
|
||||||
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
|
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
|
||||||
|
const hasDataRetention = jobs.some((item) => item.id === DATA_RETENTION_JOB_ID)
|
||||||
|
|
||||||
if (!hasCloudtentaclesHealth) {
|
if (!hasCloudtentaclesHealth) {
|
||||||
jobs.push(createDefaultCloudtentaclesHealthJob())
|
jobs.push(createDefaultCloudtentaclesHealthJob())
|
||||||
@@ -83,6 +85,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
|||||||
if (!hasDepositUnfreeze) {
|
if (!hasDepositUnfreeze) {
|
||||||
jobs.push(createDefaultDepositUnfreezeJob())
|
jobs.push(createDefaultDepositUnfreezeJob())
|
||||||
}
|
}
|
||||||
|
if (!hasDataRetention) {
|
||||||
|
jobs.push(createDefaultDataRetentionJob())
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
|
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
|
||||||
@@ -105,6 +110,9 @@ function normalizeScheduledJob(rawValue: unknown) {
|
|||||||
if (type === 'deposit_unfreeze') {
|
if (type === 'deposit_unfreeze') {
|
||||||
return normalizeDepositUnfreezeJob(rawValue)
|
return normalizeDepositUnfreezeJob(rawValue)
|
||||||
}
|
}
|
||||||
|
if (type === 'data_retention') {
|
||||||
|
return normalizeDataRetentionJob(rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -123,6 +131,16 @@ function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDataRetentionJob(rawValue: JsonObject) {
|
||||||
|
return {
|
||||||
|
id: DATA_RETENTION_JOB_ID,
|
||||||
|
type: 'data_retention',
|
||||||
|
enabled: rawValue.enabled !== false,
|
||||||
|
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 86400, 3600, 604800),
|
||||||
|
config: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) {
|
function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) {
|
||||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||||
|
|
||||||
@@ -211,10 +229,21 @@ function createDefaultScheduledJobsConfig() {
|
|||||||
createDefaultCloudtentaclesHealthJob(),
|
createDefaultCloudtentaclesHealthJob(),
|
||||||
createDefaultWorkOrderTimeoutJob(),
|
createDefaultWorkOrderTimeoutJob(),
|
||||||
createDefaultDepositUnfreezeJob(),
|
createDefaultDepositUnfreezeJob(),
|
||||||
|
createDefaultDataRetentionJob(),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDefaultDataRetentionJob() {
|
||||||
|
return {
|
||||||
|
id: DATA_RETENTION_JOB_ID,
|
||||||
|
type: 'data_retention',
|
||||||
|
enabled: true,
|
||||||
|
intervalSeconds: 86400,
|
||||||
|
config: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultDepositUnfreezeJob() {
|
function createDefaultDepositUnfreezeJob() {
|
||||||
return {
|
return {
|
||||||
id: DEPOSIT_UNFREEZE_JOB_ID,
|
id: DEPOSIT_UNFREEZE_JOB_ID,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { query } from '../../db/client.js'
|
||||||
|
import { runtimeConfig } from '../../config/runtime.js'
|
||||||
|
|
||||||
|
export type DataRetentionResult = {
|
||||||
|
status: 'ok'
|
||||||
|
message: string
|
||||||
|
rawPayloadRows: number
|
||||||
|
taskEventRows: number
|
||||||
|
webhookEventRows: number
|
||||||
|
auditLogRows: number
|
||||||
|
matchLogRows: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理敏感原始报文并删除已过保留期的事件/审计记录。 */
|
||||||
|
export async function runDataRetentionJob(): Promise<DataRetentionResult> {
|
||||||
|
const retention = runtimeConfig.retention || {}
|
||||||
|
const rawPayloadDays = positiveDays(retention.rawPayloadDays, 180)
|
||||||
|
const taskEventDays = positiveDays(retention.taskEventDays, 180)
|
||||||
|
const webhookEventDays = positiveDays(retention.webhookEventDays, 180)
|
||||||
|
const auditLogDays = positiveDays(retention.auditLogDays, 365)
|
||||||
|
|
||||||
|
const rawPayloadRows =
|
||||||
|
(await scrubJsonColumnInBatches('orders', 'updated_at', rawPayloadDays)) +
|
||||||
|
(await scrubJsonColumnInBatches('kuaishou_industry_vouchers', 'updated_at', rawPayloadDays))
|
||||||
|
const matchLogRows = await scrubJsonColumnInBatches(
|
||||||
|
'work_product_match_logs',
|
||||||
|
'created_at',
|
||||||
|
rawPayloadDays,
|
||||||
|
)
|
||||||
|
const taskEventRows = await deleteOldRowsInBatches('task_events', taskEventDays)
|
||||||
|
const webhookEventRows = await deleteOldRowsInBatches('webhook_events', webhookEventDays)
|
||||||
|
const auditLogRows = await deleteOldRowsInBatches('admin_audit_logs', auditLogDays)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: '数据保留清理完成',
|
||||||
|
rawPayloadRows,
|
||||||
|
matchLogRows,
|
||||||
|
taskEventRows,
|
||||||
|
webhookEventRows,
|
||||||
|
auditLogRows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrubJsonColumnInBatches(
|
||||||
|
table: 'orders' | 'kuaishou_industry_vouchers' | 'work_product_match_logs',
|
||||||
|
dateColumn: 'created_at' | 'updated_at',
|
||||||
|
retentionDays: number,
|
||||||
|
) {
|
||||||
|
let total = 0
|
||||||
|
while (true) {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT ctid
|
||||||
|
FROM ${table}
|
||||||
|
WHERE ${dateColumn} < NOW() - make_interval(days => $1::int)
|
||||||
|
AND raw_payload_json <> '{}'::jsonb
|
||||||
|
LIMIT 1000
|
||||||
|
)
|
||||||
|
UPDATE ${table} AS target
|
||||||
|
SET raw_payload_json = '{}'::jsonb
|
||||||
|
FROM candidates
|
||||||
|
WHERE target.ctid = candidates.ctid
|
||||||
|
`,
|
||||||
|
[retentionDays],
|
||||||
|
)
|
||||||
|
total += result.rowCount || 0
|
||||||
|
if ((result.rowCount || 0) < 1000) return total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOldRowsInBatches(
|
||||||
|
table: 'task_events' | 'webhook_events' | 'admin_audit_logs',
|
||||||
|
retentionDays: number,
|
||||||
|
) {
|
||||||
|
let total = 0
|
||||||
|
while (true) {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT ctid
|
||||||
|
FROM ${table}
|
||||||
|
WHERE created_at < NOW() - make_interval(days => $1::int)
|
||||||
|
LIMIT 1000
|
||||||
|
)
|
||||||
|
DELETE FROM ${table} AS target
|
||||||
|
USING candidates
|
||||||
|
WHERE target.ctid = candidates.ctid
|
||||||
|
`,
|
||||||
|
[retentionDays],
|
||||||
|
)
|
||||||
|
total += result.rowCount || 0
|
||||||
|
if ((result.rowCount || 0) < 1000) return total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveDays(value: unknown, fallback: number) {
|
||||||
|
const days = Number(value)
|
||||||
|
return Number.isInteger(days) && days > 0 ? days : fallback
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { getScheduledJobById, getScheduledJobsConfig } from './config-service.js
|
|||||||
import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js'
|
import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js'
|
||||||
import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
|
import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
|
||||||
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
|
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
|
||||||
|
import { runDataRetentionJob } from './data-retention-job.js'
|
||||||
|
|
||||||
const timers = new Map<string, NodeJS.Timeout>()
|
const timers = new Map<string, NodeJS.Timeout>()
|
||||||
const jobStates = new Map<string, JsonObject>()
|
const jobStates = new Map<string, JsonObject>()
|
||||||
@@ -150,6 +151,15 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean }
|
|||||||
jobId,
|
jobId,
|
||||||
type: job.type,
|
type: job.type,
|
||||||
status: String(summary.status || 'ok'),
|
status: String(summary.status || 'ok'),
|
||||||
|
...(job.type === 'data_retention'
|
||||||
|
? {
|
||||||
|
rawPayloadRows: Number(summary.rawPayloadRows || 0),
|
||||||
|
matchLogRows: Number(summary.matchLogRows || 0),
|
||||||
|
taskEventRows: Number(summary.taskEventRows || 0),
|
||||||
|
webhookEventRows: Number(summary.webhookEventRows || 0),
|
||||||
|
auditLogRows: Number(summary.auditLogRows || 0),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -184,6 +194,9 @@ function dispatchJob(job: JsonObject) {
|
|||||||
if (job.type === 'deposit_unfreeze') {
|
if (job.type === 'deposit_unfreeze') {
|
||||||
return runDepositUnfreezeJob(job)
|
return runDepositUnfreezeJob(job)
|
||||||
}
|
}
|
||||||
|
if (job.type === 'data_retention') {
|
||||||
|
return runDataRetentionJob()
|
||||||
|
}
|
||||||
|
|
||||||
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
|
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ export async function setAdminWorkerPersonalDepositFreeAmount(
|
|||||||
) {
|
) {
|
||||||
const worker = await getRequiredWorker(workerId)
|
const worker = await getRequiredWorker(workerId)
|
||||||
const rawAmount = payload.depositFreeAmount ?? payload.deposit_free_amount
|
const rawAmount = payload.depositFreeAmount ?? payload.deposit_free_amount
|
||||||
const clearPersonalAmount = rawAmount === null || rawAmount === undefined || String(rawAmount).trim() === ''
|
const clearPersonalAmount =
|
||||||
|
rawAmount === null || rawAmount === undefined || String(rawAmount).trim() === ''
|
||||||
if (!clearPersonalAmount) {
|
if (!clearPersonalAmount) {
|
||||||
const amount = Number(rawAmount)
|
const amount = Number(rawAmount)
|
||||||
if (!Number.isFinite(amount) || amount < 0) {
|
if (!Number.isFinite(amount) || amount < 0) {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export function resolveWorkerPermissions(worker: WorkerUserRow) {
|
|||||||
personalDepositFreeAmount >= 0
|
personalDepositFreeAmount >= 0
|
||||||
return {
|
return {
|
||||||
// 个人额度是在等级额度基础上的追加额度,两者叠加生效。
|
// 个人额度是在等级额度基础上的追加额度,两者叠加生效。
|
||||||
depositFreeAmount: levelDepositFreeAmount +
|
depositFreeAmount:
|
||||||
(hasPersonalDepositFreeAmount ? personalDepositFreeAmount : 0),
|
levelDepositFreeAmount + (hasPersonalDepositFreeAmount ? personalDepositFreeAmount : 0),
|
||||||
levelDepositFreeAmount,
|
levelDepositFreeAmount,
|
||||||
personalDepositFreeAmount: hasPersonalDepositFreeAmount ? personalDepositFreeAmount : null,
|
personalDepositFreeAmount: hasPersonalDepositFreeAmount ? personalDepositFreeAmount : null,
|
||||||
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export type RuntimeConfig = {
|
|||||||
integrationLevel: string
|
integrationLevel: string
|
||||||
retentionDays: number
|
retentionDays: number
|
||||||
}
|
}
|
||||||
|
retention: {
|
||||||
|
rawPayloadDays: number
|
||||||
|
taskEventDays: number
|
||||||
|
webhookEventDays: number
|
||||||
|
auditLogDays: number
|
||||||
|
}
|
||||||
database: {
|
database: {
|
||||||
url: string
|
url: string
|
||||||
ssl: boolean
|
ssl: boolean
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
|||||||
color: 'green',
|
color: 'green',
|
||||||
}
|
}
|
||||||
case 'gift_material_reset':
|
case 'gift_material_reset':
|
||||||
return { title: note ? `客服重置打手好友赠送资料:${note}` : '客服重置打手好友赠送资料', color: 'orange' }
|
return {
|
||||||
|
title: note ? `客服重置打手好友赠送资料:${note}` : '客服重置打手好友赠送资料',
|
||||||
|
color: 'orange',
|
||||||
|
}
|
||||||
|
|
||||||
// —— 验收节点 ——
|
// —— 验收节点 ——
|
||||||
case 'acceptance_submitted': {
|
case 'acceptance_submitted': {
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ function resolveLedgerTagColor(ledgerType: string) {
|
|||||||
].includes(ledgerType)
|
].includes(ledgerType)
|
||||||
)
|
)
|
||||||
return 'gold'
|
return 'gold'
|
||||||
if (['deposit_release', 'deposit_unfreeze', 'reward_unfreeze', 'after_sales_refund'].includes(ledgerType))
|
if (
|
||||||
|
['deposit_release', 'deposit_unfreeze', 'reward_unfreeze', 'after_sales_refund'].includes(
|
||||||
|
ledgerType,
|
||||||
|
)
|
||||||
|
)
|
||||||
return 'green'
|
return 'green'
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -371,9 +371,9 @@ export default function WorkersPanel() {
|
|||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.personalDepositFreeAmount !== null ? (
|
row.personalDepositFreeAmount !== null ? (
|
||||||
<Typography.Text>
|
<Typography.Text>
|
||||||
{formatMoney(row.effectiveDepositFreeAmount)}(等级 {formatMoney(
|
{formatMoney(row.effectiveDepositFreeAmount)}(等级{' '}
|
||||||
row.levelDepositFreeAmount,
|
{formatMoney(row.levelDepositFreeAmount)} + 个人{' '}
|
||||||
)} + 个人 {formatMoney(row.personalDepositFreeAmount)})
|
{formatMoney(row.personalDepositFreeAmount)})
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
|
|||||||
Reference in New Issue
Block a user