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
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:
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { getDebugState, loginAdmin, resetMockState } from './helpers'
|
||||
import { getDebugState, loginAdmin, MOCK_BASE_URL, resetMockState } from './helpers'
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await resetMockState(request)
|
||||
@@ -39,6 +39,7 @@ test('后台登录、导航与关键模块页面可加载', async ({ page }) =>
|
||||
{ label: '评测', url: /\/reviews$/, text: '《漫长的季节》' },
|
||||
{ label: '媒体库', url: /\/media$/, text: '漫长的季节封面' },
|
||||
{ label: '订阅', url: /\/subscriptions$/, text: 'watcher@example.com' },
|
||||
{ label: 'Workers', url: /\/workers$/, text: '异步 Worker 控制台' },
|
||||
{ label: '审计', url: /\/audit$/, text: 'playwright-smoke' },
|
||||
{ label: '设置', url: /\/settings$/, text: 'InitCool' },
|
||||
]
|
||||
@@ -50,6 +51,17 @@ test('后台登录、导航与关键模块页面可加载', async ({ page }) =>
|
||||
}
|
||||
})
|
||||
|
||||
test('后台 dashboard worker 健康卡片可跳转到带筛选的 workers', async ({ page }) => {
|
||||
await loginAdmin(page)
|
||||
|
||||
await expect(page.locator('main')).toContainText('Worker 活动')
|
||||
|
||||
await page.getByTestId('dashboard-worker-card-failed').click()
|
||||
await expect(page).toHaveURL(/\/workers\?status=failed$/)
|
||||
await expect(page.locator('main')).toContainText('异步 Worker 控制台')
|
||||
await expect(page.locator('main')).toContainText('failed')
|
||||
})
|
||||
|
||||
test('后台可以审核评论和友链,并更新站点设置', async ({ page }) => {
|
||||
await loginAdmin(page)
|
||||
|
||||
@@ -136,8 +148,21 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
|
||||
await expect(row).toContainText('Deep Regression Updated')
|
||||
|
||||
await row.getByTestId(/subscription-test-/).click()
|
||||
await expect(page.getByTestId('subscriptions-last-job')).toBeVisible()
|
||||
await page.getByTestId('subscriptions-last-job').click()
|
||||
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
|
||||
await expect(page.locator('main')).toContainText('subscription.test')
|
||||
|
||||
await page.getByRole('link', { name: '订阅' }).click()
|
||||
await page.getByTestId('subscriptions-send-weekly').click()
|
||||
await expect(page.getByTestId('subscriptions-last-job')).toBeVisible()
|
||||
await page.getByTestId('subscriptions-send-monthly').click()
|
||||
await expect(page.getByTestId(/^subscription-delivery-job-/).first()).toBeVisible()
|
||||
await page.getByTestId(/^subscription-delivery-job-/).first().click()
|
||||
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
|
||||
await expect(page.locator('main')).toContainText('worker.notification_delivery')
|
||||
|
||||
await page.getByRole('link', { name: '订阅' }).click()
|
||||
|
||||
let state = await getDebugState(request)
|
||||
expect(
|
||||
@@ -146,6 +171,9 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
|
||||
).toBeTruthy()
|
||||
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.weekly')).toBeTruthy()
|
||||
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.monthly')).toBeTruthy()
|
||||
expect(
|
||||
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'worker.notification_delivery'),
|
||||
).toBeTruthy()
|
||||
|
||||
await row.getByTestId(/subscription-delete-/).click()
|
||||
await expect(row).toHaveCount(0)
|
||||
@@ -156,6 +184,33 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
test('后台可查看 worker 控制台并执行 digest / retry / job 重跑', async ({ page, request }) => {
|
||||
await loginAdmin(page)
|
||||
await page.getByRole('link', { name: 'Workers' }).click()
|
||||
|
||||
await page.getByTestId('workers-run-weekly').click()
|
||||
await expect(page.locator('main')).toContainText('task.send_weekly_digest')
|
||||
|
||||
await page.getByTestId('workers-retry-job').click()
|
||||
|
||||
let state = await getDebugState(request)
|
||||
expect(
|
||||
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'task.send_weekly_digest'),
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
state.worker_jobs.some(
|
||||
(item: { worker_name: string; parent_job_id: number | null }) =>
|
||||
item.worker_name === 'task.send_weekly_digest' && item.parent_job_id !== null,
|
||||
),
|
||||
).toBeTruthy()
|
||||
|
||||
await page.getByTestId('workers-run-retry').click()
|
||||
state = await getDebugState(request)
|
||||
expect(
|
||||
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'task.retry_deliveries'),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
test('后台可完成文章创建、保存、版本恢复与删除', async ({ page, request }) => {
|
||||
await loginAdmin(page)
|
||||
await page.getByRole('link', { name: '文章' }).click()
|
||||
@@ -209,6 +264,15 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
|
||||
await loginAdmin(page)
|
||||
|
||||
await page.getByRole('link', { name: '媒体库' }).click()
|
||||
await page.getByTestId('media-remote-url').fill(`${MOCK_BASE_URL}/media-files/remote-playwright.svg`)
|
||||
await page.getByTestId('media-remote-title').fill('Remote Playwright Cover')
|
||||
await page.getByTestId('media-remote-download').click()
|
||||
await expect(page.getByTestId('media-last-remote-job')).toBeVisible()
|
||||
await page.getByTestId('media-last-remote-job').click()
|
||||
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
|
||||
await expect(page.locator('main')).toContainText('worker.download_media')
|
||||
await page.getByRole('link', { name: '媒体库' }).click()
|
||||
|
||||
await page.getByTestId('media-upload-input').setInputFiles([
|
||||
buildSvgPayload('deep-regression-cover.svg', 'deep-upload'),
|
||||
])
|
||||
@@ -222,6 +286,13 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
|
||||
await page.getByTestId('media-save-metadata').click()
|
||||
|
||||
let state = await getDebugState(request)
|
||||
expect(
|
||||
state.media.some(
|
||||
(item: { title: string; key: string }) =>
|
||||
item.title === 'Remote Playwright Cover' &&
|
||||
String(item.key || '').includes('post-covers/'),
|
||||
),
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
state.media.some(
|
||||
(item: { title: string; alt_text: string; tags: string[] }) =>
|
||||
|
||||
@@ -24,6 +24,36 @@ test('首页过滤、热门区和文章详情链路可用', async ({ page }) =>
|
||||
await expect(page).toHaveURL(/\/articles\/playwright-regression-workflow$/)
|
||||
await expect(page.getByRole('heading', { name: 'Playwright 回归工作流设计' })).toBeVisible()
|
||||
await expect(page.locator('.paragraph-comment-marker').first()).toBeVisible()
|
||||
|
||||
await page.goto('/categories/frontend-engineering')
|
||||
await expect(page.getByRole('heading', { name: '前端工程' })).toBeVisible()
|
||||
await expect(page.getByText('Astro 终端博客信息架构实战')).toBeVisible()
|
||||
|
||||
await page.goto('/tags/playwright')
|
||||
await expect(page.getByRole('heading', { name: 'Playwright', exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Playwright 回归工作流设计')).toBeVisible()
|
||||
|
||||
await page.goto('/reviews')
|
||||
await expect(page.getByText('《宇宙探索编辑部》')).toHaveCount(0)
|
||||
await page.goto('/reviews/4')
|
||||
await expect(page.getByRole('heading', { name: '评价不存在' })).toBeVisible()
|
||||
|
||||
await page.goto('/reviews/1')
|
||||
await page.getByRole('link', { name: '#年度最佳' }).click()
|
||||
await expect(page).toHaveURL(/\/reviews\?tag=%E5%B9%B4%E5%BA%A6%E6%9C%80%E4%BD%B3$/)
|
||||
await expect(page.getByText('《漫长的季节》')).toBeVisible()
|
||||
|
||||
await page.goto('/reviews/1')
|
||||
await page.getByRole('link', { name: '动画' }).click()
|
||||
await expect(page).toHaveURL(/\/reviews\?type=anime$/)
|
||||
await expect(page.locator('#reviews-subtitle')).toContainText('动画')
|
||||
await expect(page.getByText('《漫长的季节》')).toBeVisible()
|
||||
|
||||
await page.goto('/reviews/1')
|
||||
await page.getByRole('link', { name: '已完成' }).click()
|
||||
await expect(page).toHaveURL(/\/reviews\?status=completed$/)
|
||||
await expect(page.locator('#reviews-subtitle')).toContainText('已完成')
|
||||
await expect(page.getByText('《漫长的季节》')).toBeVisible()
|
||||
})
|
||||
|
||||
test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => {
|
||||
@@ -64,16 +94,27 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
|
||||
expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy()
|
||||
|
||||
await page.goto('/')
|
||||
await page.locator('[data-subscribe-form] input[name="displayName"]').fill('首页订阅用户')
|
||||
await page.locator('[data-subscribe-form] input[name="email"]').fill('inline-subscriber@example.com')
|
||||
await page.locator('[data-subscribe-form] button[type="submit"]').click()
|
||||
await expect(page.locator('[data-subscribe-status]')).toContainText('订阅')
|
||||
|
||||
await page.locator('[data-subscription-popup-open]').click()
|
||||
await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户')
|
||||
await page.locator('[data-subscription-popup-email]').fill('playwright-subscriber@example.com')
|
||||
await page.locator('[data-subscription-popup-form] button[type="submit"]').click()
|
||||
await expect(page.locator('[data-subscription-popup-status]')).toContainText('订阅')
|
||||
|
||||
const subscriptionState = await getDebugState(request)
|
||||
const inlineRecord = subscriptionState.subscriptions.find(
|
||||
(item: { target: string; display_name: string }) => item.target === 'inline-subscriber@example.com',
|
||||
)
|
||||
expect(inlineRecord?.display_name).toBe('首页订阅用户')
|
||||
const latest = subscriptionState.subscriptions.find(
|
||||
(item: { target: string }) => item.target === 'playwright-subscriber@example.com',
|
||||
(item: { target: string; display_name: string }) => item.target === 'playwright-subscriber@example.com',
|
||||
)
|
||||
expect(latest).toBeTruthy()
|
||||
expect(latest.display_name).toBe('弹窗订阅用户')
|
||||
|
||||
await page.goto(`/subscriptions/confirm?token=${encodeURIComponent(latest.confirm_token)}`)
|
||||
await expect(page.getByText('订阅已确认')).toBeVisible()
|
||||
|
||||
Reference in New Issue
Block a user