AI-News/backend/app/api/routes/articles/articles_resource.py

221 lines
7.7 KiB
Python
Raw Normal View History

2025-12-04 10:04:21 +08:00
# 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)