feat: add worker operations and fix gitea actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s

This commit is contained in:
2026-04-02 03:43:37 +08:00
parent ee0bec4a78
commit a516be2e91
37 changed files with 3890 additions and 879 deletions

View File

@@ -897,6 +897,12 @@ function buildReviewRecord(entry, id) {
}
}
function isPublicReviewVisible(review) {
return ['published', 'completed', 'done'].includes(
normalizeText(review?.status).toLowerCase(),
)
}
function createSubscriptionRecord(id, overrides = {}) {
return {
created_at: iso(-id * 6),
@@ -1097,6 +1103,7 @@ function createInitialState() {
],
comment_persona_logs: [],
deliveries: [],
worker_jobs: [],
ai_events: [],
content_events: [],
captcha_tokens: new Map(),
@@ -1111,6 +1118,7 @@ function createInitialState() {
audit: 1,
revision: 1,
delivery: 1,
worker_job: 1,
blacklist: 2,
persona_log: 1,
ai_event: 1,
@@ -1224,6 +1232,156 @@ function createNotificationDelivery({
}
}
function createWorkerJob({
job_kind = 'worker',
worker_name,
display_name = null,
status = 'queued',
queue_name = null,
requested_by = VALID_LOGIN.username,
requested_source = 'mock-admin',
trigger_mode = 'manual',
payload = null,
result = null,
error_text = null,
tags = [],
related_entity_type = null,
related_entity_id = null,
parent_job_id = null,
attempts_count = status === 'queued' ? 0 : 1,
max_attempts = 1,
cancel_requested = false,
queued_at = iso(-1),
started_at = status === 'queued' ? null : iso(-1),
finished_at = status === 'queued' || status === 'running' ? null : iso(-1),
} = {}) {
const record = {
created_at: iso(-1),
updated_at: iso(-1),
id: nextId('worker_job'),
parent_job_id,
job_kind,
worker_name,
display_name,
status,
queue_name,
requested_by,
requested_source,
trigger_mode,
payload,
result,
error_text,
tags: [...tags],
related_entity_type,
related_entity_id: related_entity_id === null ? null : String(related_entity_id),
attempts_count,
max_attempts,
cancel_requested,
queued_at,
started_at,
finished_at,
}
state.worker_jobs.unshift(record)
return record
}
function canCancelWorkerJob(job) {
return !job.cancel_requested && (job.status === 'queued' || job.status === 'running')
}
function canRetryWorkerJob(job) {
return ['failed', 'cancelled', 'succeeded'].includes(job.status)
}
function normalizeWorkerJob(job) {
return {
...clone(job),
can_cancel: canCancelWorkerJob(job),
can_retry: canRetryWorkerJob(job),
}
}
function buildWorkerOverview() {
const jobs = state.worker_jobs
const counters = {
total_jobs: jobs.length,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
active_jobs: 0,
}
const grouped = new Map()
const catalog = [
['worker.download_media', 'worker', '远程媒体下载', '抓取远程图片 / PDF 到媒体库,并回写媒体元数据。', 'media'],
['worker.notification_delivery', 'worker', '通知投递', '执行订阅通知、测试通知与 digest 投递。', 'notifications'],
['task.retry_deliveries', 'task', '重试待投递通知', '扫描 retry_pending 的通知记录并重新入队。', 'maintenance'],
['task.send_weekly_digest', 'task', '发送周报', '根据近期内容生成周报,并为活跃订阅目标入队。', 'digests'],
['task.send_monthly_digest', 'task', '发送月报', '根据近期内容生成月报,并为活跃订阅目标入队。', 'digests'],
].map(([worker_name, job_kind, label, description, queue_name]) => ({
worker_name,
job_kind,
label,
description,
queue_name,
supports_cancel: true,
supports_retry: true,
}))
for (const job of jobs) {
if (Object.hasOwn(counters, job.status)) {
counters[job.status] += 1
}
const existing =
grouped.get(job.worker_name) ||
{
worker_name: job.worker_name,
job_kind: job.job_kind,
label: catalog.find((item) => item.worker_name === job.worker_name)?.label || job.worker_name,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
last_job_at: null,
}
if (Object.hasOwn(existing, job.status)) {
existing[job.status] += 1
}
existing.last_job_at ||= job.created_at
grouped.set(job.worker_name, existing)
}
counters.active_jobs = counters.queued + counters.running
return {
...counters,
worker_stats: Array.from(grouped.values()),
catalog,
}
}
function enqueueNotificationDeliveryJob(delivery, options = {}) {
return createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.notification_delivery',
display_name: `${delivery.event_type}${delivery.target}`,
status: options.status || 'succeeded',
queue_name: 'notifications',
payload: {
delivery_id: delivery.id,
job_id: null,
},
result: options.status === 'failed' ? null : { delivery_id: delivery.id },
error_text: options.status === 'failed' ? options.error_text || 'mock delivery failed' : null,
tags: ['notifications', 'delivery'],
related_entity_type: 'notification_delivery',
related_entity_id: delivery.id,
parent_job_id: options.parent_job_id ?? null,
})
}
function sanitizeFilename(value, fallback = 'upload.bin') {
const normalized = String(value || '').split(/[\\/]/).pop() || fallback
return normalized.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || fallback
@@ -1249,6 +1407,38 @@ function makeMediaRecordFromUpload(key, file) {
}
}
function queueDownloadWorkerJob(payload, mediaRecord) {
return createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.download_media',
display_name: normalizeText(payload.title) || `download ${normalizeText(payload.source_url)}`,
status: 'succeeded',
queue_name: 'media',
payload: {
source_url: payload.source_url,
prefix: payload.prefix || null,
title: payload.title || null,
alt_text: payload.alt_text || null,
caption: payload.caption || null,
tags: Array.isArray(payload.tags) ? payload.tags : [],
notes: payload.notes || null,
job_id: null,
},
result: mediaRecord
? {
key: mediaRecord.key,
url: mediaRecord.url,
size_bytes: mediaRecord.size_bytes,
source_url: normalizeText(payload.source_url),
content_type: mediaRecord.content_type,
}
: null,
tags: ['media', 'download'],
related_entity_type: 'media_download',
related_entity_id: normalizeText(payload.source_url),
})
}
function upsertPostFromPayload(current, payload) {
const next = current ? { ...current } : {}
const title = normalizeText(payload.title) || normalizeText(current?.title) || '未命名文章'
@@ -1549,6 +1739,7 @@ function latestDebugState() {
filters: clone(item.filters),
})),
deliveries: state.deliveries.map((item) => clone(item)),
worker_jobs: state.worker_jobs.map((item) => clone(item)),
media: state.media.map((item) => ({
key: item.key,
title: item.title,
@@ -1765,7 +1956,10 @@ const server = createServer(async (req, res) => {
}
if (pathname === '/api/reviews' && req.method === 'GET') {
json(res, 200, state.reviews.map((item) => clone(item)))
const items = isAuthenticated(req)
? state.reviews
: state.reviews.filter((item) => isPublicReviewVisible(item))
json(res, 200, items.map((item) => clone(item)))
return
}
@@ -1801,7 +1995,7 @@ const server = createServer(async (req, res) => {
if (pathname.match(/^\/api\/reviews\/\d+$/) && req.method === 'GET') {
const id = Number(pathname.split('/').pop())
const review = state.reviews.find((item) => item.id === id)
if (!review) {
if (!review || (!isAuthenticated(req) && !isPublicReviewVisible(review))) {
notFound(res, '评测不存在。')
return
}
@@ -2836,10 +3030,11 @@ const server = createServer(async (req, res) => {
status: 'queued',
})
state.deliveries.unshift(delivery)
const job = enqueueNotificationDeliveryJob(delivery)
record.last_delivery_status = delivery.status
record.last_notified_at = iso(-1)
addAuditLog('subscription.test', 'subscription', record.target, record.id)
json(res, 200, { queued: true, id: record.id, delivery_id: delivery.id })
json(res, 200, { queued: true, id: record.id, delivery_id: delivery.id, job_id: job.id })
return
}
@@ -2855,14 +3050,14 @@ const server = createServer(async (req, res) => {
const period = normalizeText(payload.period) || 'weekly'
const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active')
activeSubscriptions.forEach((subscription) => {
state.deliveries.unshift(
createNotificationDelivery({
subscription,
eventType: `digest.${period}`,
status: 'queued',
payload: { period },
}),
)
const delivery = createNotificationDelivery({
subscription,
eventType: `digest.${period}`,
status: 'queued',
payload: { period },
})
state.deliveries.unshift(delivery)
enqueueNotificationDeliveryJob(delivery)
subscription.last_delivery_status = 'queued'
subscription.last_notified_at = iso(-1)
})
@@ -2876,6 +3071,205 @@ const server = createServer(async (req, res) => {
return
}
if (pathname === '/api/admin/workers/overview' && req.method === 'GET') {
json(res, 200, buildWorkerOverview())
return
}
if (pathname === '/api/admin/workers/jobs' && req.method === 'GET') {
const status = normalizeText(searchParams.get('status'))
const jobKind = normalizeText(searchParams.get('job_kind'))
const workerName = normalizeText(searchParams.get('worker_name'))
const keyword = normalizeText(searchParams.get('search'))
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
let items = [...state.worker_jobs]
if (status) {
items = items.filter((item) => item.status === status)
}
if (jobKind) {
items = items.filter((item) => item.job_kind === jobKind)
}
if (workerName) {
items = items.filter((item) => item.worker_name === workerName)
}
if (keyword) {
items = items.filter((item) =>
[item.worker_name, item.display_name, item.related_entity_type, item.related_entity_id]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword.toLowerCase())),
)
}
const total = items.length
if (limit > 0) {
items = items.slice(0, limit)
}
json(res, 200, { total, jobs: items.map((item) => normalizeWorkerJob(item)) })
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+$/) && req.method === 'GET') {
const id = Number(pathname.split('/').pop())
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
json(res, 200, normalizeWorkerJob(job))
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+\/cancel$/) && req.method === 'POST') {
const id = Number(pathname.split('/')[5])
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
job.cancel_requested = true
if (job.status === 'queued') {
job.status = 'cancelled'
job.finished_at = iso(-1)
job.error_text = 'job cancelled before start'
}
job.updated_at = iso(-1)
addAuditLog('worker.cancel', 'worker_job', job.worker_name, job.id)
json(res, 200, normalizeWorkerJob(job))
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+\/retry$/) && req.method === 'POST') {
const id = Number(pathname.split('/')[5])
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
let nextJob = null
if (job.worker_name === 'task.send_weekly_digest' || job.worker_name === 'task.send_monthly_digest') {
const period = job.worker_name === 'task.send_monthly_digest' ? 'monthly' : 'weekly'
nextJob = createWorkerJob({
job_kind: 'task',
worker_name: job.worker_name,
display_name: period === 'monthly' ? '发送月报' : '发送周报',
status: 'succeeded',
queue_name: 'digests',
payload: { period },
result: {
period,
post_count: state.posts.filter((item) => item.status === 'published').length,
queued: state.subscriptions.filter((item) => item.status === 'active').length,
skipped: state.subscriptions.filter((item) => item.status !== 'active').length,
},
tags: ['digest', period],
related_entity_type: 'subscription_digest',
related_entity_id: period,
parent_job_id: job.id,
trigger_mode: 'retry',
})
} else if (job.worker_name === 'worker.download_media') {
const payload = job.payload || {}
nextJob = createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.download_media',
display_name: job.display_name,
status: 'succeeded',
queue_name: 'media',
payload,
result: job.result,
tags: ['media', 'download'],
related_entity_type: job.related_entity_type,
related_entity_id: job.related_entity_id,
parent_job_id: job.id,
trigger_mode: 'retry',
})
} else {
nextJob = createWorkerJob({
job_kind: job.job_kind,
worker_name: job.worker_name,
display_name: job.display_name,
status: 'succeeded',
queue_name: job.queue_name,
payload: clone(job.payload),
result: clone(job.result),
tags: Array.isArray(job.tags) ? job.tags : [],
related_entity_type: job.related_entity_type,
related_entity_id: job.related_entity_id,
parent_job_id: job.id,
trigger_mode: 'retry',
})
}
addAuditLog('worker.retry', 'worker_job', nextJob.worker_name, nextJob.id, { source_job_id: job.id })
json(res, 200, { queued: true, job: normalizeWorkerJob(nextJob) })
return
}
if (pathname === '/api/admin/workers/tasks/retry-deliveries' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const limit = Number.parseInt(String(payload.limit || '80'), 10) || 80
const retryable = state.deliveries
.filter((item) => item.status === 'retry_pending')
.slice(0, limit)
retryable.forEach((delivery) => {
delivery.status = 'queued'
delivery.updated_at = iso(-1)
delivery.next_retry_at = null
enqueueNotificationDeliveryJob(delivery)
})
const job = createWorkerJob({
job_kind: 'task',
worker_name: 'task.retry_deliveries',
display_name: '重试待投递通知',
status: 'succeeded',
queue_name: 'maintenance',
payload: { limit },
result: { limit, queued: retryable.length },
tags: ['maintenance', 'retry'],
related_entity_type: 'notification_delivery',
related_entity_id: null,
})
addAuditLog('worker.task.retry_deliveries', 'worker_job', job.worker_name, job.id, { limit })
json(res, 200, { queued: true, job: normalizeWorkerJob(job) })
return
}
if (pathname === '/api/admin/workers/tasks/digest' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const period = normalizeText(payload.period) === 'monthly' ? 'monthly' : 'weekly'
const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active')
activeSubscriptions.forEach((subscription) => {
const delivery = createNotificationDelivery({
subscription,
eventType: `digest.${period}`,
status: 'queued',
payload: { period },
})
state.deliveries.unshift(delivery)
enqueueNotificationDeliveryJob(delivery)
})
const job = createWorkerJob({
job_kind: 'task',
worker_name: period === 'monthly' ? 'task.send_monthly_digest' : 'task.send_weekly_digest',
display_name: period === 'monthly' ? '发送月报' : '发送周报',
status: 'succeeded',
queue_name: 'digests',
payload: { period },
result: {
period,
post_count: state.posts.filter((item) => item.status === 'published').length,
queued: activeSubscriptions.length,
skipped: state.subscriptions.length - activeSubscriptions.length,
},
tags: ['digest', period],
related_entity_type: 'subscription_digest',
related_entity_id: period,
})
addAuditLog('worker.task.digest', 'worker_job', job.worker_name, job.id, { period })
json(res, 200, { queued: true, job: normalizeWorkerJob(job) })
return
}
if (pathname === '/api/admin/storage/media' && req.method === 'GET') {
const prefix = normalizeText(searchParams.get('prefix'))
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
@@ -2970,6 +3364,42 @@ const server = createServer(async (req, res) => {
return
}
if (pathname === '/api/admin/storage/media/download' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const sourceUrl = normalizeText(payload.source_url)
if (!sourceUrl) {
badRequest(res, '缺少远程素材地址。')
return
}
const prefix = normalizeText(payload.prefix) || 'uploads/'
const fileName = sanitizeFilename(sourceUrl.split('/').pop(), 'remote-asset.svg')
const key = `${prefix}${Date.now()}-${fileName}`
const title = normalizeText(payload.title) || sanitizeFilename(fileName, 'remote-asset')
const record = {
key,
url: `${MOCK_ORIGIN}/media/${encodeURIComponent(key)}`,
size_bytes: 2048,
last_modified: iso(-1),
title,
alt_text: normalizeText(payload.alt_text) || null,
caption: normalizeText(payload.caption) || null,
tags: Array.isArray(payload.tags) ? payload.tags.filter(Boolean) : [],
notes: normalizeText(payload.notes) || `downloaded from ${sourceUrl}`,
body: `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675"><rect width="1200" height="675" fill="#111827"/><text x="72" y="200" fill="#f8fafc" font-family="monospace" font-size="40">${title}</text><text x="72" y="280" fill="#94a3b8" font-family="monospace" font-size="24">${sourceUrl}</text></svg>`,
content_type: CONTENT_TYPES.svg,
}
state.media.unshift(record)
const job = queueDownloadWorkerJob(payload, record)
addAuditLog('media.download', 'media', sourceUrl, job.id, { key })
json(res, 200, {
queued: true,
job_id: job.id,
status: job.status,
})
return
}
if (pathname === '/api/admin/storage/media/metadata' && req.method === 'PATCH') {
const { json: payload } = await parseRequest(req)
const key = normalizeText(payload.key)