mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
379
crates/aether-data/src/repository/announcements/memory.rs
Normal file
379
crates/aether-data/src/repository/announcements/memory.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::RwLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::types::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryAnnouncementReadRepository {
|
||||
announcements: RwLock<Vec<StoredAnnouncement>>,
|
||||
announcement_reads: RwLock<BTreeSet<(String, String)>>,
|
||||
}
|
||||
|
||||
impl InMemoryAnnouncementReadRepository {
|
||||
pub fn seed<I>(announcements: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredAnnouncement>,
|
||||
{
|
||||
Self::seed_with_reads(announcements, std::iter::empty::<(String, String)>())
|
||||
}
|
||||
|
||||
pub fn seed_with_reads<I, J>(announcements: I, reads: J) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredAnnouncement>,
|
||||
J: IntoIterator<Item = (String, String)>,
|
||||
{
|
||||
Self {
|
||||
announcements: RwLock::new(announcements.into_iter().collect()),
|
||||
announcement_reads: RwLock::new(reads.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementReadRepository for InMemoryAnnouncementReadRepository {
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
Ok(self
|
||||
.announcements
|
||||
.read()
|
||||
.expect("announcement repository lock")
|
||||
.iter()
|
||||
.find(|announcement| announcement.id == announcement_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn list_announcements(
|
||||
&self,
|
||||
query: &AnnouncementListQuery,
|
||||
) -> Result<StoredAnnouncementPage, DataLayerError> {
|
||||
let now_unix_secs = query.now_unix_secs.unwrap_or_else(Self::now_unix_secs);
|
||||
let announcements = self
|
||||
.announcements
|
||||
.read()
|
||||
.expect("announcement repository lock");
|
||||
|
||||
let mut items: Vec<_> = announcements
|
||||
.iter()
|
||||
.filter(|announcement| {
|
||||
if !query.active_only {
|
||||
return true;
|
||||
}
|
||||
announcement.is_active
|
||||
&& announcement
|
||||
.start_time_unix_secs
|
||||
.is_none_or(|value| value <= now_unix_secs)
|
||||
&& announcement
|
||||
.end_time_unix_secs
|
||||
.is_none_or(|value| value >= now_unix_secs)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.is_pinned
|
||||
.cmp(&left.is_pinned)
|
||||
.then_with(|| right.priority.cmp(&left.priority))
|
||||
.then_with(|| right.created_at_unix_secs.cmp(&left.created_at_unix_secs))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
|
||||
let total = items.len() as u64;
|
||||
let items = items
|
||||
.into_iter()
|
||||
.skip(query.offset)
|
||||
.take(query.limit)
|
||||
.collect();
|
||||
|
||||
Ok(StoredAnnouncementPage { items, total })
|
||||
}
|
||||
|
||||
async fn count_unread_active_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<u64, DataLayerError> {
|
||||
let announcements = self
|
||||
.announcements
|
||||
.read()
|
||||
.expect("announcement repository lock");
|
||||
let reads = self
|
||||
.announcement_reads
|
||||
.read()
|
||||
.expect("announcement reads repository lock");
|
||||
|
||||
let total = announcements
|
||||
.iter()
|
||||
.filter(|announcement| {
|
||||
announcement.is_active
|
||||
&& announcement
|
||||
.start_time_unix_secs
|
||||
.is_none_or(|value| value <= now_unix_secs)
|
||||
&& announcement
|
||||
.end_time_unix_secs
|
||||
.is_none_or(|value| value >= now_unix_secs)
|
||||
&& !reads.contains(&(user_id.to_string(), announcement.id.clone()))
|
||||
})
|
||||
.count() as u64;
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementWriteRepository for InMemoryAnnouncementReadRepository {
|
||||
async fn create_announcement(
|
||||
&self,
|
||||
record: CreateAnnouncementRecord,
|
||||
) -> Result<StoredAnnouncement, DataLayerError> {
|
||||
record.validate()?;
|
||||
let now_unix_secs = Self::now_unix_secs();
|
||||
let announcement = StoredAnnouncement::new(
|
||||
Uuid::new_v4().to_string(),
|
||||
record.title,
|
||||
record.content,
|
||||
record.kind,
|
||||
record.priority,
|
||||
true,
|
||||
record.is_pinned,
|
||||
Some(record.author_id),
|
||||
None,
|
||||
record.start_time_unix_secs.map(|value| value as i64),
|
||||
record.end_time_unix_secs.map(|value| value as i64),
|
||||
now_unix_secs as i64,
|
||||
now_unix_secs as i64,
|
||||
)?;
|
||||
self.announcements
|
||||
.write()
|
||||
.expect("announcement repository lock")
|
||||
.push(announcement.clone());
|
||||
Ok(announcement)
|
||||
}
|
||||
|
||||
async fn update_announcement(
|
||||
&self,
|
||||
record: UpdateAnnouncementRecord,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
record.validate()?;
|
||||
let mut announcements = self
|
||||
.announcements
|
||||
.write()
|
||||
.expect("announcement repository lock");
|
||||
let Some(announcement) = announcements
|
||||
.iter_mut()
|
||||
.find(|announcement| announcement.id == record.announcement_id)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Some(title) = record.title {
|
||||
announcement.title = title;
|
||||
}
|
||||
if let Some(content) = record.content {
|
||||
announcement.content = content;
|
||||
}
|
||||
if let Some(kind) = record.kind {
|
||||
announcement.kind = kind;
|
||||
}
|
||||
if let Some(priority) = record.priority {
|
||||
announcement.priority = priority;
|
||||
}
|
||||
if let Some(is_active) = record.is_active {
|
||||
announcement.is_active = is_active;
|
||||
}
|
||||
if let Some(is_pinned) = record.is_pinned {
|
||||
announcement.is_pinned = is_pinned;
|
||||
}
|
||||
if let Some(start_time_unix_secs) = record.start_time_unix_secs {
|
||||
announcement.start_time_unix_secs = Some(start_time_unix_secs);
|
||||
}
|
||||
if let Some(end_time_unix_secs) = record.end_time_unix_secs {
|
||||
announcement.end_time_unix_secs = Some(end_time_unix_secs);
|
||||
}
|
||||
announcement.updated_at_unix_secs = Self::now_unix_secs();
|
||||
Ok(Some(announcement.clone()))
|
||||
}
|
||||
|
||||
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> {
|
||||
let mut announcements = self
|
||||
.announcements
|
||||
.write()
|
||||
.expect("announcement repository lock");
|
||||
let original_len = announcements.len();
|
||||
announcements.retain(|announcement| announcement.id != announcement_id);
|
||||
Ok(announcements.len() != original_len)
|
||||
}
|
||||
|
||||
async fn mark_announcement_as_read(
|
||||
&self,
|
||||
user_id: &str,
|
||||
announcement_id: &str,
|
||||
_read_at_unix_secs: u64,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let inserted = self
|
||||
.announcement_reads
|
||||
.write()
|
||||
.expect("announcement reads repository lock")
|
||||
.insert((user_id.to_string(), announcement_id.to_string()));
|
||||
Ok(inserted)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::InMemoryAnnouncementReadRepository;
|
||||
use crate::repository::announcements::{
|
||||
AnnouncementReadRepository, AnnouncementWriteRepository, CreateAnnouncementRecord,
|
||||
StoredAnnouncement, UpdateAnnouncementRecord,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_seeded_announcements() {
|
||||
let repository = InMemoryAnnouncementReadRepository::seed(vec![StoredAnnouncement::new(
|
||||
"announcement-1".to_string(),
|
||||
"系统维护".to_string(),
|
||||
"今天晚些时候维护".to_string(),
|
||||
"maintenance".to_string(),
|
||||
10,
|
||||
true,
|
||||
true,
|
||||
Some("admin-1".to_string()),
|
||||
Some("admin".to_string()),
|
||||
None,
|
||||
None,
|
||||
1_711_000_000,
|
||||
1_711_000_100,
|
||||
)
|
||||
.expect("announcement should build")]);
|
||||
|
||||
let announcement = repository
|
||||
.find_by_id("announcement-1")
|
||||
.await
|
||||
.expect("announcement should load")
|
||||
.expect("announcement should exist");
|
||||
|
||||
assert_eq!(announcement.title, "系统维护");
|
||||
assert_eq!(announcement.author_username.as_deref(), Some("admin"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutates_seeded_announcements() {
|
||||
let repository = InMemoryAnnouncementReadRepository::seed(vec![]);
|
||||
|
||||
let created = repository
|
||||
.create_announcement(CreateAnnouncementRecord {
|
||||
title: "系统维护".to_string(),
|
||||
content: "今天晚些时候维护".to_string(),
|
||||
kind: "maintenance".to_string(),
|
||||
priority: 10,
|
||||
is_pinned: true,
|
||||
author_id: "admin-1".to_string(),
|
||||
start_time_unix_secs: None,
|
||||
end_time_unix_secs: None,
|
||||
})
|
||||
.await
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.kind, "maintenance");
|
||||
|
||||
let updated = repository
|
||||
.update_announcement(UpdateAnnouncementRecord {
|
||||
announcement_id: created.id.clone(),
|
||||
title: Some("系统升级".to_string()),
|
||||
content: None,
|
||||
kind: Some("important".to_string()),
|
||||
priority: Some(99),
|
||||
is_active: Some(false),
|
||||
is_pinned: Some(false),
|
||||
start_time_unix_secs: None,
|
||||
end_time_unix_secs: None,
|
||||
})
|
||||
.await
|
||||
.expect("update should succeed")
|
||||
.expect("announcement should exist");
|
||||
assert_eq!(updated.title, "系统升级");
|
||||
assert_eq!(updated.kind, "important");
|
||||
assert!(!updated.is_active);
|
||||
|
||||
let deleted = repository
|
||||
.delete_announcement(&created.id)
|
||||
.await
|
||||
.expect("delete should succeed");
|
||||
assert!(deleted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tracks_user_announcement_read_state() {
|
||||
let repository = InMemoryAnnouncementReadRepository::seed_with_reads(
|
||||
vec![
|
||||
StoredAnnouncement::new(
|
||||
"announcement-1".to_string(),
|
||||
"系统维护".to_string(),
|
||||
"今天晚些时候维护".to_string(),
|
||||
"maintenance".to_string(),
|
||||
10,
|
||||
true,
|
||||
true,
|
||||
Some("admin-1".to_string()),
|
||||
Some("admin".to_string()),
|
||||
None,
|
||||
None,
|
||||
1_711_000_000,
|
||||
1_711_000_100,
|
||||
)
|
||||
.expect("announcement should build"),
|
||||
StoredAnnouncement::new(
|
||||
"announcement-2".to_string(),
|
||||
"系统升级".to_string(),
|
||||
"升级说明".to_string(),
|
||||
"info".to_string(),
|
||||
5,
|
||||
true,
|
||||
false,
|
||||
Some("admin-1".to_string()),
|
||||
Some("admin".to_string()),
|
||||
None,
|
||||
None,
|
||||
1_711_000_000,
|
||||
1_711_000_100,
|
||||
)
|
||||
.expect("announcement should build"),
|
||||
],
|
||||
[("user-1".to_string(), "announcement-1".to_string())],
|
||||
);
|
||||
|
||||
let unread = repository
|
||||
.count_unread_active_announcements("user-1", 1_711_000_200)
|
||||
.await
|
||||
.expect("count should succeed");
|
||||
assert_eq!(unread, 1);
|
||||
|
||||
let inserted = repository
|
||||
.mark_announcement_as_read("user-1", "announcement-2", 1_711_000_300)
|
||||
.await
|
||||
.expect("mark read should succeed");
|
||||
assert!(inserted);
|
||||
|
||||
let unread = repository
|
||||
.count_unread_active_announcements("user-1", 1_711_000_200)
|
||||
.await
|
||||
.expect("count should succeed");
|
||||
assert_eq!(unread, 0);
|
||||
}
|
||||
}
|
||||
10
crates/aether-data/src/repository/announcements/mod.rs
Normal file
10
crates/aether-data/src/repository/announcements/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
mod memory;
|
||||
mod sql;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemoryAnnouncementReadRepository;
|
||||
pub use sql::SqlxAnnouncementReadRepository;
|
||||
pub use types::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
};
|
||||
369
crates/aether-data/src/repository/announcements/sql.rs
Normal file
369
crates/aether-data/src/repository/announcements/sql.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use sqlx::{postgres::PgRow, PgPool, Row};
|
||||
|
||||
use super::types::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
const FIND_ANNOUNCEMENT_BY_ID_SQL: &str = r#"
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.content,
|
||||
a.type,
|
||||
a.priority,
|
||||
a.is_active,
|
||||
a.is_pinned,
|
||||
a.author_id,
|
||||
u.username AS author_username,
|
||||
EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.end_time)::bigint AS end_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM announcements a
|
||||
LEFT JOIN users u ON u.id = a.author_id
|
||||
WHERE a.id = $1
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const LIST_ANNOUNCEMENTS_SQL: &str = r#"
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.content,
|
||||
a.type,
|
||||
a.priority,
|
||||
a.is_active,
|
||||
a.is_pinned,
|
||||
a.author_id,
|
||||
u.username AS author_username,
|
||||
EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.end_time)::bigint AS end_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM a.updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM announcements a
|
||||
LEFT JOIN users u ON u.id = a.author_id
|
||||
WHERE (
|
||||
NOT $1 OR (
|
||||
a.is_active = TRUE
|
||||
AND (a.start_time IS NULL OR a.start_time <= TO_TIMESTAMP($2::double precision))
|
||||
AND (a.end_time IS NULL OR a.end_time >= TO_TIMESTAMP($2::double precision))
|
||||
)
|
||||
)
|
||||
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
|
||||
OFFSET $3
|
||||
LIMIT $4
|
||||
"#;
|
||||
|
||||
const COUNT_ANNOUNCEMENTS_SQL: &str = r#"
|
||||
SELECT COUNT(a.id) AS total
|
||||
FROM announcements a
|
||||
WHERE (
|
||||
NOT $1 OR (
|
||||
a.is_active = TRUE
|
||||
AND (a.start_time IS NULL OR a.start_time <= TO_TIMESTAMP($2::double precision))
|
||||
AND (a.end_time IS NULL OR a.end_time >= TO_TIMESTAMP($2::double precision))
|
||||
)
|
||||
)
|
||||
"#;
|
||||
|
||||
const COUNT_UNREAD_ACTIVE_ANNOUNCEMENTS_SQL: &str = r#"
|
||||
SELECT COUNT(a.id) AS total
|
||||
FROM announcements a
|
||||
WHERE a.is_active = TRUE
|
||||
AND (a.start_time IS NULL OR a.start_time <= TO_TIMESTAMP($2::double precision))
|
||||
AND (a.end_time IS NULL OR a.end_time >= TO_TIMESTAMP($2::double precision))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM announcement_reads r
|
||||
WHERE r.user_id = $1
|
||||
AND r.announcement_id = a.id
|
||||
)
|
||||
"#;
|
||||
|
||||
const CREATE_ANNOUNCEMENT_SQL: &str = r#"
|
||||
INSERT INTO announcements (
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
priority,
|
||||
author_id,
|
||||
is_active,
|
||||
is_pinned,
|
||||
start_time,
|
||||
end_time,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
TRUE,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
priority,
|
||||
is_active,
|
||||
is_pinned,
|
||||
author_id,
|
||||
(SELECT username FROM users WHERE id = announcements.author_id) AS author_username,
|
||||
EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM end_time)::bigint AS end_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
|
||||
"#;
|
||||
|
||||
const UPDATE_ANNOUNCEMENT_SQL: &str = r#"
|
||||
UPDATE announcements
|
||||
SET
|
||||
title = COALESCE($2, title),
|
||||
content = COALESCE($3, content),
|
||||
type = COALESCE($4, type),
|
||||
priority = COALESCE($5, priority),
|
||||
is_active = COALESCE($6, is_active),
|
||||
is_pinned = COALESCE($7, is_pinned),
|
||||
start_time = COALESCE($8, start_time),
|
||||
end_time = COALESCE($9, end_time),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
priority,
|
||||
is_active,
|
||||
is_pinned,
|
||||
author_id,
|
||||
(SELECT username FROM users WHERE id = announcements.author_id) AS author_username,
|
||||
EXTRACT(EPOCH FROM start_time)::bigint AS start_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM end_time)::bigint AS end_time_unix_secs,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
|
||||
"#;
|
||||
|
||||
const DELETE_ANNOUNCEMENT_SQL: &str = r#"
|
||||
DELETE FROM announcements
|
||||
WHERE id = $1
|
||||
"#;
|
||||
|
||||
const MARK_ANNOUNCEMENT_AS_READ_SQL: &str = r#"
|
||||
INSERT INTO announcement_reads (
|
||||
id,
|
||||
user_id,
|
||||
announcement_id,
|
||||
read_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
TO_TIMESTAMP($4::double precision)
|
||||
)
|
||||
ON CONFLICT (user_id, announcement_id) DO NOTHING
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxAnnouncementReadRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl SqlxAnnouncementReadRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementReadRepository for SqlxAnnouncementReadRepository {
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_ANNOUNCEMENT_BY_ID_SQL)
|
||||
.bind(announcement_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.as_ref().map(map_announcement_row).transpose()
|
||||
}
|
||||
|
||||
async fn list_announcements(
|
||||
&self,
|
||||
query: &AnnouncementListQuery,
|
||||
) -> Result<StoredAnnouncementPage, DataLayerError> {
|
||||
let now_unix_secs = query.now_unix_secs.unwrap_or_else(current_unix_secs);
|
||||
let total_row = sqlx::query(COUNT_ANNOUNCEMENTS_SQL)
|
||||
.bind(query.active_only)
|
||||
.bind(now_unix_secs as f64)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let total = total_row.try_get::<i64, _>("total")?.max(0) as u64;
|
||||
|
||||
let rows = sqlx::query(LIST_ANNOUNCEMENTS_SQL)
|
||||
.bind(query.active_only)
|
||||
.bind(now_unix_secs as f64)
|
||||
.bind(query.offset as i64)
|
||||
.bind(query.limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let items = rows
|
||||
.iter()
|
||||
.map(map_announcement_row)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(StoredAnnouncementPage { items, total })
|
||||
}
|
||||
|
||||
async fn count_unread_active_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<u64, DataLayerError> {
|
||||
let row = sqlx::query(COUNT_UNREAD_ACTIVE_ANNOUNCEMENTS_SQL)
|
||||
.bind(user_id)
|
||||
.bind(now_unix_secs as f64)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.try_get::<i64, _>("total")?.max(0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnouncementWriteRepository for SqlxAnnouncementReadRepository {
|
||||
async fn create_announcement(
|
||||
&self,
|
||||
record: CreateAnnouncementRecord,
|
||||
) -> Result<StoredAnnouncement, DataLayerError> {
|
||||
record.validate()?;
|
||||
let row = sqlx::query(CREATE_ANNOUNCEMENT_SQL)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(record.title)
|
||||
.bind(record.content)
|
||||
.bind(record.kind)
|
||||
.bind(record.priority)
|
||||
.bind(record.author_id)
|
||||
.bind(record.is_pinned)
|
||||
.bind(optional_datetime(record.start_time_unix_secs))
|
||||
.bind(optional_datetime(record.end_time_unix_secs))
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
map_announcement_row(&row)
|
||||
}
|
||||
|
||||
async fn update_announcement(
|
||||
&self,
|
||||
record: UpdateAnnouncementRecord,
|
||||
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
|
||||
record.validate()?;
|
||||
let row = sqlx::query(UPDATE_ANNOUNCEMENT_SQL)
|
||||
.bind(record.announcement_id)
|
||||
.bind(record.title)
|
||||
.bind(record.content)
|
||||
.bind(record.kind)
|
||||
.bind(record.priority)
|
||||
.bind(record.is_active)
|
||||
.bind(record.is_pinned)
|
||||
.bind(optional_datetime(record.start_time_unix_secs))
|
||||
.bind(optional_datetime(record.end_time_unix_secs))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.as_ref().map(map_announcement_row).transpose()
|
||||
}
|
||||
|
||||
async fn delete_announcement(&self, announcement_id: &str) -> Result<bool, DataLayerError> {
|
||||
let result = sqlx::query(DELETE_ANNOUNCEMENT_SQL)
|
||||
.bind(announcement_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn mark_announcement_as_read(
|
||||
&self,
|
||||
user_id: &str,
|
||||
announcement_id: &str,
|
||||
read_at_unix_secs: u64,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let result = sqlx::query(MARK_ANNOUNCEMENT_AS_READ_SQL)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(user_id)
|
||||
.bind(announcement_id)
|
||||
.bind(read_at_unix_secs as f64)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_datetime(unix_secs: Option<u64>) -> Option<chrono::DateTime<Utc>> {
|
||||
unix_secs.and_then(|value| {
|
||||
i64::try_from(value)
|
||||
.ok()
|
||||
.and_then(|value| Utc.timestamp_opt(value, 0).single())
|
||||
})
|
||||
}
|
||||
|
||||
fn current_unix_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn map_announcement_row(row: &PgRow) -> Result<StoredAnnouncement, DataLayerError> {
|
||||
StoredAnnouncement::new(
|
||||
row.try_get("id")?,
|
||||
row.try_get("title")?,
|
||||
row.try_get("content")?,
|
||||
row.try_get("type")?,
|
||||
row.try_get("priority")?,
|
||||
row.try_get("is_active")?,
|
||||
row.try_get("is_pinned")?,
|
||||
row.try_get("author_id")?,
|
||||
row.try_get("author_username")?,
|
||||
row.try_get("start_time_unix_secs")?,
|
||||
row.try_get("end_time_unix_secs")?,
|
||||
row.try_get("created_at_unix_secs")?,
|
||||
row.try_get("updated_at_unix_secs")?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SqlxAnnouncementReadRepository;
|
||||
use crate::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_constructs_from_lazy_pool() {
|
||||
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
||||
database_url: "postgres://localhost/aether".to_string(),
|
||||
min_connections: 1,
|
||||
max_connections: 4,
|
||||
acquire_timeout_ms: 1_000,
|
||||
idle_timeout_ms: 5_000,
|
||||
max_lifetime_ms: 30_000,
|
||||
statement_cache_capacity: 64,
|
||||
require_ssl: false,
|
||||
})
|
||||
.expect("factory should build");
|
||||
|
||||
let pool = factory.connect_lazy().expect("pool should build");
|
||||
let _repository = SqlxAnnouncementReadRepository::new(pool);
|
||||
}
|
||||
}
|
||||
237
crates/aether-data/src/repository/announcements/types.rs
Normal file
237
crates/aether-data/src/repository/announcements/types.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAnnouncement {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub priority: i32,
|
||||
pub is_active: bool,
|
||||
pub is_pinned: bool,
|
||||
pub author_id: Option<String>,
|
||||
pub author_username: Option<String>,
|
||||
pub start_time_unix_secs: Option<u64>,
|
||||
pub end_time_unix_secs: Option<u64>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub updated_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
impl StoredAnnouncement {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: String,
|
||||
title: String,
|
||||
content: String,
|
||||
kind: String,
|
||||
priority: i32,
|
||||
is_active: bool,
|
||||
is_pinned: bool,
|
||||
author_id: Option<String>,
|
||||
author_username: Option<String>,
|
||||
start_time_unix_secs: Option<i64>,
|
||||
end_time_unix_secs: Option<i64>,
|
||||
created_at_unix_secs: i64,
|
||||
updated_at_unix_secs: i64,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"announcements.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if title.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"announcements.title is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if content.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"announcements.content is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if kind.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"announcements.type is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
kind,
|
||||
priority,
|
||||
is_active,
|
||||
is_pinned,
|
||||
author_id,
|
||||
author_username,
|
||||
start_time_unix_secs: start_time_unix_secs
|
||||
.map(|value| parse_timestamp(value, "announcements.start_time"))
|
||||
.transpose()?,
|
||||
end_time_unix_secs: end_time_unix_secs
|
||||
.map(|value| parse_timestamp(value, "announcements.end_time"))
|
||||
.transpose()?,
|
||||
created_at_unix_secs: parse_timestamp(
|
||||
created_at_unix_secs,
|
||||
"announcements.created_at",
|
||||
)?,
|
||||
updated_at_unix_secs: parse_timestamp(
|
||||
updated_at_unix_secs,
|
||||
"announcements.updated_at",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AnnouncementListQuery {
|
||||
pub active_only: bool,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub now_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAnnouncementPage {
|
||||
pub items: Vec<StoredAnnouncement>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: i64, field: &str) -> Result<u64, crate::DataLayerError> {
|
||||
u64::try_from(value).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!("{field} is negative: {value}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AnnouncementReadRepository: Send + Sync {
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<Option<StoredAnnouncement>, crate::DataLayerError>;
|
||||
|
||||
async fn list_announcements(
|
||||
&self,
|
||||
query: &AnnouncementListQuery,
|
||||
) -> Result<StoredAnnouncementPage, crate::DataLayerError>;
|
||||
|
||||
async fn count_unread_active_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<u64, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CreateAnnouncementRecord {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub priority: i32,
|
||||
pub is_pinned: bool,
|
||||
pub author_id: String,
|
||||
pub start_time_unix_secs: Option<u64>,
|
||||
pub end_time_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl CreateAnnouncementRecord {
|
||||
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
|
||||
if self.title.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement title cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.content.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement content cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.kind.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement type cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.author_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement author_id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpdateAnnouncementRecord {
|
||||
pub announcement_id: String,
|
||||
pub title: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub priority: Option<i32>,
|
||||
pub is_active: Option<bool>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub start_time_unix_secs: Option<u64>,
|
||||
pub end_time_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl UpdateAnnouncementRecord {
|
||||
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
|
||||
if self.announcement_id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement_id cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.title
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement title cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.content
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement content cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.kind
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"announcement type cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AnnouncementWriteRepository: Send + Sync {
|
||||
async fn create_announcement(
|
||||
&self,
|
||||
record: CreateAnnouncementRecord,
|
||||
) -> Result<StoredAnnouncement, crate::DataLayerError>;
|
||||
|
||||
async fn update_announcement(
|
||||
&self,
|
||||
record: UpdateAnnouncementRecord,
|
||||
) -> Result<Option<StoredAnnouncement>, crate::DataLayerError>;
|
||||
|
||||
async fn delete_announcement(
|
||||
&self,
|
||||
announcement_id: &str,
|
||||
) -> Result<bool, crate::DataLayerError>;
|
||||
|
||||
async fn mark_announcement_as_read(
|
||||
&self,
|
||||
user_id: &str,
|
||||
announcement_id: &str,
|
||||
read_at_unix_secs: u64,
|
||||
) -> Result<bool, crate::DataLayerError>;
|
||||
}
|
||||
Reference in New Issue
Block a user