221 lines
7.7 KiB
Python
221 lines
7.7 KiB
Python
# 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)
|