Skip to main content

yggdrasil/api/settings/
trash.rs

1// 与 posts 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit/too_many_arguments
2// 提示,按项目惯例放行(限流/运行器等配置项天然参数多)。
3#![allow(clippy::unused_unit, deprecated, clippy::too_many_arguments)]
4
5use dioxus::prelude::*;
6
7#[cfg(feature = "server")]
8use crate::api::auth::get_current_admin_user;
9#[cfg(feature = "server")]
10use crate::api::error::AppError;
11#[cfg(feature = "server")]
12use crate::db::pool::get_conn;
13use crate::models::settings::TrashSettings;
14
15/// Web 与 MCP 共用的回收站配置读取;缺失或非法值回退默认值。
16#[cfg(feature = "server")]
17pub(crate) async fn load_trash_settings(
18    client: &tokio_postgres::Client,
19) -> Result<TrashSettings, AppError> {
20    let enabled: bool = client
21        .query_opt(
22            "SELECT value FROM settings WHERE key = 'trash_auto_purge_enabled'",
23            &[],
24        )
25        .await
26        .map_err(AppError::query)?
27        .and_then(|r| r.get::<_, String>("value").parse().ok())
28        .unwrap_or(crate::models::settings::DEFAULT_AUTO_PURGE_ENABLED);
29
30    let days: i32 = client
31        .query_opt(
32            "SELECT value FROM settings WHERE key = 'trash_retention_days'",
33            &[],
34        )
35        .await
36        .map_err(AppError::query)?
37        .and_then(|r| r.get::<_, String>("value").parse().ok())
38        .unwrap_or(crate::models::settings::DEFAULT_RETENTION_DAYS);
39
40    Ok(TrashSettings {
41        auto_purge_enabled: enabled,
42        retention_days: TrashSettings::clamp_retention(days),
43    })
44}
45
46/// Web 与 MCP 共用的配置写入;保留天数统一钳制后写入并返回。
47#[cfg(feature = "server")]
48pub(crate) async fn save_trash_settings(
49    client: &tokio_postgres::Client,
50    auto_purge_enabled: bool,
51    retention_days: i32,
52) -> Result<TrashSettings, AppError> {
53    let retention_days = TrashSettings::clamp_retention(retention_days);
54
55    client
56        .execute(
57            "INSERT INTO settings (key, value, updated_at) VALUES ('trash_auto_purge_enabled', $1, NOW())
58             ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
59            &[&auto_purge_enabled.to_string()],
60        )
61        .await
62        .map_err(AppError::query)?;
63
64    client
65        .execute(
66            "INSERT INTO settings (key, value, updated_at) VALUES ('trash_retention_days', $1, NOW())
67             ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
68            &[&retention_days.to_string()],
69        )
70        .await
71        .map_err(AppError::query)?;
72
73    Ok(TrashSettings {
74        auto_purge_enabled,
75        retention_days,
76    })
77}
78
79/// 读取回收站配置。
80///
81/// settings 表缺失键时回退到默认值,保证向后兼容。
82#[server(GetTrashSettings, "/api")]
83pub async fn get_trash_settings() -> Result<TrashSettings, ServerFnError> {
84    let _user = get_current_admin_user().await?;
85
86    #[cfg(feature = "server")]
87    {
88        let client = get_conn().await.map_err(AppError::db_conn)?;
89        Ok(load_trash_settings(&client).await?)
90    }
91
92    #[cfg(not(feature = "server"))]
93    {
94        Ok(TrashSettings::default())
95    }
96}
97
98/// 更新回收站配置。
99///
100/// retention_days 会被 clamp 到合法范围后写入。
101#[server(UpdateTrashSettings, "/api")]
102pub async fn update_trash_settings(
103    auto_purge_enabled: bool,
104    retention_days: i32,
105) -> Result<TrashSettings, ServerFnError> {
106    let _user = get_current_admin_user().await?;
107
108    #[cfg(feature = "server")]
109    {
110        let client = get_conn().await.map_err(AppError::db_conn)?;
111        let settings = save_trash_settings(&client, auto_purge_enabled, retention_days).await?;
112
113        tracing::info!(
114            "Trash settings updated: auto_purge={}, retention_days={}",
115            settings.auto_purge_enabled,
116            settings.retention_days
117        );
118
119        Ok(settings)
120    }
121
122    #[cfg(not(feature = "server"))]
123    {
124        Ok(TrashSettings {
125            auto_purge_enabled,
126            retention_days,
127        })
128    }
129}
130
131#[cfg(all(test, feature = "server"))]
132mod tests {
133    use super::*;
134
135    #[tokio::test]
136    #[ignore = "requires YGGDRASIL_TEST_DATABASE_URL; uses only a connection-local temporary table"]
137    async fn trash_settings_database_roundtrip() {
138        let url = std::env::var("YGGDRASIL_TEST_DATABASE_URL")
139            .expect("set YGGDRASIL_TEST_DATABASE_URL to run this database test");
140        let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
141            .await
142            .unwrap();
143        let connection = tokio::spawn(connection);
144        client
145            .batch_execute(
146                "CREATE TEMP TABLE settings (
147                    key TEXT PRIMARY KEY,
148                    value TEXT NOT NULL,
149                    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
150                )",
151            )
152            .await
153            .unwrap();
154
155        assert_eq!(
156            load_trash_settings(&client).await.unwrap(),
157            TrashSettings::default()
158        );
159        client
160            .batch_execute(
161                "INSERT INTO settings (key, value) VALUES
162                    ('trash_auto_purge_enabled', 'invalid'),
163                    ('trash_retention_days', 'invalid')",
164            )
165            .await
166            .unwrap();
167        assert_eq!(
168            load_trash_settings(&client).await.unwrap(),
169            TrashSettings::default()
170        );
171
172        for (enabled, input_days, expected_days) in [(true, -5, 1), (false, 366, 365), (true, 7, 7)]
173        {
174            let expected = TrashSettings {
175                auto_purge_enabled: enabled,
176                retention_days: expected_days,
177            };
178            assert_eq!(
179                save_trash_settings(&client, enabled, input_days)
180                    .await
181                    .unwrap(),
182                expected
183            );
184            assert_eq!(load_trash_settings(&client).await.unwrap(), expected);
185            let stored: String = client
186                .query_one(
187                    "SELECT value FROM settings WHERE key = 'trash_retention_days'",
188                    &[],
189                )
190                .await
191                .unwrap()
192                .get(0);
193            assert_eq!(stored, expected_days.to_string());
194        }
195
196        for (stored, expected_days) in [("-5", 1), ("366", 365)] {
197            client
198                .execute(
199                    "UPDATE settings SET value = $1 WHERE key = 'trash_retention_days'",
200                    &[&stored],
201                )
202                .await
203                .unwrap();
204            assert_eq!(
205                load_trash_settings(&client).await.unwrap().retention_days,
206                expected_days
207            );
208        }
209
210        client
211            .batch_execute("ALTER TABLE pg_temp.settings RENAME COLUMN value TO invalid_value")
212            .await
213            .unwrap();
214        assert!(matches!(
215            load_trash_settings(&client).await,
216            Err(AppError::Query(_))
217        ));
218        assert!(matches!(
219            save_trash_settings(&client, true, 30).await,
220            Err(AppError::Query(_))
221        ));
222        drop(client);
223        connection.await.unwrap().unwrap();
224    }
225}