-
订阅中心 / 异步投递 / Digest
+
订阅中心 / 异步投递 / 汇总简报
这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy 推送目标;当前投递走异步队列,并支持 retry pending 状态追踪。
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 78b3366..c192928 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
-FROM rust:1.88-bookworm AS builder
+FROM rust:1.91.1-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs
index 5e524d9..3c4efa5 100644
--- a/backend/migration/src/lib.rs
+++ b/backend/migration/src/lib.rs
@@ -36,6 +36,7 @@ mod m20260331_000025_create_post_revisions;
mod m20260331_000026_create_subscriptions;
mod m20260331_000027_create_notification_deliveries;
mod m20260331_000028_expand_subscriptions_and_deliveries;
+mod m20260331_000029_add_subscription_popup_settings_to_site_settings;
pub struct Migrator;
#[async_trait::async_trait]
@@ -76,6 +77,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260331_000026_create_subscriptions::Migration),
Box::new(m20260331_000027_create_notification_deliveries::Migration),
Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration),
+ Box::new(m20260331_000029_add_subscription_popup_settings_to_site_settings::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/backend/migration/src/m20260331_000029_add_subscription_popup_settings_to_site_settings.rs b/backend/migration/src/m20260331_000029_add_subscription_popup_settings_to_site_settings.rs
new file mode 100644
index 0000000..91f8170
--- /dev/null
+++ b/backend/migration/src/m20260331_000029_add_subscription_popup_settings_to_site_settings.rs
@@ -0,0 +1,111 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ let table = Alias::new("site_settings");
+
+ if !manager
+ .has_column("site_settings", "subscription_popup_enabled")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("subscription_popup_enabled"))
+ .boolean()
+ .null()
+ .default(true),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "subscription_popup_title")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("subscription_popup_title"))
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "subscription_popup_description")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("subscription_popup_description"))
+ .text()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "subscription_popup_delay_seconds")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table)
+ .add_column(
+ ColumnDef::new(Alias::new("subscription_popup_delay_seconds"))
+ .integer()
+ .null()
+ .default(18),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ let table = Alias::new("site_settings");
+
+ for column in [
+ "subscription_popup_delay_seconds",
+ "subscription_popup_description",
+ "subscription_popup_title",
+ "subscription_popup_enabled",
+ ] {
+ if manager.has_column("site_settings", column).await? {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .drop_column(Alias::new(column))
+ .to_owned(),
+ )
+ .await?;
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/backend/src/app.rs b/backend/src/app.rs
index 77cd0ae..3fd5303 100644
--- a/backend/src/app.rs
+++ b/backend/src/app.rs
@@ -1,5 +1,8 @@
use async_trait::async_trait;
-use axum::{http::Method, Router as AxumRouter};
+use axum::{
+ http::{header, HeaderName, Method},
+ Router as AxumRouter,
+};
use loco_rs::{
app::{AppContext, Hooks, Initializer},
bgworker::{BackgroundWorker, Queue},
@@ -15,8 +18,8 @@ use migration::Migrator;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
};
-use std::path::Path;
-use tower_http::cors::{Any, CorsLayer};
+use std::{collections::BTreeSet, path::Path};
+use tower_http::cors::CorsLayer;
#[allow(unused_imports)]
use crate::{
@@ -29,6 +32,48 @@ use crate::{
};
pub struct App;
+
+fn normalized_origin(value: &str) -> Option
{
+ let trimmed = value.trim().trim_end_matches('/').to_string();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed)
+ }
+}
+
+fn collect_cors_origins() -> Vec {
+ let mut origins = BTreeSet::new();
+
+ for origin in [
+ "http://127.0.0.1:4321",
+ "http://127.0.0.1:4322",
+ "http://localhost:4321",
+ "http://localhost:4322",
+ ] {
+ origins.insert(origin.to_string());
+ }
+
+ for key in [
+ "APP_BASE_URL",
+ "ADMIN_API_BASE_URL",
+ "ADMIN_FRONTEND_BASE_URL",
+ "PUBLIC_API_BASE_URL",
+ "PUBLIC_FRONTEND_BASE_URL",
+ "TERMI_CORS_ALLOWED_ORIGINS",
+ ] {
+ if let Ok(value) = std::env::var(key) {
+ for origin in value.split([',', ';', ' ']) {
+ if let Some(origin) = normalized_origin(origin) {
+ origins.insert(origin);
+ }
+ }
+ }
+ }
+
+ origins.into_iter().collect()
+}
+
#[async_trait]
impl Hooks for App {
fn app_name() -> &'static str {
@@ -76,8 +121,22 @@ impl Hooks for App {
.add_route(controllers::subscription::routes())
}
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result {
+ let allowed_origins = collect_cors_origins()
+ .into_iter()
+ .filter_map(|origin| origin.parse().ok())
+ .collect::>();
+ let allowed_headers = [
+ header::ACCEPT,
+ header::ACCEPT_LANGUAGE,
+ header::AUTHORIZATION,
+ header::CONTENT_LANGUAGE,
+ header::CONTENT_TYPE,
+ header::COOKIE,
+ header::ORIGIN,
+ HeaderName::from_static("x-requested-with"),
+ ];
let cors = CorsLayer::new()
- .allow_origin(Any)
+ .allow_origin(allowed_origins)
.allow_methods([
Method::GET,
Method::POST,
@@ -85,7 +144,8 @@ impl Hooks for App {
Method::PATCH,
Method::DELETE,
])
- .allow_headers(Any);
+ .allow_headers(allowed_headers)
+ .allow_credentials(true);
Ok(router.layer(cors))
}
diff --git a/backend/src/controllers/admin_api.rs b/backend/src/controllers/admin_api.rs
index 55ea247..ce91ad9 100644
--- a/backend/src/controllers/admin_api.rs
+++ b/backend/src/controllers/admin_api.rs
@@ -44,8 +44,13 @@ pub struct AdminSessionResponse {
pub can_logout: bool,
}
-fn build_session_response(identity: Option) -> AdminSessionResponse {
- let can_logout = matches!(identity.as_ref().map(|item| item.source.as_str()), Some("local"));
+fn build_session_response(
+ identity: Option,
+) -> AdminSessionResponse {
+ let can_logout = matches!(
+ identity.as_ref().map(|item| item.source.as_str()),
+ Some("local")
+ );
AdminSessionResponse {
authenticated: identity.is_some(),
@@ -193,6 +198,10 @@ pub struct AdminSiteSettingsResponse {
pub notification_webhook_url: Option,
pub notification_comment_enabled: bool,
pub notification_friend_link_enabled: bool,
+ pub subscription_popup_enabled: bool,
+ pub subscription_popup_title: String,
+ pub subscription_popup_description: String,
+ pub subscription_popup_delay_seconds: i32,
pub search_synonyms: Vec,
}
@@ -706,6 +715,18 @@ fn build_settings_response(
notification_webhook_url: item.notification_webhook_url,
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
+ .subscription_popup_enabled
+ .unwrap_or_else(site_settings::default_subscription_popup_enabled),
+ subscription_popup_title: item
+ .subscription_popup_title
+ .unwrap_or_else(site_settings::default_subscription_popup_title),
+ subscription_popup_description: item
+ .subscription_popup_description
+ .unwrap_or_else(site_settings::default_subscription_popup_description),
+ subscription_popup_delay_seconds: item
+ .subscription_popup_delay_seconds
+ .unwrap_or_else(site_settings::default_subscription_popup_delay_seconds),
search_synonyms: tech_stack_values(&item.search_synonyms),
}
}
@@ -753,7 +774,10 @@ pub async fn session_login(
#[debug_handler]
pub async fn session_logout(headers: HeaderMap, State(ctx): State) -> Result {
let before = resolve_admin_identity(&headers);
- if matches!(before.as_ref().map(|item| item.source.as_str()), Some("local")) {
+ if matches!(
+ before.as_ref().map(|item| item.source.as_str()),
+ Some("local")
+ ) {
clear_local_session(&headers);
}
@@ -764,7 +788,10 @@ pub async fn session_logout(headers: HeaderMap, State(ctx): State) -
"admin.logout",
"admin_session",
None,
- identity.email.clone().or_else(|| Some(identity.username.clone())),
+ identity
+ .email
+ .clone()
+ .or_else(|| Some(identity.username.clone())),
None,
)
.await?;
@@ -843,10 +870,7 @@ pub async fn dashboard(headers: HeaderMap, State(ctx): State) -> Res
}
}
- let mut recent_posts = all_posts
- .clone()
- .into_iter()
- .collect::>();
+ let mut recent_posts = all_posts.clone().into_iter().collect::>();
recent_posts.sort_by(|left, right| right.created_at.cmp(&left.created_at));
let recent_posts = recent_posts
.into_iter()
@@ -959,13 +983,19 @@ pub async fn dashboard(headers: HeaderMap, State(ctx): State) -> Res
}
#[debug_handler]
-pub async fn analytics_overview(headers: HeaderMap, State(ctx): State) -> Result {
+pub async fn analytics_overview(
+ headers: HeaderMap,
+ State(ctx): State,
+) -> Result {
check_auth(&headers)?;
format::json(analytics::build_admin_analytics(&ctx).await?)
}
#[debug_handler]
-pub async fn get_site_settings(headers: HeaderMap, State(ctx): State) -> Result {
+pub async fn get_site_settings(
+ headers: HeaderMap,
+ State(ctx): State,
+) -> Result {
check_auth(&headers)?;
let current = site_settings::load_current(&ctx).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
@@ -1061,7 +1091,10 @@ pub async fn test_ai_image_provider(
}
#[debug_handler]
-pub async fn test_r2_storage(headers: HeaderMap, State(ctx): State) -> Result {
+pub async fn test_r2_storage(
+ headers: HeaderMap,
+ State(ctx): State,
+) -> Result {
check_auth(&headers)?;
let settings = storage::require_r2_settings(&ctx).await?;
@@ -1278,7 +1311,10 @@ pub async fn replace_media_object(
}
#[debug_handler]
-pub async fn list_comment_blacklist(headers: HeaderMap, State(ctx): State) -> Result {
+pub async fn list_comment_blacklist(
+ headers: HeaderMap,
+ State(ctx): State,
+) -> Result {
check_auth(&headers)?;
let items = comment_blacklist::Entity::find()
diff --git a/backend/src/controllers/site_settings.rs b/backend/src/controllers/site_settings.rs
index 8d26345..a43036e 100644
--- a/backend/src/controllers/site_settings.rs
+++ b/backend/src/controllers/site_settings.rs
@@ -143,6 +143,14 @@ pub struct SiteSettingsPayload {
pub notification_comment_enabled: Option,
#[serde(default, alias = "notificationFriendLinkEnabled")]
pub notification_friend_link_enabled: Option,
+ #[serde(default, alias = "subscriptionPopupEnabled")]
+ pub subscription_popup_enabled: Option,
+ #[serde(default, alias = "subscriptionPopupTitle")]
+ pub subscription_popup_title: Option,
+ #[serde(default, alias = "subscriptionPopupDescription")]
+ pub subscription_popup_description: Option,
+ #[serde(default, alias = "subscriptionPopupDelaySeconds")]
+ pub subscription_popup_delay_seconds: Option,
#[serde(default, alias = "searchSynonyms")]
pub search_synonyms: Option>,
}
@@ -169,6 +177,10 @@ pub struct PublicSiteSettingsResponse {
pub music_playlist: Option,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
+ pub subscription_popup_enabled: bool,
+ pub subscription_popup_title: String,
+ pub subscription_popup_description: String,
+ pub subscription_popup_delay_seconds: i32,
pub seo_default_og_image: Option,
pub seo_default_twitter_handle: Option,
}
@@ -208,6 +220,22 @@ fn normalize_optional_int(value: Option, min: i32, max: i32) -> Option
value.map(|item| item.clamp(min, max))
}
+pub(crate) fn default_subscription_popup_enabled() -> bool {
+ true
+}
+
+pub(crate) fn default_subscription_popup_title() -> String {
+ "订阅更新".to_string()
+}
+
+pub(crate) fn default_subscription_popup_description() -> String {
+ "有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。".to_string()
+}
+
+pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
+ 18
+}
+
fn normalize_string_list(values: Vec) -> Vec {
values
.into_iter()
@@ -569,6 +597,21 @@ impl SiteSettingsPayload {
if let Some(notification_friend_link_enabled) = self.notification_friend_link_enabled {
item.notification_friend_link_enabled = Some(notification_friend_link_enabled);
}
+ if let Some(subscription_popup_enabled) = self.subscription_popup_enabled {
+ item.subscription_popup_enabled = Some(subscription_popup_enabled);
+ }
+ if let Some(subscription_popup_title) = self.subscription_popup_title {
+ item.subscription_popup_title =
+ normalize_optional_string(Some(subscription_popup_title));
+ }
+ if let Some(subscription_popup_description) = self.subscription_popup_description {
+ item.subscription_popup_description =
+ normalize_optional_string(Some(subscription_popup_description));
+ }
+ if self.subscription_popup_delay_seconds.is_some() {
+ item.subscription_popup_delay_seconds =
+ normalize_optional_int(self.subscription_popup_delay_seconds, 3, 120);
+ }
if let Some(search_synonyms) = self.search_synonyms {
let normalized = normalize_string_list(search_synonyms);
item.search_synonyms = (!normalized.is_empty()).then(|| serde_json::json!(normalized));
@@ -684,6 +727,10 @@ fn default_payload() -> SiteSettingsPayload {
notification_webhook_url: None,
notification_comment_enabled: Some(false),
notification_friend_link_enabled: Some(false),
+ subscription_popup_enabled: Some(default_subscription_popup_enabled()),
+ subscription_popup_title: Some(default_subscription_popup_title()),
+ subscription_popup_description: Some(default_subscription_popup_description()),
+ subscription_popup_delay_seconds: Some(default_subscription_popup_delay_seconds()),
search_synonyms: Some(Vec::new()),
}
}
@@ -734,6 +781,18 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
music_playlist: model.music_playlist,
ai_enabled: model.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
+ subscription_popup_enabled: model
+ .subscription_popup_enabled
+ .unwrap_or_else(default_subscription_popup_enabled),
+ subscription_popup_title: model
+ .subscription_popup_title
+ .unwrap_or_else(default_subscription_popup_title),
+ subscription_popup_description: model
+ .subscription_popup_description
+ .unwrap_or_else(default_subscription_popup_description),
+ subscription_popup_delay_seconds: model
+ .subscription_popup_delay_seconds
+ .unwrap_or_else(default_subscription_popup_delay_seconds),
seo_default_og_image: model.seo_default_og_image,
seo_default_twitter_handle: model.seo_default_twitter_handle,
}
@@ -784,7 +843,8 @@ pub async fn home(State(ctx): State) -> Result {
.collect::>();
let content_highlights =
crate::services::analytics::build_public_content_highlights(&ctx, &posts).await?;
- let content_ranges = crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
+ let content_ranges =
+ crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
format::json(HomePageResponse {
site_settings,
diff --git a/backend/src/models/_entities/site_settings.rs b/backend/src/models/_entities/site_settings.rs
index 8d49ab2..83325cd 100644
--- a/backend/src/models/_entities/site_settings.rs
+++ b/backend/src/models/_entities/site_settings.rs
@@ -65,6 +65,11 @@ pub struct Model {
pub notification_webhook_url: Option,
pub notification_comment_enabled: Option,
pub notification_friend_link_enabled: Option,
+ pub subscription_popup_enabled: Option,
+ pub subscription_popup_title: Option,
+ #[sea_orm(column_type = "Text", nullable)]
+ pub subscription_popup_description: Option,
+ pub subscription_popup_delay_seconds: Option,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub search_synonyms: Option,
}
diff --git a/frontend/package.json b/frontend/package.json
index ae7c659..4c99815 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
"astro": "astro"
},
"dependencies": {
+ "@astrojs/markdown-remark": "^7.0.1",
"@astrojs/node": "^10.0.4",
"@astrojs/svelte": "^8.0.3",
"@astrojs/tailwind": "^6.0.2",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 1b9ec81..a286037 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@astrojs/markdown-remark':
+ specifier: ^7.0.1
+ version: 7.0.1
'@astrojs/node':
specifier: ^10.0.4
version: 10.0.4(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
diff --git a/frontend/src/components/SubscriptionPopup.astro b/frontend/src/components/SubscriptionPopup.astro
new file mode 100644
index 0000000..3fa0304
--- /dev/null
+++ b/frontend/src/components/SubscriptionPopup.astro
@@ -0,0 +1,678 @@
+---
+import { resolvePublicApiBaseUrl } from '../lib/api/client';
+import type { SiteSettings } from '../lib/types';
+
+interface Props {
+ requestUrl?: string | URL;
+ siteSettings: SiteSettings;
+}
+
+const { requestUrl, siteSettings } = Astro.props as Props;
+const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
+const popupSettings = siteSettings.subscriptions;
+---
+
+{popupSettings.popupEnabled && (
+
+)}
+
+
+
+
diff --git a/frontend/src/layouts/BaseLayout.astro b/frontend/src/layouts/BaseLayout.astro
index 4b25f1b..1145d8c 100644
--- a/frontend/src/layouts/BaseLayout.astro
+++ b/frontend/src/layouts/BaseLayout.astro
@@ -2,6 +2,7 @@
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
+import SubscriptionPopup from '../components/SubscriptionPopup.astro';
import BackToTop from '../components/interactive/BackToTop.svelte';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
@@ -475,6 +476,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
+