# app/db/repositories/articles.py from typing import List, Optional, Sequence, Tuple from asyncpg import Connection, Record from pathlib import Path from app.db.errors import EntityDoesNotExist from app.db.queries.queries import queries from app.db.repositories.base import BaseRepository from app.db.repositories.profiles import ProfilesRepository from app.db.repositories.tags import TagsRepository from app.models.domain.articles import Article from app.models.domain.users import User AUTHOR_USERNAME_ALIAS = "author_username" SLUG_ALIAS = "slug" class ArticlesRepository(BaseRepository): # noqa: WPS214 def __init__(self, conn: Connection) -> None: super().__init__(conn) self._profiles_repo = ProfilesRepository(conn) self._tags_repo = TagsRepository(conn) # ===== 内部工具 ===== async def _ensure_article_flag_columns(self) -> None: """ 给 articles 表补充置顶/推荐/权重字段,兼容旧库。 多次执行使用 IF NOT EXISTS,不会抛错。 """ await self.connection.execute( """ ALTER TABLE articles ADD COLUMN IF NOT EXISTS is_top BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS sort_weight INT NOT NULL DEFAULT 0; """, ) def _try_delete_cover_file(self, cover: Optional[str]) -> None: """ 清空 cover 时顺带删除 static/uploads 下的旧封面文件。 """ if not cover: return try: path_str = cover.lstrip("/") if not path_str.startswith("static/uploads/"): return p = Path(path_str) if not p.is_absolute(): p = Path(".") / p if p.is_file(): p.unlink() except Exception: # 可以按需加日志,这里静默 pass # ===== CRUD ===== async def create_article( # noqa: WPS211 self, *, slug: str, title: str, description: str, body: str, author: User, tags: Optional[Sequence[str]] = None, cover: Optional[str] = None, ) -> Article: await self._ensure_article_flag_columns() async with self.connection.transaction(): article_row = await queries.create_new_article( self.connection, slug=slug, title=title, description=description, body=body, author_username=author.username, cover=cover, ) if tags: await self._tags_repo.create_tags_that_dont_exist(tags=tags) await self._link_article_with_tags(slug=slug, tags=tags) return await self._get_article_from_db_record( article_row=article_row, slug=slug, author_username=article_row[AUTHOR_USERNAME_ALIAS], requested_user=author, ) async def update_article( # noqa: WPS211 self, *, article: Article, slug: Optional[str] = None, title: Optional[str] = None, body: Optional[str] = None, description: Optional[str] = None, cover: Optional[str] = None, cover_provided: bool = False, ) -> Article: """ cover_provided: - True 表示本次请求体里包含 cover 字段(可能是字符串/""/null) - False 表示前端没动 cover,保持不变 """ await self._ensure_article_flag_columns() updated_article = article.copy(deep=True) updated_article.slug = slug or updated_article.slug updated_article.title = title or article.title updated_article.body = body or article.body updated_article.description = description or article.description old_cover = article.cover if cover_provided: # 约定:None / "" 视为清空封面 updated_article.cover = cover or None async with self.connection.transaction(): updated_row = await queries.update_article( self.connection, slug=article.slug, author_username=article.author.username, new_slug=updated_article.slug, new_title=updated_article.title, new_body=updated_article.body, new_description=updated_article.description, new_cover=updated_article.cover, ) updated_article.updated_at = updated_row["updated_at"] # 如果这次真的更新了 cover,并且旧值存在且发生变化,则尝试删除旧文件 if cover_provided and old_cover and old_cover != updated_article.cover: self._try_delete_cover_file(old_cover) return updated_article async def delete_article(self, *, article: Article) -> None: await self._ensure_article_flag_columns() async with self.connection.transaction(): await queries.delete_article( self.connection, slug=article.slug, author_username=article.author.username, ) if article.cover: self._try_delete_cover_file(article.cover) async def filter_articles( # noqa: WPS211 self, *, tag: Optional[str] = None, tags: Optional[Sequence[str]] = None, author: Optional[str] = None, favorited: Optional[str] = None, search: Optional[str] = None, limit: int = 20, offset: int = 0, requested_user: Optional[User] = None, tag_mode: str = "and", ) -> List[Article]: await self._ensure_article_flag_columns() tag_list: List[str] = [] if tags: tag_list.extend([t.strip() for t in tags if str(t).strip()]) if tag: tag_list.append(tag.strip()) # 去重,保留顺序 seen = set() tag_list = [t for t in tag_list if not (t in seen or seen.add(t))] tag_mode = (tag_mode or "and").lower() if tag_mode not in ("and", "or"): tag_mode = "and" params: List[object] = [] joins: List[str] = ["LEFT JOIN users u ON u.id = a.author_id"] where_clauses: List[str] = [] having_clause = "" if author: params.append(author) where_clauses.append(f"u.username = ${len(params)}") if favorited: params.append(favorited) joins.append( f"""JOIN favorites f ON f.article_id = a.id AND f.user_id = (SELECT id FROM users WHERE username = ${len(params)})""", ) if tag_list: params.append(tag_list) joins.append( f"JOIN articles_to_tags att ON att.article_id = a.id AND att.tag = ANY(${len(params)})", ) # AND 逻辑:命中全部 tag if tag_mode == "and": having_clause = f"HAVING COUNT(DISTINCT att.tag) >= {len(tag_list)}" if search: params.append(f"%{search}%") where_clauses.append( f"(a.title ILIKE ${len(params)} OR a.description ILIKE ${len(params)} OR a.slug ILIKE ${len(params)})", ) where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" limit_idx = len(params) + 1 offset_idx = len(params) + 2 params.extend([limit, offset]) group_cols = ", ".join( [ "a.id", "a.slug", "a.title", "a.description", "a.body", "a.cover", "a.views", "a.created_at", "a.updated_at", "a.is_top", "a.is_featured", "a.sort_weight", "u.username", ], ) sql = f""" SELECT a.id, a.slug, a.title, a.description, a.body, a.cover, a.views, a.created_at, a.updated_at, a.is_top, a.is_featured, a.sort_weight, u.username AS {AUTHOR_USERNAME_ALIAS} FROM articles a {' '.join(joins)} {where_sql} GROUP BY {group_cols} {having_clause} ORDER BY a.is_top DESC, a.sort_weight DESC, a.updated_at DESC, a.created_at DESC LIMIT ${limit_idx} OFFSET ${offset_idx} """ articles_rows = await self.connection.fetch(sql, *params) return [ await self._get_article_from_db_record( article_row=article_row, slug=article_row[SLUG_ALIAS], author_username=article_row[AUTHOR_USERNAME_ALIAS], requested_user=requested_user, ) for article_row in articles_rows ] async def list_articles_by_slugs( self, *, slugs: Sequence[str], requested_user: Optional[User] = None, ) -> List[Article]: """ 按给定顺序批量获取文章;缺失的 slug 会被忽略。 """ if not slugs: return [] await self._ensure_article_flag_columns() unique_slugs: List[str] = [] for slug in slugs: if slug not in unique_slugs: unique_slugs.append(slug) rows = await self.connection.fetch( f""" SELECT a.id, a.slug, a.title, a.description, a.body, a.cover, a.views, a.is_top, a.is_featured, a.sort_weight, a.created_at, a.updated_at, u.username AS {AUTHOR_USERNAME_ALIAS} FROM articles a LEFT JOIN users u ON u.id = a.author_id WHERE a.slug = ANY($1::text[]) ORDER BY array_position($1::text[], a.slug) """, unique_slugs, ) articles: List[Article] = [] for row in rows: articles.append( await self._get_article_from_db_record( article_row=row, slug=row[SLUG_ALIAS], author_username=row[AUTHOR_USERNAME_ALIAS], requested_user=requested_user, ), ) return articles async def get_articles_for_user_feed( self, *, user: User, limit: int = 20, offset: int = 0, ) -> List[Article]: await self._ensure_article_flag_columns() articles_rows = await queries.get_articles_for_feed( self.connection, follower_username=user.username, limit=limit, offset=offset, ) return [ await self._get_article_from_db_record( article_row=article_row, slug=article_row[SLUG_ALIAS], author_username=article_row[AUTHOR_USERNAME_ALIAS], requested_user=user, ) for article_row in articles_rows ] async def get_article_by_slug( self, *, slug: str, requested_user: Optional[User] = None, ) -> Article: await self._ensure_article_flag_columns() article_row = await queries.get_article_by_slug(self.connection, slug=slug) if article_row: return await self._get_article_from_db_record( article_row=article_row, slug=article_row[SLUG_ALIAS], author_username=article_row[AUTHOR_USERNAME_ALIAS], requested_user=requested_user, ) raise EntityDoesNotExist(f"article with slug {slug} does not exist") async def get_tags_for_article_by_slug(self, *, slug: str) -> List[str]: tag_rows = await queries.get_tags_for_article_by_slug( self.connection, slug=slug, ) return [row["tag"] for row in tag_rows] async def get_favorites_count_for_article_by_slug(self, *, slug: str) -> int: return ( await queries.get_favorites_count_for_article(self.connection, slug=slug) )["favorites_count"] async def is_article_favorited_by_user(self, *, slug: str, user: User) -> bool: return ( await queries.is_article_in_favorites( self.connection, username=user.username, slug=slug, ) )["favorited"] async def add_article_into_favorites(self, *, article: Article, user: User) -> None: await queries.add_article_to_favorites( self.connection, username=user.username, slug=article.slug, ) async def remove_article_from_favorites( self, *, article: Article, user: User, ) -> None: await queries.remove_article_from_favorites( self.connection, username=user.username, slug=article.slug, ) async def _get_article_from_db_record( self, *, article_row: Record, slug: str, author_username: str, requested_user: Optional[User], ) -> Article: cover = article_row.get("cover") if "cover" in article_row else None views = article_row.get("views", 0) is_top = bool(article_row.get("is_top", False)) is_featured = bool(article_row.get("is_featured", False)) sort_weight = int(article_row.get("sort_weight", 0) or 0) return Article( id_=article_row["id"], slug=slug, title=article_row["title"], description=article_row["description"], body=article_row["body"], cover=cover, is_top=is_top, is_featured=is_featured, sort_weight=sort_weight, views=views, author=await self._profiles_repo.get_profile_by_username( username=author_username, requested_user=requested_user, ), tags=await self.get_tags_for_article_by_slug(slug=slug), favorites_count=await self.get_favorites_count_for_article_by_slug( slug=slug, ), favorited=await self.is_article_favorited_by_user( slug=slug, user=requested_user, ) if requested_user else False, created_at=article_row["created_at"], updated_at=article_row["updated_at"], ) async def increment_article_views(self, *, slug: str) -> int: result = await queries.increment_article_views(self.connection, slug=slug) return result["views"] async def _link_article_with_tags(self, *, slug: str, tags: Sequence[str]) -> None: """ 把 tag 列表绑定到文章。 """ for tag in tags: await queries.add_tags_to_article( self.connection, slug=slug, tag=tag, ) async def list_articles_for_admin( self, *, search: Optional[str] = None, author: Optional[str] = None, limit: int = 20, offset: int = 0, ) -> Tuple[List[Article], int]: await self._ensure_article_flag_columns() clauses: List[str] = [] params: List[object] = [] if author: placeholder = f"${len(params) + 1}" params.append(author) clauses.append(f"u.username = {placeholder}") if search: placeholder = f"${len(params) + 1}" params.append(f"%{search}%") clauses.append( f"(a.title ILIKE {placeholder} OR a.slug ILIKE {placeholder} OR a.description ILIKE {placeholder})", ) where_sql = "" if clauses: where_sql = "WHERE " + " AND ".join(clauses) count_sql = f""" SELECT COUNT(*) FROM articles a LEFT JOIN users u ON u.id = a.author_id {where_sql} """ total = await self.connection.fetchval(count_sql, *params) list_params = list(params) list_params.extend([limit, offset]) list_sql = f""" SELECT a.id, a.slug, a.title, a.description, a.body, a.cover, a.views, a.created_at, a.updated_at, a.is_top, a.is_featured, a.sort_weight, u.username AS {AUTHOR_USERNAME_ALIAS} FROM articles a LEFT JOIN users u ON u.id = a.author_id {where_sql} ORDER BY a.is_top DESC, a.sort_weight DESC, a.updated_at DESC, a.created_at DESC LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} """ rows = await self.connection.fetch(list_sql, *list_params) articles = [ await self._get_article_from_db_record( article_row=row, slug=row[SLUG_ALIAS], author_username=row[AUTHOR_USERNAME_ALIAS], requested_user=None, ) for row in rows ] return articles, int(total or 0) async def admin_update_article( self, *, article: Article, slug: Optional[str] = None, title: Optional[str] = None, body: Optional[str] = None, description: Optional[str] = None, cover: Optional[str] = None, is_top: Optional[bool] = None, is_featured: Optional[bool] = None, sort_weight: Optional[int] = None, cover_provided: bool = False, ) -> Article: await self._ensure_article_flag_columns() updated_article = article.copy(deep=True) updated_article.slug = slug or updated_article.slug updated_article.title = title or article.title updated_article.body = body or article.body updated_article.description = description or article.description if is_top is not None: updated_article.is_top = is_top if is_featured is not None: updated_article.is_featured = is_featured if sort_weight is not None: updated_article.sort_weight = sort_weight old_cover = article.cover if cover_provided: updated_article.cover = cover or None async with self.connection.transaction(): updated_row = await self.connection.fetchrow( """ UPDATE articles SET slug = COALESCE($2, slug), title = COALESCE($3, title), body = COALESCE($4, body), description = COALESCE($5, description), cover = $6, is_top = COALESCE($7, is_top), is_featured = COALESCE($8, is_featured), sort_weight = COALESCE($9, sort_weight) WHERE id = $1 RETURNING updated_at """, article.id_, updated_article.slug, updated_article.title, updated_article.body, updated_article.description, updated_article.cover, updated_article.is_top, updated_article.is_featured, updated_article.sort_weight, ) updated_article.updated_at = updated_row["updated_at"] if cover_provided and old_cover and old_cover != updated_article.cover: self._try_delete_cover_file(old_cover) return updated_article async def admin_delete_article(self, *, article: Article) -> None: await self._ensure_article_flag_columns() async with self.connection.transaction(): await self.connection.execute( "DELETE FROM articles WHERE id = $1", article.id_, ) if article.cover: self._try_delete_cover_file(article.cover)