feat(ops): 数据保留清理、CORS 白名单与上传失败回滚
- 新增每日数据保留清理任务:按保留期清空原始报文、删除过期 事件/审计记录,批量幂等可重跑;067 迁移补齐清理索引并清理 冗余索引,check-sql-guard 增加防线 - 生产 CORS 改为显式来源白名单(CORS_ALLOWED_ORIGINS),禁用 通配符,非通配响应补充 Vary: Origin - 文件上传任一步失败时回滚已上传对象,清理失败记录 warn 日志 - 新增 HTTP/CORS 与 PostgreSQL 集成测试;若干文件仅 prettier 重排
This commit is contained in:
@@ -8,6 +8,7 @@ import { createFileAsset, type FileAssetRow } from '../../repositories/file-asse
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
|
||||
const IMAGE_VARIANT_THUMB = 'thumb'
|
||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
@@ -64,74 +65,98 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
const scene = normalizeScene(input.scene)
|
||||
const objectKey = createObjectKey(scene, file.originalname, contentType)
|
||||
const storage = getObjectStorage()
|
||||
await storage.putObject({
|
||||
key: objectKey,
|
||||
content: file.buffer,
|
||||
contentType,
|
||||
metadata: {
|
||||
'original-filename': file.originalname,
|
||||
},
|
||||
})
|
||||
const uploadedKeys: string[] = []
|
||||
try {
|
||||
uploadedKeys.push(objectKey)
|
||||
await storage.putObject({
|
||||
key: objectKey,
|
||||
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)
|
||||
let thumbnailObjectKey = ''
|
||||
let mediumObjectKey = ''
|
||||
|
||||
for (const variant of variants) {
|
||||
try {
|
||||
await storage.putObject({
|
||||
key: variant.key,
|
||||
content: variant.content,
|
||||
contentType: variant.contentType,
|
||||
metadata: {
|
||||
'source-object': objectKey,
|
||||
},
|
||||
})
|
||||
if (variant.name === IMAGE_VARIANT_THUMB) {
|
||||
thumbnailObjectKey = variant.key
|
||||
for (const variant of variants) {
|
||||
try {
|
||||
uploadedKeys.push(variant.key)
|
||||
await storage.putObject({
|
||||
key: variant.key,
|
||||
content: variant.content,
|
||||
contentType: variant.contentType,
|
||||
metadata: {
|
||||
'source-object': objectKey,
|
||||
},
|
||||
})
|
||||
if (variant.name === IMAGE_VARIANT_THUMB) {
|
||||
thumbnailObjectKey = variant.key
|
||||
}
|
||||
if (variant.name === IMAGE_VARIANT_MEDIUM) {
|
||||
mediumObjectKey = variant.key
|
||||
}
|
||||
} catch {
|
||||
// 缩略图失败不阻断原图上传;预览接口会回退到原图。
|
||||
}
|
||||
if (variant.name === IMAGE_VARIANT_MEDIUM) {
|
||||
mediumObjectKey = variant.key
|
||||
}
|
||||
} catch {
|
||||
// 缩略图失败不阻断原图上传;预览接口会回退到原图。
|
||||
}
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fileUrl = buildUnsignedFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
|
||||
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, {
|
||||
const now = nowIso()
|
||||
const fileUrl = buildUnsignedFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
|
||||
const asset = await createFileAsset({
|
||||
objectKey,
|
||||
fileUrl,
|
||||
scene,
|
||||
originalFilename: file.originalname,
|
||||
contentType,
|
||||
sizeBytes: file.buffer.length,
|
||||
thumbnailObjectKey,
|
||||
mediumObjectKey,
|
||||
url: fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
filename: file.originalname,
|
||||
contentType,
|
||||
size: file.buffer.length,
|
||||
}),
|
||||
uploaderType: String(input.uploaderType || '').trim(),
|
||||
uploaderId: String(input.uploaderId || '').trim(),
|
||||
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 {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
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> {
|
||||
await this.ensureBucketReady()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
@@ -233,6 +241,18 @@ export class StorageRouter {
|
||||
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
|
||||
|
||||
@@ -92,3 +92,16 @@ test('normalizeScheduledJobsConfig 支持多账号去重并保留 0 阈值', ()
|
||||
assert.equal(config.jobs[0].intervalSeconds, 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 WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
|
||||
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
|
||||
const DATA_RETENTION_JOB_ID = 'data-retention'
|
||||
|
||||
export function getScheduledJobsFilePath() {
|
||||
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 hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_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) {
|
||||
jobs.push(createDefaultCloudtentaclesHealthJob())
|
||||
@@ -83,6 +85,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
if (!hasDepositUnfreeze) {
|
||||
jobs.push(createDefaultDepositUnfreezeJob())
|
||||
}
|
||||
if (!hasDataRetention) {
|
||||
jobs.push(createDefaultDataRetentionJob())
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
|
||||
@@ -105,6 +110,9 @@ function normalizeScheduledJob(rawValue: unknown) {
|
||||
if (type === 'deposit_unfreeze') {
|
||||
return normalizeDepositUnfreezeJob(rawValue)
|
||||
}
|
||||
if (type === 'data_retention') {
|
||||
return normalizeDataRetentionJob(rawValue)
|
||||
}
|
||||
|
||||
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) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
|
||||
@@ -211,10 +229,21 @@ function createDefaultScheduledJobsConfig() {
|
||||
createDefaultCloudtentaclesHealthJob(),
|
||||
createDefaultWorkOrderTimeoutJob(),
|
||||
createDefaultDepositUnfreezeJob(),
|
||||
createDefaultDataRetentionJob(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultDataRetentionJob() {
|
||||
return {
|
||||
id: DATA_RETENTION_JOB_ID,
|
||||
type: 'data_retention',
|
||||
enabled: true,
|
||||
intervalSeconds: 86400,
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultDepositUnfreezeJob() {
|
||||
return {
|
||||
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 { runDepositUnfreezeJob } from './deposit-unfreeze-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 jobStates = new Map<string, JsonObject>()
|
||||
@@ -150,6 +151,15 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean }
|
||||
jobId,
|
||||
type: job.type,
|
||||
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
|
||||
} catch (error) {
|
||||
@@ -184,6 +194,9 @@ function dispatchJob(job: JsonObject) {
|
||||
if (job.type === 'deposit_unfreeze') {
|
||||
return runDepositUnfreezeJob(job)
|
||||
}
|
||||
if (job.type === 'data_retention') {
|
||||
return runDataRetentionJob()
|
||||
}
|
||||
|
||||
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
|
||||
statusCode: 400,
|
||||
|
||||
@@ -97,7 +97,8 @@ export async function setAdminWorkerPersonalDepositFreeAmount(
|
||||
) {
|
||||
const worker = await getRequiredWorker(workerId)
|
||||
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) {
|
||||
const amount = Number(rawAmount)
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
|
||||
@@ -17,8 +17,8 @@ export function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
personalDepositFreeAmount >= 0
|
||||
return {
|
||||
// 个人额度是在等级额度基础上的追加额度,两者叠加生效。
|
||||
depositFreeAmount: levelDepositFreeAmount +
|
||||
(hasPersonalDepositFreeAmount ? personalDepositFreeAmount : 0),
|
||||
depositFreeAmount:
|
||||
levelDepositFreeAmount + (hasPersonalDepositFreeAmount ? personalDepositFreeAmount : 0),
|
||||
levelDepositFreeAmount,
|
||||
personalDepositFreeAmount: hasPersonalDepositFreeAmount ? personalDepositFreeAmount : null,
|
||||
maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1),
|
||||
|
||||
Reference in New Issue
Block a user