feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled

This commit is contained in:
2026-04-01 13:22:19 +08:00
parent 669b79cc95
commit 497a9d713d
75 changed files with 6985 additions and 668 deletions

View File

@@ -22,7 +22,7 @@ use crate::{
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
reviews,
},
services::{admin_audit, ai, analytics, comment_guard, content, storage},
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage},
};
#[derive(Clone, Debug, Deserialize)]
@@ -170,6 +170,14 @@ pub struct AdminSiteSettingsResponse {
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_turnstile_enabled: bool,
pub subscription_turnstile_enabled: bool,
pub web_push_enabled: bool,
pub turnstile_site_key: Option<String>,
pub turnstile_secret_key: Option<String>,
pub web_push_vapid_public_key: Option<String>,
pub web_push_vapid_private_key: Option<String>,
pub web_push_vapid_subject: Option<String>,
pub ai_provider: Option<String>,
pub ai_api_base: Option<String>,
pub ai_api_key: Option<String>,
@@ -196,6 +204,7 @@ pub struct AdminSiteSettingsResponse {
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
pub notification_webhook_url: Option<String>,
pub notification_channel_type: String,
pub notification_comment_enabled: bool,
pub notification_friend_link_enabled: bool,
pub subscription_popup_enabled: bool,
@@ -258,6 +267,11 @@ pub struct AdminMediaObjectResponse {
pub url: String,
pub size_bytes: i64,
pub last_modified: Option<String>,
pub title: Option<String>,
pub alt_text: Option<String>,
pub caption: Option<String>,
pub tags: Vec<String>,
pub notes: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -304,6 +318,32 @@ pub struct AdminMediaReplaceResponse {
pub url: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaMetadataPayload {
pub key: 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 AdminMediaMetadataResponse {
pub saved: bool,
pub key: String,
pub title: Option<String>,
pub alt_text: Option<String>,
pub caption: Option<String>,
pub tags: Vec<String>,
pub notes: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaListQuery {
pub prefix: Option<String>,
@@ -634,6 +674,25 @@ fn normalize_media_key(value: Option<String>) -> Option<String> {
})
}
fn build_media_object_response(
item: storage::StoredObjectSummary,
metadata: Option<&crate::models::_entities::media_assets::Model>,
) -> AdminMediaObjectResponse {
AdminMediaObjectResponse {
key: item.key,
url: item.url,
size_bytes: item.size_bytes,
last_modified: item.last_modified,
title: metadata.and_then(|entry| entry.title.clone()),
alt_text: metadata.and_then(|entry| entry.alt_text.clone()),
caption: metadata.and_then(|entry| entry.caption.clone()),
tags: metadata
.map(media_assets::tag_list)
.unwrap_or_default(),
notes: metadata.and_then(|entry| entry.notes.clone()),
}
}
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
value
.as_ref()
@@ -665,6 +724,11 @@ fn build_settings_response(
) -> AdminSiteSettingsResponse {
let ai_providers = site_settings::ai_provider_configs(&item);
let ai_active_provider_id = site_settings::active_ai_provider_id(&item);
let turnstile_site_key = crate::services::turnstile::site_key(&item);
let turnstile_secret_key = crate::services::turnstile::secret_key(&item);
let web_push_vapid_public_key = crate::services::web_push::public_key(&item);
let web_push_vapid_private_key = crate::services::web_push::private_key(&item);
let web_push_vapid_subject = crate::services::web_push::vapid_subject(&item);
AdminSiteSettingsResponse {
id: item.id,
@@ -687,6 +751,14 @@ fn build_settings_response(
music_playlist: music_playlist_values(&item.music_playlist),
ai_enabled: item.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
comment_turnstile_enabled: item.comment_turnstile_enabled.unwrap_or(false),
subscription_turnstile_enabled: item.subscription_turnstile_enabled.unwrap_or(false),
web_push_enabled: item.web_push_enabled.unwrap_or(false),
turnstile_site_key,
turnstile_secret_key,
web_push_vapid_public_key,
web_push_vapid_private_key,
web_push_vapid_subject,
ai_provider: item.ai_provider,
ai_api_base: item.ai_api_base,
ai_api_key: item.ai_api_key,
@@ -713,6 +785,9 @@ fn build_settings_response(
seo_default_og_image: item.seo_default_og_image,
seo_default_twitter_handle: item.seo_default_twitter_handle,
notification_webhook_url: item.notification_webhook_url,
notification_channel_type: item
.notification_channel_type
.unwrap_or_else(|| "webhook".to_string()),
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
subscription_popup_enabled: item
@@ -1115,14 +1190,18 @@ pub async fn list_media_objects(
check_auth(&headers)?;
let settings = storage::require_r2_settings(&ctx).await?;
let items = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
.await?
let objects = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
.await?;
let keys = objects
.iter()
.map(|item| item.key.clone())
.collect::<Vec<_>>();
let metadata_map = media_assets::list_by_keys(&ctx, &keys).await?;
let items = objects
.into_iter()
.map(|item| AdminMediaObjectResponse {
key: item.key,
url: item.url,
size_bytes: item.size_bytes,
last_modified: item.last_modified,
.map(|item| {
let metadata = metadata_map.get(&item.key);
build_media_object_response(item, metadata)
})
.collect::<Vec<_>>();
@@ -1148,6 +1227,9 @@ pub async fn delete_media_object(
}
storage::delete_object(&ctx, key).await?;
if let Err(error) = media_assets::delete_by_key(&ctx, key).await {
tracing::warn!(?error, key, "failed to delete media metadata after object deletion");
}
format::json(AdminMediaDeleteResponse {
deleted: true,
@@ -1241,7 +1323,12 @@ pub async fn batch_delete_media_objects(
for key in keys {
match storage::delete_object(&ctx, &key).await {
Ok(()) => deleted.push(key),
Ok(()) => {
if let Err(error) = media_assets::delete_by_key(&ctx, &key).await {
tracing::warn!(?error, key, "failed to delete media metadata after batch removal");
}
deleted.push(key)
}
Err(_) => failed.push(key),
}
}
@@ -1249,6 +1336,43 @@ pub async fn batch_delete_media_objects(
format::json(AdminMediaBatchDeleteResponse { deleted, failed })
}
#[debug_handler]
pub async fn update_media_object_metadata(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<AdminMediaMetadataPayload>,
) -> Result<Response> {
check_auth(&headers)?;
let key = payload.key.trim();
if key.is_empty() {
return Err(Error::BadRequest("缺少对象 key".to_string()));
}
let metadata = media_assets::upsert_by_key(
&ctx,
key,
media_assets::MediaAssetMetadataInput {
title: payload.title,
alt_text: payload.alt_text,
caption: payload.caption,
tags: payload.tags,
notes: payload.notes,
},
)
.await?;
format::json(AdminMediaMetadataResponse {
saved: true,
key: metadata.object_key.clone(),
title: metadata.title.clone(),
alt_text: metadata.alt_text.clone(),
caption: metadata.caption.clone(),
tags: media_assets::tag_list(&metadata),
notes: metadata.notes.clone(),
})
}
#[debug_handler]
pub async fn replace_media_object(
headers: HeaderMap,
@@ -1831,6 +1955,7 @@ pub fn routes() -> Routes {
"/storage/media/batch-delete",
post(batch_delete_media_objects),
)
.add("/storage/media/metadata", patch(update_media_object_metadata))
.add("/storage/media/replace", post(replace_media_object))
.add(
"/comments/blacklist",