# app/api/routes/articles/articles_resource.py from typing import Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response from starlette import status from app.api.dependencies.articles import ( check_article_modification_permissions, get_article_by_slug_from_path, get_articles_filters, ) from app.api.dependencies.authentication import get_current_user_authorizer from app.api.dependencies.database import get_repository from app.db.repositories.articles import ArticlesRepository from app.db.repositories.menu_slots import DEFAULT_MENU_SLOTS, MenuSlotsRepository from app.models.domain.articles import Article from app.models.domain.users import User from app.models.schemas.articles import ( DEFAULT_ARTICLES_LIMIT, DEFAULT_ARTICLES_OFFSET, ArticleForResponse, ArticleInCreate, ArticleInResponse, ArticleInUpdate, ArticlesFilters, ListOfArticlesInResponse, ) from app.resources import strings from app.services.articles import check_article_exists, get_slug_for_article router = APIRouter() DEFAULT_MENU_SLOT_KEYS = {slot["slot_key"] for slot in DEFAULT_MENU_SLOTS} @router.get( "", response_model=ListOfArticlesInResponse, name="articles:list-articles", ) async def list_articles( articles_filters: ArticlesFilters = Depends(get_articles_filters), # ✅ 可选用户:未登录/坏 token 都允许,只是 requested_user=None user: Optional[User] = Depends(get_current_user_authorizer(required=False)), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ListOfArticlesInResponse: articles = await articles_repo.filter_articles( tag=articles_filters.tag, tags=articles_filters.tags, author=articles_filters.author, favorited=articles_filters.favorited, search=articles_filters.search, limit=articles_filters.limit, offset=articles_filters.offset, requested_user=user, ) articles_for_response = [ ArticleForResponse.from_orm(article) for article in articles ] return ListOfArticlesInResponse( articles=articles_for_response, articles_count=len(articles), ) @router.get( "/menu/{slot_key}", response_model=ListOfArticlesInResponse, name="articles:list-by-menu-slot", ) async def list_articles_by_menu_slot( slot_key: str, limit: int = Query(DEFAULT_ARTICLES_LIMIT, ge=1, le=200), offset: int = Query(DEFAULT_ARTICLES_OFFSET, ge=0), mode: str = Query("and", description="tag match mode: and/or"), user: Optional[User] = Depends(get_current_user_authorizer(required=False)), menu_slots_repo: MenuSlotsRepository = Depends(get_repository(MenuSlotsRepository)), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ListOfArticlesInResponse: slot = await menu_slots_repo.get_slot(slot_key) if not slot and slot_key not in DEFAULT_MENU_SLOT_KEYS: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Menu slot not found", ) if not slot: default_label = next( (s["label"] for s in DEFAULT_MENU_SLOTS if s["slot_key"] == slot_key), slot_key, ) slot = await menu_slots_repo.upsert_slot_tags( slot_key=slot_key, tags=[], label=default_label, ) tags = slot["tags"] or [] articles = await articles_repo.filter_articles( tags=tags, limit=limit, offset=offset, requested_user=user, tag_mode=mode, ) # 如果严格 AND 结果为空且指定了标签,则降级为 OR,避免前台完全空白 if mode == "and" and tags and not articles: articles = await articles_repo.filter_articles( tags=tags, limit=limit, offset=offset, requested_user=user, tag_mode="or", ) articles_for_response = [ ArticleForResponse.from_orm(article) for article in articles ] return ListOfArticlesInResponse( articles=articles_for_response, articles_count=len(articles), ) @router.post( "", status_code=status.HTTP_201_CREATED, response_model=ArticleInResponse, name="articles:create-article", ) async def create_new_article( article_create: ArticleInCreate = Body(..., embed=True, alias="article"), # ✅ 必须登录 user: User = Depends(get_current_user_authorizer()), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ArticleInResponse: slug = get_slug_for_article(article_create.title) if await check_article_exists(articles_repo, slug): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=strings.ARTICLE_ALREADY_EXISTS, ) article = await articles_repo.create_article( slug=slug, title=article_create.title, description=article_create.description, body=article_create.body, author=user, tags=article_create.tags, cover=article_create.cover, # 支持封面 ) return ArticleInResponse(article=ArticleForResponse.from_orm(article)) @router.get( "/{slug}", response_model=ArticleInResponse, name="articles:get-article", ) async def retrieve_article_by_slug( # ❗ 不再使用 get_article_by_slug_from_path(它通常会强制鉴权) slug: str, # ✅ 可选用户:支持个性化(是否已收藏等),但不影响公开访问 user: Optional[User] = Depends(get_current_user_authorizer(required=False)), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ArticleInResponse: """ 文章详情:对所有人开放访问。 - 未登录 / token 缺失 / token 无效 -> user 为 None,正常返回文章。 - 已登录且 token 有效 -> user 有值,可用于 favorited 等字段计算。 """ article = await articles_repo.get_article_by_slug( slug=slug, requested_user=user, ) # 每次访问详情时累加查看次数,并同步更新返回对象 article.views = await articles_repo.increment_article_views(slug=slug) return ArticleInResponse(article=ArticleForResponse.from_orm(article)) @router.put( "/{slug}", response_model=ArticleInResponse, name="articles:update-article", dependencies=[Depends(check_article_modification_permissions)], ) async def update_article_by_slug( article_update: ArticleInUpdate = Body(..., embed=True, alias="article"), current_article: Article = Depends(get_article_by_slug_from_path), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ArticleInResponse: slug = get_slug_for_article(article_update.title) if article_update.title else None # 是否在本次请求里显式传了 cover 字段(视你的 ArticleInUpdate 定义而定) cover_provided = "cover" in article_update.__fields_set__ article = await articles_repo.update_article( article=current_article, slug=slug, title=article_update.title, body=article_update.body, description=article_update.description, cover=article_update.cover, cover_provided=cover_provided, ) return ArticleInResponse(article=ArticleForResponse.from_orm(article)) @router.delete( "/{slug}", status_code=status.HTTP_204_NO_CONTENT, name="articles:delete-article", dependencies=[Depends(check_article_modification_permissions)], response_class=Response, ) async def delete_article_by_slug( article: Article = Depends(get_article_by_slug_from_path), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> None: await articles_repo.delete_article(article=article)