AI-News/backend/app/api/routes/articles/articles_resource.py
2025-12-04 10:04:21 +08:00

221 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)