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

@@ -22,7 +22,10 @@ use crate::{
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
reviews,
},
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage},
services::{
admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs,
},
workers::downloader::DownloadWorkerArgs,
};
#[derive(Clone, Debug, Deserialize)]
@@ -346,6 +349,30 @@ pub struct AdminMediaMetadataResponse {
pub notes: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaDownloadPayload {
pub source_url: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub alt_text: Option<String>,
#[serde(default)]
pub caption: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDownloadResponse {
pub queued: bool,
pub job_id: i32,
pub status: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaListQuery {
pub prefix: Option<String>,
@@ -1457,6 +1484,55 @@ pub async fn replace_media_object(
})
}
#[debug_handler]
pub async fn download_media_object(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<AdminMediaDownloadPayload>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let worker_args = DownloadWorkerArgs {
source_url: payload.source_url.clone(),
prefix: payload.prefix.clone(),
title: payload.title.clone(),
alt_text: payload.alt_text.clone(),
caption: payload.caption.clone(),
tags: payload.tags.unwrap_or_default(),
notes: payload.notes.clone(),
job_id: None,
};
let job = worker_jobs::queue_download_job(
&ctx,
&worker_args,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"media.download",
"media",
Some(job.id.to_string()),
Some(payload.source_url.clone()),
Some(serde_json::json!({
"job_id": job.id,
"queued": true,
"source_url": payload.source_url,
})),
)
.await?;
format::json(AdminMediaDownloadResponse {
queued: true,
job_id: job.id,
status: job.status,
})
}
#[debug_handler]
pub async fn list_comment_blacklist(
headers: HeaderMap,
@@ -1982,6 +2058,7 @@ pub fn routes() -> Routes {
"/storage/media/metadata",
patch(update_media_object_metadata),
)
.add("/storage/media/download", post(download_media_object))
.add("/storage/media/replace", post(replace_media_object))
.add(
"/comments/blacklist",

View File

@@ -13,7 +13,7 @@ use crate::{
},
services::{
admin_audit, backups, post_revisions as revision_service,
subscriptions as subscription_service,
subscriptions as subscription_service, worker_jobs,
},
};
@@ -35,6 +35,15 @@ pub struct DeliveriesQuery {
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WorkerJobsQuery {
pub status: Option<String>,
pub job_kind: Option<String>,
pub worker_name: Option<String>,
pub search: Option<String>,
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionPayload {
#[serde(alias = "channelType")]
@@ -85,6 +94,11 @@ pub struct DigestDispatchRequest {
pub period: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct RetryDeliveriesRequest {
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SiteBackupImportRequest {
pub backup: backups::SiteBackupDocument,
@@ -132,6 +146,12 @@ pub struct DeliveryListResponse {
pub deliveries: Vec<notification_deliveries::Model>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerTaskActionResponse {
pub queued: bool,
pub job: worker_jobs::WorkerJobRecord,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
@@ -408,6 +428,13 @@ pub async fn test_subscription(
.ok_or(Error::NotFound)?;
let delivery = subscription_service::send_test_notification(&ctx, &item).await?;
let job = worker_jobs::find_latest_job_by_related_entity(
&ctx,
"notification_delivery",
&delivery.id.to_string(),
Some(worker_jobs::WORKER_NOTIFICATION_DELIVERY),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
@@ -419,7 +446,12 @@ pub async fn test_subscription(
)
.await?;
format::json(serde_json::json!({ "queued": true, "id": item.id, "delivery_id": delivery.id }))
format::json(serde_json::json!({
"queued": true,
"id": item.id,
"delivery_id": delivery.id,
"job_id": job.as_ref().map(|value| value.id),
}))
}
#[debug_handler]
@@ -450,6 +482,162 @@ pub async fn send_subscription_digest(
format::json(summary)
}
#[debug_handler]
pub async fn workers_overview(
headers: HeaderMap,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(worker_jobs::get_overview(&ctx).await?)
}
#[debug_handler]
pub async fn list_worker_jobs(
headers: HeaderMap,
Query(query): Query<WorkerJobsQuery>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(
worker_jobs::list_jobs(
&ctx,
worker_jobs::WorkerJobListQuery {
status: query.status,
job_kind: query.job_kind,
worker_name: query.worker_name,
search: query.search,
limit: query.limit,
},
)
.await?,
)
}
#[debug_handler]
pub async fn get_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(worker_jobs::get_job_record(&ctx, id).await?)
}
#[debug_handler]
pub async fn cancel_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let updated = worker_jobs::request_cancel(&ctx, id).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.cancel",
"worker_job",
Some(id.to_string()),
Some(updated.worker_name.clone()),
Some(serde_json::json!({ "status": updated.status })),
)
.await?;
format::json(updated)
}
#[debug_handler]
pub async fn retry_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let job = worker_jobs::retry_job(
&ctx,
id,
Some(actor.username.clone()),
Some(actor.source.clone()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.retry",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "source_job_id": id })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn run_retry_deliveries_job(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<RetryDeliveriesRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let job = worker_jobs::spawn_retry_deliveries_task(
&ctx,
payload.limit,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.task.retry_deliveries",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "limit": payload.limit })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn run_digest_worker_job(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<DigestDispatchRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let period = payload.period.unwrap_or_else(|| "weekly".to_string());
let job = worker_jobs::spawn_digest_task(
&ctx,
&period,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.task.digest",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "period": period })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn export_site_backup(
headers: HeaderMap,
@@ -481,6 +669,13 @@ pub fn routes() -> Routes {
.add("/subscriptions/digest", post(send_subscription_digest))
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
.add("/subscriptions/{id}/test", post(test_subscription))
.add("/workers/overview", get(workers_overview))
.add("/workers/jobs", get(list_worker_jobs))
.add("/workers/jobs/{id}", get(get_worker_job))
.add("/workers/jobs/{id}/cancel", post(cancel_worker_job))
.add("/workers/jobs/{id}/retry", post(retry_worker_job))
.add("/workers/tasks/retry-deliveries", post(run_retry_deliveries_job))
.add("/workers/tasks/digest", post(run_digest_worker_job))
.add("/site-backup/export", get(export_site_backup))
.add("/site-backup/import", post(import_site_backup))
}

View File

@@ -7,12 +7,19 @@ use sea_orm::{EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use crate::{
controllers::admin::check_auth,
controllers::admin::{check_auth, resolve_admin_identity},
models::_entities::reviews::{self, Entity as ReviewEntity},
services::{admin_audit, storage},
};
#[derive(Serialize, Deserialize, Debug)]
fn is_public_review_status(status: Option<&str>) -> bool {
matches!(
status.unwrap_or_default().trim().to_ascii_lowercase().as_str(),
"published" | "completed" | "done"
)
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CreateReviewRequest {
pub title: String,
pub review_type: String,
@@ -25,7 +32,7 @@ pub struct CreateReviewRequest {
pub link_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UpdateReviewRequest {
pub title: Option<String>,
pub review_type: Option<String>,
@@ -38,23 +45,30 @@ pub struct UpdateReviewRequest {
pub link_url: Option<String>,
}
pub async fn list(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
pub async fn list(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
let include_private = resolve_admin_identity(&headers).is_some();
let reviews = ReviewEntity::find()
.order_by_desc(reviews::Column::CreatedAt)
.all(&ctx.db)
.await?;
.await?
.into_iter()
.filter(|review| include_private || is_public_review_status(review.status.as_deref()))
.collect::<Vec<_>>();
format::json(reviews)
}
pub async fn get_one(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<impl IntoResponse> {
let include_private = resolve_admin_identity(&headers).is_some();
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
match review {
Some(r) => format::json(r),
Some(r) if include_private || is_public_review_status(r.status.as_deref()) => format::json(r),
Some(_) => Err(Error::NotFound),
None => Err(Error::NotFound),
}
}