feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+715
View File
@@ -0,0 +1,715 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::immediate::{ArticleAction, ArticleEvent};
use crate::models::channels::{Article, ArticleComment, ArticleReaction};
use crate::models::common::{ArticleStatus, Visibility};
use crate::service::ImService;
use crate::service::im::events::ImEvent;
use super::session::ImSession;
use super::util::*;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateArticleParams {
pub title: String,
pub summary: Option<String>,
pub body: String,
pub cover_image_url: Option<String>,
pub tags: Option<Vec<String>>,
pub visibility: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateArticleParams {
pub title: Option<String>,
pub summary: Option<String>,
pub body: Option<String>,
pub cover_image_url: Option<String>,
pub tags: Option<Vec<String>>,
pub visibility: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct ArticleListFilters {
pub status: Option<String>,
pub tag: Option<String>,
pub author_id: Option<Uuid>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateArticleCommentParams {
pub body: String,
pub parent_comment_id: Option<Uuid>,
}
impl ImService {
async fn article_realtime(&self, channel_id: Uuid, article_id: Uuid, action: ArticleAction) {
let request_id = Uuid::nil();
let event = ArticleEvent {
channel_id,
article_id,
action,
};
self.publish(&format!("im.article.{}", channel_id), request_id, &event)
.await;
self.emit_event(ImEvent::Article {
request_id,
data: event,
});
}
pub async fn article_list(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
filters: ArticleListFilters,
limit: i64,
offset: i64,
) -> Result<Vec<Article>, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_readable(user_uid, &channel).await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
let status = filters
.status
.as_deref()
.and_then(|s| s.parse::<ArticleStatus>().ok())
.filter(|s| *s != ArticleStatus::Unknown);
sqlx::query_as::<_, Article>(
"SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
metadata, created_at, updated_at, deleted_at \
FROM article WHERE channel_id = $1 AND deleted_at IS NULL \
AND ($2::text IS NULL OR status::text = $2) \
AND ($3::uuid IS NULL OR author_id = $3) \
AND ($4::text IS NULL OR $4 = ANY(tags)) \
ORDER BY created_at DESC LIMIT $5 OFFSET $6",
)
.bind(channel_id)
.bind(status.map(|s| s.to_string()))
.bind(filters.author_id)
.bind(filters.tag.as_deref())
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn article_get(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
) -> Result<Article, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_readable(user_uid, &channel).await?;
let article = self.resolve_article(article_id, channel_id).await?;
// Increment view count (best-effort, not in a txn)
let _ = sqlx::query("UPDATE article SET views_count = views_count + 1 WHERE id = $1")
.bind(article_id)
.execute(self.ctx.db.writer())
.await;
Ok(article)
}
pub async fn article_create(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
params: CreateArticleParams,
) -> Result<Article, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_editable(user_uid, &channel).await?;
let title = required_text(params.title, "title")?;
if title.len() > MAX_ARTICLE_TITLE {
return Err(AppError::BadRequest("article title too long".into()));
}
let body = required_text(params.body, "body")?;
let visibility = parse_enum(
params.visibility,
Visibility::Public,
Visibility::Unknown,
"visibility",
)?;
let slug = self.generate_article_slug(channel_id, &title).await?;
let now = chrono::Utc::now();
let tags = params.tags.unwrap_or_default();
let article = sqlx::query_as::<_, Article>(
"INSERT INTO article \
(id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, cross_posted, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10, false, $11, $11) \
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
metadata, created_at, updated_at, deleted_at",
)
.bind(Uuid::now_v7())
.bind(channel_id)
.bind(user_uid)
.bind(&title)
.bind(&slug)
.bind(params.summary.as_deref())
.bind(&body)
.bind(params.cover_image_url.as_deref())
.bind(visibility)
.bind(&tags)
.bind(now)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.article_realtime(channel_id, article.id, ArticleAction::Created)
.await;
Ok(article)
}
pub async fn article_update(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
params: UpdateArticleParams,
) -> Result<Article, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
let article = self.resolve_article(article_id, channel_id).await?;
if article.author_id != user_uid {
self.ensure_channel_admin(user_uid, &channel).await?;
}
let new_title = match params.title {
Some(t) => {
let t = required_text(t, "title")?;
if t.len() > MAX_ARTICLE_TITLE {
return Err(AppError::BadRequest("article title too long".into()));
}
t
}
None => article.title,
};
let new_body = params.body.unwrap_or(article.body);
let new_summary = params.summary.or(article.summary);
let new_cover = params.cover_image_url.or(article.cover_image_url);
let new_tags = params.tags.unwrap_or(article.tags);
let visibility = parse_enum(
params.visibility,
article.visibility,
Visibility::Unknown,
"visibility",
)?;
let now = chrono::Utc::now();
let updated = sqlx::query_as::<_, Article>(
"UPDATE article SET title = $1, summary = $2, body = $3, cover_image_url = $4, \
tags = $5, visibility = $6, updated_at = $7 \
WHERE id = $8 AND deleted_at IS NULL \
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
metadata, created_at, updated_at, deleted_at",
)
.bind(&new_title)
.bind(&new_summary)
.bind(&new_body)
.bind(&new_cover)
.bind(&new_tags)
.bind(visibility)
.bind(now)
.bind(article_id)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
.await;
Ok(updated)
}
pub async fn article_publish(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
) -> Result<Article, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
let article = self.resolve_article(article_id, channel_id).await?;
if article.author_id != user_uid {
self.ensure_channel_editable(user_uid, &channel).await?;
}
if article.status != ArticleStatus::Draft && article.status != ArticleStatus::Scheduled {
return Err(AppError::BadRequest(
"only draft or scheduled articles can be published".into(),
));
}
let now = chrono::Utc::now();
let published = sqlx::query_as::<_, Article>(
"UPDATE article SET status = 'published', published_at = $1, published_by = $2, \
updated_at = $1 \
WHERE id = $3 AND deleted_at IS NULL \
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
metadata, created_at, updated_at, deleted_at",
)
.bind(now)
.bind(user_uid)
.bind(article_id)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
// Trigger cross-posts to followers
if let Err(e) = self
.cross_post_article(article_id, channel_id, user_uid)
.await
{
tracing::warn!(article_id = %article_id, error = %e, "cross-post failed");
}
tracing::info!(article_id = %article_id, "Article published");
self.article_realtime(channel_id, article_id, ArticleAction::Published)
.await;
Ok(published)
}
pub async fn article_unpublish(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
) -> Result<Article, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
let article = self.resolve_article(article_id, channel_id).await?;
if article.author_id != user_uid {
self.ensure_channel_editable(user_uid, &channel).await?;
}
let now = chrono::Utc::now();
let unpublished = sqlx::query_as::<_, Article>(
"UPDATE article SET status = 'unpublished', unpublished_at = $1, updated_at = $1 \
WHERE id = $2 AND status = 'published' AND deleted_at IS NULL \
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
metadata, created_at, updated_at, deleted_at",
)
.bind(now)
.bind(article_id)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.article_realtime(channel_id, article_id, ArticleAction::Unpublished)
.await;
Ok(unpublished)
}
pub async fn article_schedule(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
scheduled_at: chrono::DateTime<chrono::Utc>,
) -> Result<Article, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
let article = self.resolve_article(article_id, channel_id).await?;
if article.author_id != user_uid {
self.ensure_channel_editable(user_uid, &channel).await?;
}
if article.status != ArticleStatus::Draft {
return Err(AppError::BadRequest(
"only draft articles can be scheduled".into(),
));
}
let now = chrono::Utc::now();
let scheduled = sqlx::query_as::<_, Article>(
"UPDATE article SET status = 'scheduled', scheduled_at = $1, updated_at = $2 \
WHERE id = $3 AND deleted_at IS NULL \
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
metadata, created_at, updated_at, deleted_at",
)
.bind(scheduled_at)
.bind(now)
.bind(article_id)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
.await;
Ok(scheduled)
}
pub async fn article_delete(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
let article = self.resolve_article(article_id, channel_id).await?;
if article.author_id != user_uid {
self.ensure_channel_admin(user_uid, &channel).await?;
}
let now = chrono::Utc::now();
let result = sqlx::query(
"UPDATE article SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
)
.bind(now)
.bind(article_id)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "article not found")?;
self.article_realtime(channel_id, article_id, ArticleAction::Deleted)
.await;
Ok(())
}
pub async fn article_comment_list(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<ArticleComment>, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_readable(user_uid, &channel).await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, ArticleComment>(
"SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \
edited_at, deleted_at, created_at, updated_at \
FROM article_comment WHERE article_id = $1 AND deleted_at IS NULL \
ORDER BY created_at ASC LIMIT $2 OFFSET $3",
)
.bind(article_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn article_comment_create(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
params: CreateArticleCommentParams,
) -> Result<ArticleComment, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_readable(user_uid, &channel).await?;
let body = required_text(params.body, "body")?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let comment = sqlx::query_as::<_, ArticleComment>(
"INSERT INTO article_comment \
(id, article_id, channel_id, author_id, parent_comment_id, body, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \
RETURNING id, article_id, channel_id, author_id, parent_comment_id, body, \
edited_at, deleted_at, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(article_id)
.bind(channel_id)
.bind(user_uid)
.bind(params.parent_comment_id)
.bind(&body)
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query("UPDATE article SET comments_count = comments_count + 1 WHERE id = $1")
.bind(article_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
.await;
Ok(comment)
}
pub async fn article_comment_delete(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
comment_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
let comment = sqlx::query_as::<_, ArticleComment>(
"SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \
edited_at, deleted_at, created_at, updated_at \
FROM article_comment WHERE id = $1 AND article_id = $2 AND deleted_at IS NULL",
)
.bind(comment_id)
.bind(article_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("comment not found".into()))?;
if comment.author_id != user_uid {
self.ensure_channel_admin(user_uid, &channel).await?;
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query("UPDATE article_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2")
.bind(now)
.bind(comment_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"UPDATE article SET comments_count = GREATEST(comments_count - 1, 0) WHERE id = $1",
)
.bind(article_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
.await;
Ok(())
}
pub async fn article_reaction_add(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
content: &str,
) -> Result<ArticleReaction, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_readable(user_uid, &channel).await?;
let content = required_text(content.to_string(), "content")?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let reaction = sqlx::query_as::<_, ArticleReaction>(
"INSERT INTO article_reaction (id, article_id, channel_id, user_id, content, created_at) \
VALUES ($1, $2, $3, $4, $5, $6) \
ON CONFLICT (article_id, user_id, content) DO NOTHING \
RETURNING id, article_id, channel_id, user_id, content, created_at",
)
.bind(Uuid::now_v7())
.bind(article_id)
.bind(channel_id)
.bind(user_uid)
.bind(&content)
.bind(now)
.fetch_optional(&mut *txn)
.await
.map_err(AppError::Database)?;
if reaction.is_some() {
sqlx::query("UPDATE article SET reactions_count = reactions_count + 1 WHERE id = $1")
.bind(article_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
}
txn.commit().await.map_err(|_| AppError::TxnError)?;
let reaction = reaction.ok_or(AppError::Conflict("reaction already exists".into()))?;
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
.await;
Ok(reaction)
}
pub async fn article_reaction_remove(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
content: &str,
) -> Result<(), AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_readable(user_uid, &channel).await?;
let _now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"DELETE FROM article_reaction WHERE article_id = $1 AND user_id = $2 AND content = $3",
)
.bind(article_id)
.bind(user_uid)
.bind(content)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "reaction not found")?;
sqlx::query(
"UPDATE article SET reactions_count = GREATEST(reactions_count - 1, 0) WHERE id = $1",
)
.bind(article_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
.await;
Ok(())
}
pub(crate) async fn resolve_article(
&self,
article_id: Uuid,
channel_id: Uuid,
) -> Result<Article, AppError> {
sqlx::query_as::<_, Article>(
"SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
metadata, created_at, updated_at, deleted_at \
FROM article WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL",
)
.bind(article_id)
.bind(channel_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("article not found".into()))
}
async fn generate_article_slug(
&self,
channel_id: Uuid,
title: &str,
) -> Result<String, AppError> {
let base = slugify(title);
let mut slug = base.clone();
let mut counter = 1u32;
loop {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM article WHERE channel_id = $1 AND slug = $2)",
)
.bind(channel_id)
.bind(&slug)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if !exists {
return Ok(slug);
}
slug = format!("{base}-{counter}");
counter += 1;
if counter > 100 {
return Err(AppError::InternalServerError(
"failed to generate unique slug".into(),
));
}
}
}
}