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
+232
View File
@@ -0,0 +1,232 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::immediate::{FollowAction, FollowEvent};
use crate::models::channels::{ArticleCrossPost, ChannelFollow};
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 FollowChannelParams {
pub target_workspace_id: Uuid,
pub target_channel_id: Option<Uuid>,
pub webhook_url: Option<String>,
}
impl ImService {
async fn follow_realtime(&self, channel_id: Uuid, follow_id: Uuid, action: FollowAction) {
let request_id = Uuid::nil();
let event = FollowEvent {
channel_id,
follow_id,
action,
};
self.publish(&format!("im.follow.{channel_id}"), request_id, &event)
.await;
self.emit_event(ImEvent::Follow {
request_id,
data: event,
});
}
pub async fn follow_list(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
) -> Result<Vec<ChannelFollow>, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_admin(user_uid, &channel).await?;
sqlx::query_as::<_, ChannelFollow>(
"SELECT id, source_channel_id, target_workspace_id, target_channel_id, \
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \
FROM channel_follow WHERE source_channel_id = $1 AND unfollowed_at IS NULL \
ORDER BY created_at DESC",
)
.bind(channel_id)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn follow_create(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
params: FollowChannelParams,
) -> Result<ChannelFollow, 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 follow = sqlx::query_as::<_, ChannelFollow>(
"INSERT INTO channel_follow \
(id, source_channel_id, target_workspace_id, target_channel_id, \
webhook_url, enabled, followed_by, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, true, $6, $7, $7) \
ON CONFLICT (source_channel_id, target_workspace_id, target_channel_id) \
DO UPDATE SET enabled = true, unfollowed_at = NULL, updated_at = $7 \
RETURNING id, source_channel_id, target_workspace_id, target_channel_id, \
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(channel_id)
.bind(params.target_workspace_id)
.bind(params.target_channel_id)
.bind(params.webhook_url.as_deref())
.bind(user_uid)
.bind(now)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.follow_realtime(channel_id, follow.id, FollowAction::Created)
.await;
Ok(follow)
}
pub async fn follow_delete(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
follow_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_admin(user_uid, &channel).await?;
let now = chrono::Utc::now();
let result = sqlx::query(
"UPDATE channel_follow SET unfollowed_at = $1, enabled = false, updated_at = $1 \
WHERE id = $2 AND source_channel_id = $3 AND unfollowed_at IS NULL",
)
.bind(now)
.bind(follow_id)
.bind(channel_id)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "follow not found")?;
self.follow_realtime(channel_id, follow_id, FollowAction::Deleted)
.await;
Ok(())
}
pub(crate) async fn cross_post_article(
&self,
article_id: Uuid,
channel_id: Uuid,
_actor_id: Uuid,
) -> Result<u64, AppError> {
let followers = sqlx::query_as::<_, ChannelFollow>(
"SELECT id, source_channel_id, target_workspace_id, target_channel_id, \
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \
FROM channel_follow WHERE source_channel_id = $1 AND enabled AND unfollowed_at IS NULL",
)
.bind(channel_id)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let now = chrono::Utc::now();
let mut count = 0u64;
for follow in &followers {
sqlx::query(
"INSERT INTO article_cross_post \
(id, article_id, follow_id, target_workspace_id, target_channel_id, \
status, attempts, created_at) \
VALUES ($1, $2, $3, $4, $5, 'pending', 0, $6) \
ON CONFLICT DO NOTHING",
)
.bind(Uuid::now_v7())
.bind(article_id)
.bind(follow.id)
.bind(follow.target_workspace_id)
.bind(follow.target_channel_id)
.bind(now)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
count += 1;
}
if count > 0 {
sqlx::query("UPDATE article SET cross_posted = true WHERE id = $1")
.bind(article_id)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
}
tracing::info!(
article_id = %article_id,
followers = count,
"Cross-post jobs created"
);
Ok(count)
}
pub async fn cross_post_list(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
article_id: Uuid,
) -> Result<Vec<ArticleCrossPost>, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_admin(user_uid, &channel).await?;
sqlx::query_as::<_, ArticleCrossPost>(
"SELECT id, article_id, follow_id, target_workspace_id, target_channel_id, \
status, attempts, last_error, sent_at, delivered_at, failed_at, created_at \
FROM article_cross_post WHERE article_id = $1 ORDER BY created_at ASC",
)
.bind(article_id)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn cross_post_retry(
&self,
ctx: &ImSession,
_wk_name: &str,
channel_id: Uuid,
cross_post_id: Uuid,
) -> Result<ArticleCrossPost, AppError> {
let user_uid = ctx.user;
let channel = self.resolve_channel(channel_id).await?;
self.ensure_channel_admin(user_uid, &channel).await?;
let post = sqlx::query_as::<_, ArticleCrossPost>(
"UPDATE article_cross_post SET status = 'pending', attempts = 0, \
last_error = NULL, failed_at = NULL \
WHERE id = $1 AND status = 'failed' \
RETURNING id, article_id, follow_id, target_workspace_id, target_channel_id, \
status, attempts, last_error, sent_at, delivered_at, failed_at, created_at",
)
.bind(cross_post_id)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.follow_realtime(channel_id, post.follow_id, FollowAction::Retried)
.await;
Ok(post)
}
}