Compare commits
5 Commits
67212a52b3
...
10ef52bede
| Author | SHA1 | Date | |
|---|---|---|---|
| 10ef52bede | |||
| 694e198331 | |||
| 2c7319c220 | |||
| a3ed0a2b4e | |||
| b24cfdcabc |
114
.env.example
114
.env.example
@ -1,60 +1,112 @@
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil
|
||||
|
||||
# tracing 日志过滤器。支持逗号分隔的细粒度语法,例如:
|
||||
# RUST_LOG=info 全局 info 级
|
||||
# RUST_LOG=info,yggdrasil=debug 本项目 debug,其余 info
|
||||
# RUST_LOG=warn,hyper=warn,sqlx=warn 降噪第三方库
|
||||
# 不设时默认 info(见 main.rs)。
|
||||
RUST_LOG=info
|
||||
|
||||
# Rate Limit — 严格限流(登录、注册)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Rate Limit 限流配置
|
||||
#
|
||||
# 采用 token bucket(令牌桶)模型:
|
||||
# PER_SEC = 稳态补充速率(每秒补充的令牌数)
|
||||
# BURST = 桶容量(允许瞬间累积的最大请求数)
|
||||
# 例如 PER_SEC=1、BURST=5:每秒补 1 个令牌,最多攒 5 个,可短时 1 秒内打 5 个请求。
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 严格限流:覆盖登录、注册、评论预检、搜索等敏感/查询接口。
|
||||
RATE_LIMIT_STRICT_PER_SEC=1
|
||||
RATE_LIMIT_STRICT_BURST=5
|
||||
# Rate Limit — 上传限流(图片上传)
|
||||
# 上传限流:仅 /api/upload(图片上传)。
|
||||
RATE_LIMIT_UPLOAD_PER_SEC=2
|
||||
RATE_LIMIT_UPLOAD_BURST=15
|
||||
# Rate Limit — 图片访问限流(/uploads/*)
|
||||
# 图片访问限流:仅 GET /uploads/*(图片读取与处理)。
|
||||
RATE_LIMIT_IMAGE_PER_SEC=10
|
||||
RATE_LIMIT_IMAGE_BURST=50
|
||||
# 评论限流:仅 POST 创建评论(与上面的“评论预检”不是同一个桶)。
|
||||
RATE_LIMIT_COMMENT_PER_SEC=1
|
||||
RATE_LIMIT_COMMENT_BURST=5
|
||||
# 宽松桶:当真实客户端 IP 无法识别(值为 "unknown")时,由 check_strict_limit 自动改用此桶。
|
||||
# 触发条件通常是 TRUSTED_PROXY_COUNT=0 且调用方为 Dioxus server function(拿不到 TCP 对端地址)。
|
||||
# 此时所有匿名请求共享同一个桶,因此阈值必须更高,否则会误杀正常用户。
|
||||
RATE_LIMIT_UNKNOWN_PER_SEC=30
|
||||
RATE_LIMIT_UNKNOWN_BURST=100
|
||||
|
||||
# Security
|
||||
# Trusted origin for CSRF checks on write requests (POST/PUT/PATCH/DELETE).
|
||||
# Set to your production origin, e.g. https://your-domain.example.
|
||||
# Unset: falls back to the request Host header + X-Forwarded-Proto (behind a reverse proxy).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Security 安全相关
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 写请求(POST/PUT/PATCH/DELETE)CSRF 校验的可信来源。
|
||||
# 生产环境设为你的源站,例如 https://your-domain.example。
|
||||
# 不设:回退到请求的 Host 头 + X-Forwarded-Proto。
|
||||
# ⚠️ 安全提示:反向代理后若 Host 头可被客户端影响,回退路径可能被 CSRF 绕过。
|
||||
# 生产环境强烈建议显式设置此变量。
|
||||
APP_BASE_URL=
|
||||
# Set true/1/yes to add the Secure flag to the session cookie (enable in HTTPS production).
|
||||
# 是否给会话 cookie 加 Secure 标志。识别 true/1/yes(其余均视为 false)。
|
||||
# 启用后浏览器仅在 HTTPS 下发送 cookie,HTTP 生产环境必开。
|
||||
COOKIE_SECURE=false
|
||||
# Number of reverse proxies in front of the app; used to extract the real client IP
|
||||
# from X-Forwarded-For. 0 when serving directly; 1 behind one proxy (e.g. nginx/Caddy).
|
||||
# 应用前方的反向代理层数,用于从 X-Forwarded-For 提取真实客户端 IP。
|
||||
# 直接对外服务时为 0;经过一层代理(如 nginx/Caddy)时为 1。
|
||||
# ⚠️ 设错的安全后果:
|
||||
# 设得比实际大 → 信任客户端伪造的 IP,限流可被绕过;
|
||||
# 设得比实际小 → 取到代理 IP 而非客户端 IP,限流对错对象。
|
||||
TRUSTED_PROXY_COUNT=0
|
||||
# Per-query timeout in seconds; slow queries are canceled to protect the connection pool.
|
||||
# 单条 SQL 查询的服务端超时秒数。超时由 PostgreSQL 服务端取消该查询(不是客户端断连)。
|
||||
# 仅对经连接池(DB_POOL)建立的连接生效;作用是防止单条慢查询长时间占用连接拖垮池。
|
||||
# 需注意:PostgreSQL 的总连接数受 max_connections 限制,本超时不影响已占连接的计费。
|
||||
STATEMENT_TIMEOUT_SECS=30
|
||||
|
||||
# WebP encoding configuration
|
||||
# Quality: 0.0 (smallest) to 100.0 (best), default 85.0
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# WebP 编码配置(仅上传转码 / 图片格式转换时生效)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 质量:0.0(最小体积)到 100.0(最佳质量),默认 85.0。
|
||||
# 越界值会被 clamp(截断)到合法范围并打 WARN 日志,不会报错。
|
||||
WEBP_QUALITY=85.0
|
||||
# Method: 0 (fastest) to 6 (best quality), default 2
|
||||
# 编码方法:0(最快)到 6(最佳质量/压缩率),默认 2。
|
||||
# 越界值同样 clamp 到 0–6。
|
||||
WEBP_METHOD=2
|
||||
|
||||
# Maximum concurrent sessions per user (default: 5, minimum: 1)
|
||||
# 每用户最大并发会话数(默认 5,最小 1)。
|
||||
# 超过上限时按最旧优先删除(LRU 式淘汰)——新设备登录会让最老的会话自动失效。
|
||||
MAX_SESSIONS_PER_USER=5
|
||||
|
||||
# Database connection pool size (default: 20)
|
||||
# 数据库连接池大小(默认 20)。
|
||||
# 不应超过 PostgreSQL 的 max_connections;多实例部署时按 (max_connections / 实例数) 估上限。
|
||||
DB_POOL_SIZE=20
|
||||
|
||||
# SSR page cache duration in seconds (default: 3600).
|
||||
# src/ssr_cache.rs maintains a global generation counter bumped on every post write, but
|
||||
# Dioxus 0.7 does not expose an API to wire it into the incremental SSR cache key. Until such
|
||||
# an API is available, this TTL is the only effective SSR cache invalidation mechanism.
|
||||
# 启动时数据库连接重试窗口,单位秒(默认 30)。
|
||||
# 服务器在启动期间等待 PostgreSQL 可达的最长时间,超时则友好退出(不 panic)。
|
||||
# 内部以 500ms 固定间隔轮询,因此 30s 约重试 60 次。
|
||||
# 适用于 DB 启动比 app 慢的场景(docker-compose 无 healthcheck、本机冷启动 Postgres 等)。
|
||||
# 仅影响启动;运行时连接失败走独立的快速失败策略(1.6s、指数退避),以避免级联故障。
|
||||
MIGRATE_STARTUP_TIMEOUT_SECS=30
|
||||
|
||||
# SSR 页面缓存时长,单位秒(默认 3600,即 1 小时)。
|
||||
# src/ssr_cache.rs 维护了一个全局 generation 计数器,每次文章写入时自增,
|
||||
# 但 Dioxus 0.7 暴露的 API 无法把它接入增量 SSR 缓存的 key。在该 API 可用前,
|
||||
# 此 TTL 是唯一有效的 SSR 缓存失效手段——意味着文章更新后最长可能滞后 TTL 秒才可见。
|
||||
SSR_CACHE_SECS=3600
|
||||
|
||||
# Compression algorithms for HTTP responses.
|
||||
# Comma-separated list, case-insensitive. Supported: gzip, brotli (or br), deflate, zstd.
|
||||
# Use "all" to enable everything (default when unset); use "none" or "off" to disable.
|
||||
# HTTP 响应压缩算法。逗号分隔,大小写不敏感。
|
||||
# 支持:gzip、brotli(可简写为 br)、deflate、zstd。
|
||||
# 示例值(四项全开)等价于 "all",也是不设环境变量时的默认。
|
||||
# 用 "none" 或 "off" 关闭压缩。
|
||||
COMPRESSION_ALGORITHMS=gzip,brotli,deflate,zstd
|
||||
|
||||
# Image serving cache headers (hardcoded defaults)
|
||||
# Uploaded image assets are served with Cache-Control: public, max-age=31536000, immutable.
|
||||
# Processed variants (?w=, ?format=, etc.) are cached for 24 hours.
|
||||
# To invalidate a cached raw upload, change its file path.
|
||||
# To refresh a processed variant, change its processing parameters.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 图片响应缓存头(硬编码默认值,非环境变量)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 上传的原始图片资源:Cache-Control: public, max-age=31536000, immutable。
|
||||
# 处理变体(?w=、?format= 等查询参数生成的衍生图):缓存 24 小时。
|
||||
# 失效方式:
|
||||
# - 原始上传:更改其文件路径;
|
||||
# - 处理变体:更改处理参数(查询串不同即视为新资源)。
|
||||
|
||||
# Image disk cache limits
|
||||
# Max total size in MB (default: 1024)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 图片磁盘缓存上限(uploads/.cache/,后台清理任务每小时扫描一次)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 最大总容量,单位 MB(默认 1024)。超限时按修改时间删除最旧的文件。
|
||||
IMAGE_DISK_CACHE_MAX_MB=1024
|
||||
# Max file age in hours before forced deletion (default: 168)
|
||||
# 文件最大保留时长,单位小时(默认 168,即 7 天)。超期的文件优先删除。
|
||||
# ⚠️ 清理任务每小时运行一次,改了不会立即生效,最长需等 1 小时。
|
||||
IMAGE_DISK_CACHE_MAX_AGE_HOURS=168
|
||||
|
||||
@ -41,7 +41,12 @@ RATE_LIMIT_UPLOAD_PER_SEC=2
|
||||
RATE_LIMIT_UPLOAD_BURST=15
|
||||
RATE_LIMIT_IMAGE_PER_SEC=10
|
||||
RATE_LIMIT_IMAGE_BURST=50
|
||||
RATE_LIMIT_COMMENT_PER_SEC=1 # comment posting
|
||||
RATE_LIMIT_COMMENT_BURST=5
|
||||
RATE_LIMIT_UNKNOWN_PER_SEC=30 # fallback bucket when real client IP can't be determined
|
||||
RATE_LIMIT_UNKNOWN_BURST=100
|
||||
DB_POOL_SIZE=20 # database connection pool size
|
||||
MIGRATE_STARTUP_TIMEOUT_SECS=30 # how long startup waits for PostgreSQL before giving up
|
||||
STATEMENT_TIMEOUT_SECS=30 # per-query timeout; slow queries are canceled to protect the pool
|
||||
SSR_CACHE_SECS=3600 # incremental SSR cache TTL
|
||||
```
|
||||
|
||||
@ -21,12 +21,13 @@ pub async fn create_comment(
|
||||
author_email: String,
|
||||
author_url: Option<String>,
|
||||
content_md: String,
|
||||
honeypot: String,
|
||||
) -> Result<CommentResponse, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::comments::helpers::{
|
||||
compute_content_hash, validate_comment_content, validate_comment_email,
|
||||
validate_comment_name, validate_comment_url,
|
||||
validate_comment_honeypot, validate_comment_name, validate_comment_url,
|
||||
};
|
||||
use crate::api::error::AppError;
|
||||
use crate::cache;
|
||||
@ -48,6 +49,18 @@ pub async fn create_comment(
|
||||
}
|
||||
}
|
||||
|
||||
// 蜜罐字段二次校验:禁用 JS 的机器人可能绕过前端拦截,这里作为服务端防线。
|
||||
if let Err(e) = validate_comment_honeypot(&honeypot) {
|
||||
return Ok(CommentResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
error_code: Some("spam_detected".into()),
|
||||
comment_id: None,
|
||||
avatar_url: None,
|
||||
depth: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 依次校验昵称、邮箱、网址与评论内容。
|
||||
if let Err(e) = validate_comment_name(&author_name) {
|
||||
return Ok(CommentResponse {
|
||||
|
||||
@ -158,6 +158,19 @@ pub fn validate_comment_content(content: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验蜜罐字段:正常用户蜜罐为空,机器人可能填入任意内容。
|
||||
///
|
||||
/// 为空视为通过;一旦被填入内容即判定为机器人提交并拒绝。
|
||||
/// 这是禁用 JS / 无视前端校验的机器人也能在服务端被拦下的防线。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn validate_comment_honeypot(value: &str) -> Result<(), String> {
|
||||
if value.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("评论提交异常".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算评论内容哈希,用于检测短时间内的重复提交。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn compute_content_hash(
|
||||
@ -400,4 +413,15 @@ mod tests {
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_honeypot_empty_is_ok() {
|
||||
assert!(validate_comment_honeypot("").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_comment_honeypot_filled_is_err() {
|
||||
assert!(validate_comment_honeypot("anything").is_err());
|
||||
assert!(validate_comment_honeypot(" ").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,21 +6,32 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::api::comments::create_comment;
|
||||
use crate::components::comments::section::CommentContext;
|
||||
use crate::components::forms::{AlertBox, BUTTON_PRIMARY_CLASS, INPUT_CLASS};
|
||||
use crate::components::forms::{AlertBox, INPUT_CLASS};
|
||||
use crate::hooks::comment_storage::{self, PendingComment};
|
||||
|
||||
/// 评论提交按钮样式:去掉全宽,改为内联宽度并右对齐。
|
||||
///
|
||||
/// 与 `BUTTON_PRIMARY_CLASS` 视觉一致,但不含 `w-full`,并把 `px-4` 加宽为 `px-6`,
|
||||
/// 使按钮宽度跟随文字、更适合文章页内联场景。
|
||||
const COMMENT_SUBMIT_CLASS: &str = "py-2.5 px-6 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer";
|
||||
|
||||
/// 评论表单组件,用于顶层评论或回复评论。
|
||||
///
|
||||
/// Props:
|
||||
/// - `post_id`:所属文章 ID
|
||||
/// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论
|
||||
/// - `parent_indent`:回复时父评论的缩进像素值,用于用负 margin 把表单拉回内容区左边缘
|
||||
///
|
||||
/// 关键事件:
|
||||
/// - 挂载时从本地存储恢复上次填写的作者信息
|
||||
/// - 提交时校验必填项与蜜罐字段
|
||||
/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
|
||||
#[component]
|
||||
pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
pub fn CommentForm(
|
||||
post_id: i32,
|
||||
parent_id: Option<i64>,
|
||||
parent_indent: Option<i32>,
|
||||
) -> Element {
|
||||
let ctx: CommentContext = use_context();
|
||||
let mut active_reply = ctx.active_reply;
|
||||
let mut refresh_trigger = ctx.refresh_trigger;
|
||||
@ -57,9 +68,22 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
|
||||
let is_reply = parent_id.is_some();
|
||||
|
||||
// 用于区分顶层表单与多个回复表单的 id 后缀,保证页面内 label/for 关联唯一。
|
||||
let id_suffix = match parent_id {
|
||||
Some(pid) => pid.to_string(),
|
||||
None => "root".to_string(),
|
||||
};
|
||||
|
||||
// 回复表单抵消父评论缩进,让表单回到内容区左边缘,避免深层回复时被越挤越右。
|
||||
let negative_margin = match (is_reply, parent_indent) {
|
||||
(true, Some(px)) if px > 0 => format!("margin-left: -{px}px;"),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" },
|
||||
style: "{negative_margin}",
|
||||
role: "form",
|
||||
aria_label: if is_reply { "回复评论" } else { "发表评论" },
|
||||
|
||||
@ -72,10 +96,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
div { class: "space-y-3",
|
||||
div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
label {
|
||||
r#for: "comment-name-{id_suffix}",
|
||||
class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"昵称 *"
|
||||
}
|
||||
input {
|
||||
id: "comment-name-{id_suffix}",
|
||||
class: INPUT_CLASS,
|
||||
r#type: "text",
|
||||
placeholder: "你的昵称",
|
||||
@ -85,10 +112,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
label {
|
||||
r#for: "comment-email-{id_suffix}",
|
||||
class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"邮箱 *"
|
||||
}
|
||||
input {
|
||||
id: "comment-email-{id_suffix}",
|
||||
class: INPUT_CLASS,
|
||||
r#type: "email",
|
||||
placeholder: "your@email.com",
|
||||
@ -99,10 +129,13 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
label {
|
||||
r#for: "comment-url-{id_suffix}",
|
||||
class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"网站"
|
||||
}
|
||||
input {
|
||||
id: "comment-url-{id_suffix}",
|
||||
class: INPUT_CLASS,
|
||||
r#type: "url",
|
||||
placeholder: "https://example.com(可选)",
|
||||
@ -112,11 +145,19 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
class: "{INPUT_CLASS} min-h-[100px] resize-y",
|
||||
value: "{content_md}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| content_md.set(e.value()),
|
||||
div {
|
||||
label {
|
||||
r#for: "comment-content-{id_suffix}",
|
||||
class: "block text-sm font-medium text-paper-secondary mb-1",
|
||||
"内容 *"
|
||||
}
|
||||
textarea {
|
||||
id: "comment-content-{id_suffix}",
|
||||
class: "{INPUT_CLASS} min-h-[100px] resize-y",
|
||||
value: "{content_md}",
|
||||
disabled: submitting(),
|
||||
oninput: move |e| content_md.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
p { class: "text-xs text-paper-tertiary",
|
||||
@ -132,100 +173,103 @@ pub fn CommentForm(post_id: i32, parent_id: Option<i64>) -> Element {
|
||||
oninput: move |e| honeypot.set(e.value()),
|
||||
}
|
||||
|
||||
button {
|
||||
class: BUTTON_PRIMARY_CLASS,
|
||||
disabled: submitting(),
|
||||
onclick: move |_| {
|
||||
if submitting() {
|
||||
return;
|
||||
}
|
||||
div { class: "flex justify-end",
|
||||
button {
|
||||
class: COMMENT_SUBMIT_CLASS,
|
||||
disabled: submitting(),
|
||||
onclick: move |_| {
|
||||
if submitting() {
|
||||
return;
|
||||
}
|
||||
|
||||
let post_id = post_id;
|
||||
let parent_id = parent_id;
|
||||
let name = author_name();
|
||||
let email = author_email();
|
||||
let url_val = author_url();
|
||||
let content = content_md();
|
||||
let hp = honeypot();
|
||||
let post_id = post_id;
|
||||
let parent_id = parent_id;
|
||||
let name = author_name();
|
||||
let email = author_email();
|
||||
let url_val = author_url();
|
||||
let content = content_md();
|
||||
let hp = honeypot();
|
||||
|
||||
// 蜜罐被填充则直接丢弃
|
||||
if !hp.is_empty() {
|
||||
return;
|
||||
}
|
||||
// 蜜罐被填充则直接丢弃
|
||||
if !hp.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() {
|
||||
message.set(Some(("请填写所有必填项".to_string(), "error")));
|
||||
return;
|
||||
}
|
||||
if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() {
|
||||
message.set(Some(("请填写所有必填项".to_string(), "error")));
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.set(true);
|
||||
message.set(None);
|
||||
submitting.set(true);
|
||||
message.set(None);
|
||||
|
||||
spawn(async move {
|
||||
let result = create_comment(
|
||||
post_id,
|
||||
parent_id,
|
||||
name.clone(),
|
||||
email.clone(),
|
||||
if url_val.trim().is_empty() { None } else { Some(url_val.clone()) },
|
||||
content.clone(),
|
||||
).await;
|
||||
spawn(async move {
|
||||
let result = create_comment(
|
||||
post_id,
|
||||
parent_id,
|
||||
name.clone(),
|
||||
email.clone(),
|
||||
if url_val.trim().is_empty() { None } else { Some(url_val.clone()) },
|
||||
content.clone(),
|
||||
hp.clone(),
|
||||
).await;
|
||||
|
||||
submitting.set(false);
|
||||
submitting.set(false);
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if resp.success {
|
||||
comment_storage::save_author(
|
||||
&name,
|
||||
&email,
|
||||
&url_val,
|
||||
);
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if resp.success {
|
||||
comment_storage::save_author(
|
||||
&name,
|
||||
&email,
|
||||
&url_val,
|
||||
);
|
||||
|
||||
if let Some(comment_id) = resp.comment_id {
|
||||
let avatar_url = resp.avatar_url.unwrap_or_default();
|
||||
let depth = resp.depth.unwrap_or(0);
|
||||
if let Some(comment_id) = resp.comment_id {
|
||||
let avatar_url = resp.avatar_url.unwrap_or_default();
|
||||
let depth = resp.depth.unwrap_or(0);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let pending = PendingComment {
|
||||
id: comment_id,
|
||||
parent_id,
|
||||
depth,
|
||||
author_name: name.clone(),
|
||||
author_url: if url_val.trim().is_empty() { None } else { Some(url_val) },
|
||||
avatar_url,
|
||||
content_md: content,
|
||||
created_at: now.clone(),
|
||||
stored_at: now,
|
||||
};
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let pending = PendingComment {
|
||||
id: comment_id,
|
||||
parent_id,
|
||||
depth,
|
||||
author_name: name.clone(),
|
||||
author_url: if url_val.trim().is_empty() { None } else { Some(url_val) },
|
||||
avatar_url,
|
||||
content_md: content,
|
||||
created_at: now.clone(),
|
||||
stored_at: now,
|
||||
};
|
||||
|
||||
comment_storage::save_pending_comment(post_id, pending.clone());
|
||||
pending_comments.write().push(pending);
|
||||
comment_storage::save_pending_comment(post_id, pending.clone());
|
||||
pending_comments.write().push(pending);
|
||||
}
|
||||
|
||||
content_md.set(String::new());
|
||||
message.set(Some((resp.message, "success")));
|
||||
if parent_id.is_some() {
|
||||
active_reply.set(None);
|
||||
}
|
||||
refresh_trigger.set(!refresh_trigger());
|
||||
} else {
|
||||
message.set(Some((resp.message, "error")));
|
||||
}
|
||||
|
||||
content_md.set(String::new());
|
||||
message.set(Some((resp.message, "success")));
|
||||
if parent_id.is_some() {
|
||||
active_reply.set(None);
|
||||
}
|
||||
refresh_trigger.set(!refresh_trigger());
|
||||
} else {
|
||||
message.set(Some((resp.message, "error")));
|
||||
}
|
||||
Err(_) => {
|
||||
message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
if submitting() {
|
||||
"提交中…"
|
||||
} else if is_reply {
|
||||
"回复"
|
||||
} else {
|
||||
"发表评论"
|
||||
if submitting() {
|
||||
"提交中…"
|
||||
} else if is_reply {
|
||||
"回复"
|
||||
} else {
|
||||
"发表评论"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,7 +104,7 @@ pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element {
|
||||
}
|
||||
|
||||
if is_replying {
|
||||
CommentForm { post_id, parent_id: Some(comment.id) }
|
||||
CommentForm { post_id, parent_id: Some(comment.id), parent_indent: Some(indent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +98,7 @@ pub fn CommentSection(post_id: i32) -> Element {
|
||||
"评论区 ({total_count})"
|
||||
}
|
||||
|
||||
CommentForm { post_id, parent_id: None }
|
||||
CommentForm { post_id, parent_id: None, parent_indent: None }
|
||||
|
||||
if !has_any {
|
||||
p { class: "text-paper-tertiary text-center py-8",
|
||||
|
||||
@ -96,26 +96,21 @@ impl std::fmt::Display for MigrateError {
|
||||
|
||||
impl std::error::Error for MigrateError {}
|
||||
|
||||
/// 执行所有未应用的迁移。
|
||||
/// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。
|
||||
///
|
||||
/// 在 `main.rs` 启动时调用一次。流程:
|
||||
/// 1. 获取一个独占连接(咨询锁是 session 级,需在同一连接上 lock/unlock)
|
||||
/// 2. 抢咨询锁(多实例启动时串行化)
|
||||
/// 3. 确保 `schema_migrations` 表存在
|
||||
/// 4. 查询已应用版本集合
|
||||
/// 5. 按序应用未应用的迁移(每个一个事务)
|
||||
/// 6. 释放咨询锁
|
||||
/// 调用方负责自行控制连接获取策略——例如 `main.rs` 启动时用
|
||||
/// [`get_conn_for_startup`](crate::db::pool::get_conn_for_startup)(长重试窗口)
|
||||
/// 拿到连接后再调用本函数,以应对 DB 尚未就绪的场景。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 抢咨询锁(多实例启动时串行化)
|
||||
/// 2. 确保 `schema_migrations` 表存在
|
||||
/// 3. 查询已应用版本集合
|
||||
/// 4. 按序应用未应用的迁移(每个一个事务)
|
||||
/// 5. 释放咨询锁
|
||||
///
|
||||
/// 失败时返回错误;调用方(`main.rs`)应让进程退出,避免启动半残服务。
|
||||
pub async fn run() -> Result<(), MigrateError> {
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
tracing::info!("running database migrations");
|
||||
|
||||
// 咨询锁是 session 级的,必须在同一连接上 lock/unlock。
|
||||
// 因此整个迁移流程独占一个池连接。
|
||||
let mut conn = get_conn().await?;
|
||||
|
||||
pub async fn run_on_conn(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
|
||||
// 抢咨询锁:多实例滚动发布时只有一个进程能进入迁移循环,
|
||||
// 其余实例在此等待;锁释放后它们查版本表发现已全部应用,直接返回。
|
||||
conn.execute("SELECT pg_advisory_lock($1)", &[&ADVISORY_LOCK_KEY])
|
||||
@ -124,12 +119,13 @@ pub async fn run() -> Result<(), MigrateError> {
|
||||
// 在已持锁连接上执行迁移主体逻辑。
|
||||
// 锁释放策略:
|
||||
// - 正常返回 / 返回 Err:下面的显式 pg_advisory_unlock 释放锁。
|
||||
// - panic:panic 会跳过显式 unlock。此时 `conn` 在 unwind 中被 drop,
|
||||
// 但 deadpool 会把连接归还池中复用(不关闭 Postgres 会话),
|
||||
// 所以 session 级咨询锁不会立即释放。安全性依赖调用方(main.rs 的
|
||||
// `.expect()`)在 panic 时终止进程——进程退出会关闭所有池连接,
|
||||
// Postgres 检测到会话断开后释放 session 级咨询锁。
|
||||
let result = run_inner(&mut conn).await;
|
||||
// - 进程被强杀(SIGKILL 等):连接断开,Postgres 在检测到会话断开后释放
|
||||
// session 级咨询锁。
|
||||
// - `main.rs` 在迁移失败时用 `std::process::exit(1)` 终止进程(不再 panic):
|
||||
// `exit(1)` 不会 unwind,但同样会关闭进程持有的所有 socket / 池连接,
|
||||
// 效果等价于会话断开——Postgres 会释放 session 级咨询锁。
|
||||
// 因此把 `.expect()` 改成 `exit(1)` 不破坏原有的锁安全保证。
|
||||
let result = run_inner(conn).await;
|
||||
|
||||
// 无论成功失败都尝试显式释放锁;释放失败不应掩盖原始错误,仅记录告警。
|
||||
if let Err(unlock_err) = conn
|
||||
|
||||
117
src/db/pool.rs
117
src/db/pool.rs
@ -3,6 +3,9 @@
|
||||
//! 仅在启用 `server` feature 时编译,使用 deadpool-postgres 管理连接池,
|
||||
//! 并通过 `std::sync::LazyLock` 在首次访问时延迟初始化全局连接池。
|
||||
//! `get_conn` 失败时按指数退避 + jitter 重试(见 `retry` 模块),以应对瞬时连接失败。
|
||||
//!
|
||||
//! 启动期的重试窗口更长(见 `get_conn_for_startup`),并配合 `main.rs` 的前置校验
|
||||
//!(`validate_database_url`)让所有启动期致命错误走统一友好的 `exit(1)` 路径。
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
@ -10,17 +13,23 @@ use std::time::Duration;
|
||||
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime};
|
||||
use tokio_postgres::NoTls;
|
||||
|
||||
/// 全局数据库连接池,基于 `DATABASE_URL` 环境变量延迟初始化。
|
||||
/// 解析 `DATABASE_URL` 并注入 `statement_timeout`,返回配置好的 `tokio_postgres::Config`。
|
||||
///
|
||||
/// 最大连接数可通过 `DB_POOL_SIZE` 环境变量调整,默认 20。
|
||||
pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable not set");
|
||||
/// 把原本写死在 `DB_POOL` LazyLock 闭包里的逻辑抽出来,便于:
|
||||
/// - `main.rs` 启动早期做前置校验(`validate_database_url`);
|
||||
/// - LazyLock 闭包退化为不可达的防御性代码。
|
||||
///
|
||||
/// 返回 `Err(String)` 而非 panic,调用方决定如何向用户报告错误。
|
||||
fn build_pg_config() -> Result<tokio_postgres::Config, String> {
|
||||
let db_url = std::env::var("DATABASE_URL").map_err(|_| {
|
||||
"DATABASE_URL environment variable not set".to_string()
|
||||
})?;
|
||||
let mut pg_cfg = db_url
|
||||
.parse::<tokio_postgres::Config>()
|
||||
.expect("Invalid DATABASE_URL format");
|
||||
.map_err(|e| format!("Invalid DATABASE_URL format: {e}"))?;
|
||||
|
||||
// statement_timeout:防止单条慢查询(如全表扫搜索)长时间占用连接拖垮池。
|
||||
// 默认 30s,可由 STATEMENT_TIMEOUT_SECS 覆盖(L6)。
|
||||
// 默认 30s,可由 STATEMENT_TIMEOUT_SECS 覆盖。
|
||||
let statement_timeout_secs = std::env::var("STATEMENT_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
@ -28,6 +37,42 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
||||
// 通过 libpq options 传递 GUC;tokio-postgres 在建连时执行。
|
||||
pg_cfg.options(format!("-c statement_timeout={}", statement_timeout_secs * 1000));
|
||||
|
||||
Ok(pg_cfg)
|
||||
}
|
||||
|
||||
/// 启动早期校验:`DATABASE_URL` 格式合法 + `DB_POOL_SIZE` 为正数。
|
||||
///
|
||||
/// 供 `main.rs` 在 `DB_POOL` LazyLock 被触碰之前调用,让 URL 格式错误、池大小非法
|
||||
/// 这类用户可修复的配置问题走统一友好的 `tracing::error!` + `exit(1)` 路径,
|
||||
/// 而不是触发 LazyLock 闭包里的 `.expect()` panic。
|
||||
///
|
||||
/// 返回 `Err(String)` 时,字符串已是面向用户的错误描述。
|
||||
pub fn validate_database_url() -> Result<(), String> {
|
||||
build_pg_config()?;
|
||||
|
||||
// 同步校验池大小,避免 LazyLock 闭包里 `unwrap_or(20)` 静默吞掉非法值。
|
||||
if let Ok(s) = std::env::var("DB_POOL_SIZE") {
|
||||
match s.parse::<usize>() {
|
||||
Ok(n) if n > 0 => {}
|
||||
Ok(_) => return Err("DB_POOL_SIZE is not a positive integer".to_string()),
|
||||
Err(e) => return Err(format!("Invalid DB_POOL_SIZE value: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 全局数据库连接池。
|
||||
///
|
||||
/// **不可达的防御性 panic**:`main.rs` 启动时已通过 `validate_database_url()` 前置校验
|
||||
/// `DATABASE_URL` 格式与 `DB_POOL_SIZE`,因此在真实运行路径上本闭包里的 `.expect()`
|
||||
/// 永远不会触发。保留 `.expect()` 只是为了满足 `LazyLock` 必须返回 `T`(而非 `Result`)
|
||||
/// 的类型约束——若这里真的 panic,说明 `validate_database_url` 与本闭包逻辑不一致,
|
||||
/// 属于代码 bug 而非用户错误。
|
||||
pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
||||
// 前置校验已保证配置合法;闭包里直接 expect 以满足 LazyLock 的类型约束。
|
||||
let pg_cfg = build_pg_config()
|
||||
.expect("DATABASE_URL should have been validated at startup; validate_database_url() was not called");
|
||||
|
||||
// 使用 Fast 回收策略:归还连接时不额外发 SELECT 1 验证,直接复用。
|
||||
// Verified 在高并发下会为每次 get() 增加一次往返;Fast 依赖 tokio-postgres
|
||||
// 在使用时自然报错,由 get_conn 的重试层兜底。
|
||||
@ -53,8 +98,12 @@ pub static DB_POOL: LazyLock<Pool> = LazyLock::new(|| {
|
||||
|
||||
/// 从全局连接池获取一个数据库连接,失败时按指数退避 + jitter 重试。
|
||||
///
|
||||
/// 这是**运行期**获取连接的路径:反雪崩导向——快速失败(约 1.6s 后放弃),
|
||||
/// 让上层限流兜底。**启动期**(迁移)请用 [`get_conn_for_startup`],它有一个
|
||||
/// 更长的可配置重试窗口,专为“DB 还没起来”的场景设计。
|
||||
///
|
||||
/// 退避策略见 `retry::backoff_for`。仅对 Backend/Postgres 错误(DB 不可达)重试;
|
||||
/// Timeout(池满)直接返回,让上层限流兜底,避免雪崩(L6)。
|
||||
/// Timeout(池满)直接返回,让上层限流兜底,避免雪崩。
|
||||
/// 若所有重试均失败,返回最后一次的 PoolError。
|
||||
pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
|
||||
use rand::Rng;
|
||||
@ -87,3 +136,57 @@ pub async fn get_conn() -> Result<deadpool_postgres::Object, deadpool_postgres::
|
||||
}
|
||||
Err(last_err.unwrap())
|
||||
}
|
||||
|
||||
/// 启动期专用:在可配置的时间窗口内反复尝试连接数据库,专治“DB 还没起来”。
|
||||
///
|
||||
/// 与运行期的 [`get_conn`] 区别:
|
||||
/// - **没有反雪崩约束**:启动时只有这一个进程在连,不会雪崩,可以放心长重试。
|
||||
/// - **固定间隔重试**(而非指数退避):启动场景下 DB 要么起来要么没起来,
|
||||
/// 固定 500ms 轮询比指数退避更可预测,也更贴合 `pg_isready` 式的等待语义。
|
||||
/// - **以总时长为终止条件**(而非次数):对运维更直观——"给 DB 30 秒起来"。
|
||||
///
|
||||
/// 超时窗口由 `MIGRATE_STARTUP_TIMEOUT_SECS` 控制,默认 30 秒。窗口用尽后返回
|
||||
/// 最后一次错误,由调用方(`main.rs`)决定如何向用户报告。
|
||||
///
|
||||
/// 适用 docker-compose(无 healthcheck)、本机忘启 Postgres 等“DB 起得比 app 慢”的场景。
|
||||
pub async fn get_conn_for_startup(
|
||||
) -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> {
|
||||
let timeout_secs = std::env::var("MIGRATE_STARTUP_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(30);
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let retry_interval = Duration::from_millis(500);
|
||||
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
attempt += 1;
|
||||
match DB_POOL.get().await {
|
||||
Ok(conn) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(
|
||||
"connected to database after {} attempt(s)",
|
||||
attempt
|
||||
);
|
||||
}
|
||||
return Ok(conn);
|
||||
}
|
||||
Err(e) => {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(e);
|
||||
}
|
||||
tracing::warn!(
|
||||
"startup DB connection attempt {} failed, ~{}s remaining until giving up: {:?}",
|
||||
attempt,
|
||||
remaining.as_secs(),
|
||||
e,
|
||||
);
|
||||
// 不要睡过 deadline,避免超出用户配置的窗口。
|
||||
let sleep = std::cmp::min(retry_interval, remaining);
|
||||
tokio::time::sleep(sleep).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
src/main.rs
46
src/main.rs
@ -203,11 +203,26 @@ fn main() {
|
||||
if std::env::var("DATABASE_URL").is_err() {
|
||||
tracing::error!("DATABASE_URL environment variable not set. Make sure .env exists or the variable is exported.");
|
||||
eprintln!("ERROR: DATABASE_URL environment variable not set");
|
||||
eprintln!("HINT: create a .env file with DATABASE_URL=postgres://user:pass@host:5432/dbname");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// 前置校验 DATABASE_URL 格式 + DB_POOL_SIZE,避免触发 DB_POOL LazyLock 闭包里
|
||||
// 不可达的 .expect() panic——让用户可修复的配置错误走统一友好的 exit(1) 路径。
|
||||
// 此处必须在任何 DB_POOL.get() 调用之前执行(即迁移之前)。
|
||||
if let Err(e) = db::pool::validate_database_url() {
|
||||
tracing::error!("{e}");
|
||||
eprintln!("ERROR: {e}");
|
||||
if e.starts_with("DB_POOL_SIZE") {
|
||||
eprintln!("HINT: DB_POOL_SIZE must be a positive integer (e.g. 20).");
|
||||
} else {
|
||||
eprintln!("HINT: expected something like postgres://user:pass@host:5432/dbname");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// 启动前执行数据库迁移。阻塞:完成前不监听端口。
|
||||
// 失败直接 panic(expect),避免启动一个 schema 不一致的半残服务。
|
||||
// 失败用 exit(1) 退出(不 panic),避免启动一个 schema 不一致的半残服务。
|
||||
// 多实例滚动发布时由咨询锁串行化,详见 src/db/migrate.rs。
|
||||
//
|
||||
// main() 是同步函数,这里用一个独立的多线程 runtime 驱动迁移的异步逻辑,
|
||||
@ -218,9 +233,32 @@ fn main() {
|
||||
.build()
|
||||
.expect("failed to build migration runtime");
|
||||
migrate_rt.block_on(async {
|
||||
db::migrate::run()
|
||||
.await
|
||||
.expect("Database migration failed on startup");
|
||||
tracing::info!("running database migrations");
|
||||
|
||||
// 启动期用长重试窗口拿连接:DB 可能还在初始化(docker-compose 无 healthcheck、
|
||||
// 本机忘启 Postgres 等)。窗口由 MIGRATE_STARTUP_TIMEOUT_SECS 控制,默认 30s。
|
||||
let mut conn = match db::pool::get_conn_for_startup().await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
let secs = std::env::var("MIGRATE_STARTUP_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(30);
|
||||
tracing::error!("could not connect to database within {secs}s startup window: {e}");
|
||||
eprintln!("ERROR: could not connect to database within {secs}s startup window: {e}");
|
||||
eprintln!("HINT: is PostgreSQL running and reachable at the configured DATABASE_URL?");
|
||||
eprintln!("HINT: raise MIGRATE_STARTUP_TIMEOUT_SECS if the DB needs longer to start.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接拿到后再执行迁移主体(咨询锁 + 建表 + 应用迁移)。
|
||||
if let Err(e) = db::migrate::run_on_conn(&mut conn).await {
|
||||
tracing::error!("database migration failed: {e}");
|
||||
eprintln!("ERROR: database migration failed: {e}");
|
||||
eprintln!("HINT: check the logs above; verify DATABASE_URL and that PostgreSQL is healthy.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
// 迁移 runtime 用完即弃,显式 drop 以在 serve() 前释放其线程资源。
|
||||
drop(migrate_rt);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user