use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; use crate::models::wiki::WikiPage; use crate::service::RepoService; use crate::session::Session; use super::util::{clamp_limit_offset, ensure_affected, required_text}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateWikiPageParams { pub title: String, pub content: String, } #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateWikiPageParams { pub title: Option, pub content: Option, pub commit_message: Option, } impl RepoService { /// 列出仓库的所有 wiki 页面 pub async fn wiki_list_pages( &self, ctx: &Session, wk_name: &str, repo_name: &str, search: Option, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; let (limit, offset) = clamp_limit_offset(limit, offset); if let Some(query) = search { let pattern = format!("%{}%", query); sqlx::query_as::<_, WikiPage>( "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ created_at, updated_at, deleted_at \ FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \ AND (title ILIKE $2 OR content ILIKE $2) \ ORDER BY updated_at DESC LIMIT $3 OFFSET $4", ) .bind(repo.id) .bind(&pattern) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } else { sqlx::query_as::<_, WikiPage>( "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ created_at, updated_at, deleted_at \ FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \ ORDER BY updated_at DESC LIMIT $2 OFFSET $3", ) .bind(repo.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } } /// 获取单个 wiki 页面 pub async fn wiki_get_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; sqlx::query_as::<_, WikiPage>( "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ created_at, updated_at, deleted_at \ FROM wiki_page WHERE repo_id = $1 AND slug = $2 AND deleted_at IS NULL", ) .bind(repo.id) .bind(slug) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or_else(|| AppError::NotFound("Wiki page not found".into())) } /// 创建 wiki 页面 pub async fn wiki_create_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, params: CreateWikiPageParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let title = required_text(params.title, "title")?; let content = required_text(params.content, "content")?; let slug = self.generate_wiki_slug(repo.id, &title).await?; let now = chrono::Utc::now(); let page_id = Uuid::now_v7(); 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 page = sqlx::query_as::<_, WikiPage>( "INSERT INTO wiki_page (id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $6, 1, $7, $7) \ RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at", ) .bind(page_id) .bind(repo.id) .bind(&slug) .bind(&title) .bind(&content) .bind(user_uid) .bind(now) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \ VALUES ($1, $2, 1, $3, $4, $5, 'Initial creation', $6)", ) .bind(Uuid::now_v7()) .bind(page_id) .bind(&title) .bind(&content) .bind(user_uid) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(page) } /// 更新 wiki 页面 pub async fn wiki_update_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, params: UpdateWikiPageParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; let new_title = params.title.unwrap_or(page.title.clone()); let new_content = params.content.unwrap_or(page.content.clone()); let new_version = page.version + 1; let commit_message = params .commit_message .unwrap_or_else(|| "Updated page".into()); 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 updated = sqlx::query_as::<_, WikiPage>( "UPDATE wiki_page SET title = $1, content = $2, last_editor_id = $3, version = $4, updated_at = $5 \ WHERE id = $6 AND deleted_at IS NULL \ RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at", ) .bind(&new_title) .bind(&new_content) .bind(user_uid) .bind(new_version) .bind(now) .bind(page.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(Uuid::now_v7()) .bind(page.id) .bind(new_version) .bind(&new_title) .bind(&new_content) .bind(user_uid) .bind(&commit_message) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(updated) } /// 删除 wiki 页面(软删除) pub async fn wiki_delete_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) .await?; let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; let now = chrono::Utc::now(); let result = sqlx::query( "UPDATE wiki_page SET deleted_at = $1 WHERE id = $2 AND deleted_at IS NULL", ) .bind(now) .bind(page.id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "wiki page not found") } /// 回滚到历史版本 pub async fn wiki_revert_to_version( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, target_version: i32, commit_message: Option, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; let revision = sqlx::query_as::<_, crate::models::wiki::WikiPageRevision>( "SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \ FROM wiki_page_revision WHERE page_id = $1 AND version = $2", ) .bind(page.id) .bind(target_version) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or_else(|| AppError::NotFound("Revision not found".into()))?; let msg = commit_message.unwrap_or_else(|| format!("Reverted to version {}", target_version)); self.wiki_update_page( ctx, wk_name, repo_name, slug, UpdateWikiPageParams { title: Some(revision.title), content: Some(revision.content), commit_message: Some(msg), }, ) .await } fn generate_slug(title: &str) -> String { title .to_lowercase() .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect::() .split('-') .filter(|s| !s.is_empty()) .collect::>() .join("-") } async fn generate_wiki_slug(&self, repo_id: Uuid, title: &str) -> Result { let base_slug = Self::generate_slug(title); let mut slug = base_slug.clone(); let mut counter = 1; loop { let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM wiki_page WHERE repo_id = $1 AND slug = $2)", ) .bind(repo_id) .bind(&slug) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if !exists { return Ok(slug); } slug = format!("{}-{}", base_slug, counter); counter += 1; if counter > 100 { return Err(AppError::InternalServerError( "Failed to generate unique slug".into(), )); } } } }