first commit

This commit is contained in:
zsh 2025-12-04 10:04:21 +08:00
commit 1f3eeb9193
252 changed files with 60272 additions and 0 deletions

10
_tmp_fix.py Normal file
View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from pathlib import Path
path = Path('frontend/app/components/RichEditor.vue')
data = path.read_text(encoding='utf-8')
data = data.replace("import { Table } from '@tiptap/extension-table'","import Table from '@tiptap/extension-table'")
data = data.replace("import { TableRow } from '@tiptap/extension-table-row'","import TableRow from '@tiptap/extension-table-row'")
data = data.replace("import { TableHeader } from '@tiptap/extension-table-header'","import TableHeader from '@tiptap/extension-table-header'")
data = data.replace("import { TableCell } from '@tiptap/extension-table-cell'","import TableCell from '@tiptap/extension-table-cell'")
data = data.replace('请输入正文支持粘贴图片、截图、链接等<EFBFBD>?', '请输入正文,支持粘贴图片、截图、链接等内容')
path.write_text(data, encoding='utf-8')

21
backend/.dockerignore Normal file
View File

@ -0,0 +1,21 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.env*
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
*.log
.git*
tests
scripts
postman
./postgres-data

20
backend/.env.example Normal file
View File

@ -0,0 +1,20 @@
# 邮件发信配置
MAIL_FROM=orjiance@163.com
SMTP_HOST=smtp.163.com
SMTP_PORT=465
SMTP_USER=orjiance@163.com
SMTP_PASSWORD=NFZqrTavzBGDQLyQ
SMTP_TLS=true
# 验证码
EMAIL_CODE_EXPIRES_MINUTES=60
# 可选:逗号分隔的场景列表(不写就用默认)
# EMAIL_CODE_SCENES=register,reset,login
# 逗号分隔的管理员邮箱,登录后自动授予 admin 权限
# ADMIN_EMAILS=admin@example.com
SECRET_KEY=secret
DEBUG=True
DATABASE_URL=postgresql://postgres:74110ZSH@localhost/aivise

BIN
backend/.github/assets/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

20
backend/.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,20 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: monthly
time: "12:00"
pull-request-branch-name:
separator: "-"
open-pull-requests-limit: 10
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: monthly
time: "12:00"
pull-request-branch-name:
separator: "-"
open-pull-requests-limit: 10

68
backend/.github/workflows/conduit.yml vendored Normal file
View File

@ -0,0 +1,68 @@
name: API spec
on:
push:
branches:
- "master"
pull_request:
branches:
- "*"
jobs:
api-spec:
name: API spec tests
runs-on: ubuntu-18.04
strategy:
matrix:
python-version: [3.9]
services:
postgres:
image: postgres:11.5-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: "1.1.12"
virtualenvs-in-project: true
- name: Set up cache
uses: actions/cache@v3
id: cache
with:
path: .venv
key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }}
- name: Ensure cache is healthy
if: steps.cache.outputs.cache-hit == 'true'
run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv
- name: Install dependencies
run: poetry install --no-interaction
- name: Run newman and test service
env:
SECRET_KEY: secret_key
DATABASE_URL: postgresql://postgres:postgres@localhost/postgres
run: |
poetry run alembic upgrade head
poetry run uvicorn app.main:app &
APIURL=http://localhost:8000/api ./postman/run-api-tests.sh
poetry run alembic downgrade base

26
backend/.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: Deploy
on:
push:
branches:
- master
env:
IMAGE_NAME: nsidnev/fastapi-realworld-example-app
DOCKER_USER: ${{ secrets.DOCKER_USER }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
jobs:
build:
name: Build Container
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v3
- name: Build image and publish to registry
run: |
docker build -t $IMAGE_NAME:latest .
echo $DOCKER_PASSWORD | docker login -u $DOCKER_USER --password-stdin
docker push $IMAGE_NAME:latest

50
backend/.github/workflows/styles.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: Styles
on:
push:
branches:
- "master"
pull_request:
branches:
- "*"
jobs:
lint:
name: Lint code
runs-on: ubuntu-18.04
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: "1.1.12"
virtualenvs-in-project: true
- name: Set up cache
uses: actions/cache@v3
id: cache
with:
path: .venv
key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }}
- name: Ensure cache is healthy
if: steps.cache.outputs.cache-hit == 'true'
run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv
- name: Install dependencies
run: poetry install --no-interaction
- name: Run linters
run: poetry run ./scripts/lint

70
backend/.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,70 @@
name: Tests
on:
push:
branches:
- "master"
pull_request:
branches:
- "*"
jobs:
lint:
name: Run tests
runs-on: ubuntu-18.04
strategy:
matrix:
python-version: [3.9]
services:
postgres:
image: postgres:11.5-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: "1.1.12"
virtualenvs-in-project: true
- name: Set up cache
uses: actions/cache@v3
id: cache
with:
path: .venv
key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }}
- name: Ensure cache is healthy
if: steps.cache.outputs.cache-hit == 'true'
run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv
- name: Install dependencies
run: poetry install --no-interaction
- name: Run tests
env:
SECRET_KEY: secret_key
DATABASE_URL: postgresql://postgres:postgres@localhost/postgres
run: |
poetry run alembic upgrade head
poetry run ./scripts/test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3.1.0

110
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,110 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.idea/
.vscode/
# Project
postgres-data

21
backend/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM python:3.9.10-slim
ENV PYTHONUNBUFFERED 1
EXPOSE 8000
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends netcat && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY poetry.lock pyproject.toml ./
RUN pip install poetry==1.1 && \
poetry config virtualenvs.in-project true && \
poetry install --no-dev
COPY . ./
CMD poetry run alembic upgrade head && \
poetry run uvicorn --host=0.0.0.0 app.main:app

21
backend/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Nik Sidnev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

157
backend/README.rst Normal file
View File

@ -0,0 +1,157 @@
.. image:: ./.github/assets/logo.png
|
.. image:: https://github.com/nsidnev/fastapi-realworld-example-app/workflows/API%20spec/badge.svg
:target: https://github.com/nsidnev/fastapi-realworld-example-app
.. image:: https://github.com/nsidnev/fastapi-realworld-example-app/workflows/Tests/badge.svg
:target: https://github.com/nsidnev/fastapi-realworld-example-app
.. image:: https://github.com/nsidnev/fastapi-realworld-example-app/workflows/Styles/badge.svg
:target: https://github.com/nsidnev/fastapi-realworld-example-app
.. image:: https://codecov.io/gh/nsidnev/fastapi-realworld-example-app/branch/master/graph/badge.svg
:target: https://codecov.io/gh/nsidnev/fastapi-realworld-example-app
.. image:: https://img.shields.io/github/license/Naereen/StrapDown.js.svg
:target: https://github.com/nsidnev/fastapi-realworld-example-app/blob/master/LICENSE
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/ambv/black
.. image:: https://img.shields.io/badge/style-wemake-000000.svg
:target: https://github.com/wemake-services/wemake-python-styleguide
----------
**NOTE**: This repository is not actively maintained because this example is quite complete and does its primary goal - passing Conduit testsuite.
More modern and relevant examples can be found in other repositories with ``fastapi`` tag on GitHub.
Quickstart
----------
First, run ``PostgreSQL``, set environment variables and create database. For example using ``docker``: ::
export POSTGRES_DB=rwdb POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres
docker run --name pgdb --rm -e POSTGRES_USER="$POSTGRES_USER" -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" -e POSTGRES_DB="$POSTGRES_DB" postgres
export POSTGRES_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' pgdb)
createdb --host=$POSTGRES_HOST --port=$POSTGRES_PORT --username=$POSTGRES_USER $POSTGRES_DB
Then run the following commands to bootstrap your environment with ``poetry``: ::
git clone https://github.com/nsidnev/fastapi-realworld-example-app
cd fastapi-realworld-example-app
poetry install
poetry shell
Then create ``.env`` file (or rename and modify ``.env.example``) in project root and set environment variables for application: ::
touch .env
echo APP_ENV=dev >> .env
echo DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB >> .env
echo SECRET_KEY=$(openssl rand -hex 32) >> .env
To run the web application in debug use::
alembic upgrade head
uvicorn app.main:app --reload
If you run into the following error in your docker container:
sqlalchemy.exc.OperationalError: (psycopg2.OperationalError) could not connect to server: No such file or directory
Is the server running locally and accepting
connections on Unix domain socket "/tmp/.s.PGSQL.5432"?
Ensure the DATABASE_URL variable is set correctly in the `.env` file.
It is most likely caused by POSTGRES_HOST not pointing to its localhost.
DATABASE_URL=postgresql://postgres:postgres@0.0.0.0:5432/rwdb
Run tests
---------
Tests for this project are defined in the ``tests/`` folder.
Set up environment variable ``DATABASE_URL`` or set up ``database_url`` in ``app/core/settings/test.py``
This project uses `pytest
<https://docs.pytest.org/>`_ to define tests because it allows you to use the ``assert`` keyword with good formatting for failed assertations.
To run all the tests of a project, simply run the ``pytest`` command: ::
$ pytest
================================================= test session starts ==================================================
platform linux -- Python 3.8.3, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
rootdir: /home/some-user/user-projects/fastapi-realworld-example-app, inifile: setup.cfg, testpaths: tests
plugins: env-0.6.2, cov-2.9.0, asyncio-0.12.0
collected 90 items
tests/test_api/test_errors/test_422_error.py . [ 1%]
tests/test_api/test_errors/test_error.py . [ 2%]
tests/test_api/test_routes/test_articles.py ................................. [ 38%]
tests/test_api/test_routes/test_authentication.py .. [ 41%]
tests/test_api/test_routes/test_comments.py .... [ 45%]
tests/test_api/test_routes/test_login.py ... [ 48%]
tests/test_api/test_routes/test_profiles.py ............ [ 62%]
tests/test_api/test_routes/test_registration.py ... [ 65%]
tests/test_api/test_routes/test_tags.py .. [ 67%]
tests/test_api/test_routes/test_users.py .................... [ 90%]
tests/test_db/test_queries/test_tables.py ... [ 93%]
tests/test_schemas/test_rw_model.py . [ 94%]
tests/test_services/test_jwt.py ..... [100%]
============================================ 90 passed in 70.50s (0:01:10) =============================================
$
If you want to run a specific test, you can do this with `this
<https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests>`_ pytest feature: ::
$ pytest tests/test_api/test_routes/test_users.py::test_user_can_not_take_already_used_credentials
Deployment with Docker
----------------------
You must have ``docker`` and ``docker-compose`` tools installed to work with material in this section.
First, create ``.env`` file like in `Quickstart` section or modify ``.env.example``.
``POSTGRES_HOST`` must be specified as `db` or modified in ``docker-compose.yml`` also.
Then just run::
docker-compose up -d db
docker-compose up -d app
Application will be available on ``localhost`` in your browser.
Web routes
----------
All routes are available on ``/docs`` or ``/redoc`` paths with Swagger or ReDoc.
Project structure
-----------------
Files related to application are in the ``app`` or ``tests`` directories.
Application parts are:
::
app
├── api - web related stuff.
│   ├── dependencies - dependencies for routes definition.
│   ├── errors - definition of error handlers.
│   └── routes - web routes.
├── core - application configuration, startup events, logging.
├── db - db related stuff.
│   ├── migrations - manually written alembic migrations.
│   └── repositories - all crud stuff.
├── models - pydantic models for this application.
│   ├── domain - main models that are used almost everywhere.
│   └── schemas - schemas for using in web routes.
├── resources - strings that are used in web responses.
├── services - logic that is not just crud related.
└── main.py - FastAPI application creation and configuration.

36
backend/alembic.ini Normal file
View File

@ -0,0 +1,36 @@
[alembic]
script_location = ./app/db/migrations
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
backend/app/__init__.py Normal file
View File

View File

View File

View File

@ -0,0 +1,70 @@
from fastapi import Depends, HTTPException, status
from loguru import logger
from app.api.dependencies.authentication import get_current_user_authorizer
from app.api.dependencies.database import get_repository
from app.db.repositories.roles import RolesRepository
from app.models.domain.users import User
from app.core.config import get_app_settings
ADMIN_ROLE_NAME = "admin"
async def get_admin_user(
current_user: User = Depends(get_current_user_authorizer()),
roles_repo: RolesRepository = Depends(get_repository(RolesRepository)),
) -> User:
user_id = getattr(current_user, "id", None) or getattr(current_user, "id_", None)
if not user_id:
logger.warning("[AdminAccess] missing user_id, deny")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
# 调试日志,观察鉴权上下文
logger.info(
"[AdminAccess] current_user id={id} email={email} roles={roles}",
id=user_id,
email=getattr(current_user, "email", None),
roles=getattr(current_user, "roles", None),
)
try:
logger.info(
"[AdminAccess] current_user dump={}",
current_user.model_dump() if hasattr(current_user, "model_dump") else vars(current_user),
)
except Exception as exc:
logger.warning("[AdminAccess] dump error: {}", exc)
# 先看用户自身的 roles 是否已包含 admin
roles_from_user = getattr(current_user, "roles", []) or []
if isinstance(roles_from_user, (list, tuple)) and "admin" in roles_from_user:
logger.info("[AdminAccess] allow via user.roles contains admin")
return current_user
# 再看是否在信任邮箱名单,避免“还未赋权”时卡死
settings = get_app_settings()
trusted_emails = {
email.strip().lower()
for email in getattr(settings, "admin_emails", []) or []
if email
}
email = getattr(current_user, "email", None)
if email and email.lower() in trusted_emails:
logger.info("[AdminAccess] allow via trusted email list")
return current_user
has_role = await roles_repo.user_has_role(
user_id=user_id,
role_name=ADMIN_ROLE_NAME,
)
if has_role:
logger.info("[AdminAccess] allow via DB role check")
return current_user
logger.warning("[AdminAccess] deny id={id} email={email}", id=user_id, email=email)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)

View File

@ -0,0 +1,67 @@
from typing import List, Optional
from fastapi import Depends, HTTPException, Path, Query
from starlette import status
from app.api.dependencies.authentication import get_current_user_authorizer
from app.api.dependencies.database import get_repository
from app.db.errors import EntityDoesNotExist
from app.db.repositories.articles import ArticlesRepository
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,
ArticlesFilters,
)
from app.resources import strings
from app.services.articles import check_user_can_modify_article
def get_articles_filters(
tag: Optional[str] = None,
tags: Optional[List[str]] = Query(default=None),
author: Optional[str] = None,
favorited: Optional[str] = None,
search: Optional[str] = Query(default=None, description="搜索标题/描述/slug"),
limit: int = Query(DEFAULT_ARTICLES_LIMIT, ge=1),
offset: int = Query(DEFAULT_ARTICLES_OFFSET, ge=0),
) -> ArticlesFilters:
final_tags: Optional[List[str]] = tags
# 兼容旧的 tag 单值参数:若提供则与 tags 合并并去重
if tag:
final_tags = list({*(final_tags or []), tag})
return ArticlesFilters(
tag=tag,
tags=final_tags,
author=author,
favorited=favorited,
search=search,
limit=limit,
offset=offset,
)
async def get_article_by_slug_from_path(
slug: str = Path(..., min_length=1),
user: Optional[User] = Depends(get_current_user_authorizer(required=False)),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> Article:
try:
return await articles_repo.get_article_by_slug(slug=slug, requested_user=user)
except EntityDoesNotExist:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=strings.ARTICLE_DOES_NOT_EXIST_ERROR,
)
def check_article_modification_permissions(
current_article: Article = Depends(get_article_by_slug_from_path),
user: User = Depends(get_current_user_authorizer()),
) -> None:
if not check_user_can_modify_article(current_article, user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=strings.USER_IS_NOT_AUTHOR_OF_ARTICLE,
)

View File

@ -0,0 +1,123 @@
# noqa:WPS201
from typing import Callable, Optional
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
from starlette import requests, status
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.dependencies.database import get_repository
from app.core.config import get_app_settings
from app.core.settings.app import AppSettings
from app.db.errors import EntityDoesNotExist
from app.db.repositories.users import UsersRepository
from app.models.domain.users import User
from app.resources import strings
from app.services import jwt
HEADER_KEY = "Authorization"
class RWAPIKeyHeader(APIKeyHeader):
async def __call__( # noqa: WPS610
self,
request: requests.Request,
) -> Optional[str]:
try:
return await super().__call__(request)
except StarletteHTTPException as original_auth_exc:
raise HTTPException(
status_code=original_auth_exc.status_code,
detail=strings.AUTHENTICATION_REQUIRED,
)
def get_current_user_authorizer(*, required: bool = True) -> Callable: # type: ignore
return _get_current_user if required else _get_current_user_optional
def _get_authorization_header_retriever(
*,
required: bool = True,
) -> Callable: # type: ignore
return _get_authorization_header if required else _get_authorization_header_optional
def _get_authorization_header(
api_key: str = Security(RWAPIKeyHeader(name=HEADER_KEY)),
settings: AppSettings = Depends(get_app_settings),
) -> str:
try:
token_prefix, token = api_key.split(" ")
except ValueError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=strings.WRONG_TOKEN_PREFIX,
)
if token_prefix != settings.jwt_token_prefix:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=strings.WRONG_TOKEN_PREFIX,
)
return token
def _get_authorization_header_optional(
authorization: Optional[str] = Security(
RWAPIKeyHeader(name=HEADER_KEY, auto_error=False),
),
settings: AppSettings = Depends(get_app_settings),
) -> str:
if authorization:
return _get_authorization_header(authorization, settings)
return ""
async def _get_current_user(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
token: str = Depends(_get_authorization_header_retriever()),
settings: AppSettings = Depends(get_app_settings),
) -> User:
try:
username = jwt.get_username_from_token(
token,
str(settings.secret_key.get_secret_value()),
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=strings.MALFORMED_PAYLOAD,
)
try:
user = await users_repo.get_user_by_username(username=username)
try:
from loguru import logger # local import to avoid global dependency if not installed
logger.info(
"[Auth] fetched user username={} id/id_={}/{} roles={}",
getattr(user, "username", None),
getattr(user, "id", None),
getattr(user, "id_", None),
getattr(user, "roles", None),
)
except Exception:
pass
return user
except EntityDoesNotExist:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=strings.MALFORMED_PAYLOAD,
)
async def _get_current_user_optional(
repo: UsersRepository = Depends(get_repository(UsersRepository)),
token: str = Depends(_get_authorization_header_retriever(required=False)),
settings: AppSettings = Depends(get_app_settings),
) -> Optional[User]:
if token:
return await _get_current_user(repo, token, settings)
return None

View File

@ -0,0 +1,47 @@
from typing import Optional
from fastapi import Depends, HTTPException, Path
from starlette import status
from app.api.dependencies import articles, authentication, database
from app.db.errors import EntityDoesNotExist
from app.db.repositories.comments import CommentsRepository
from app.models.domain.articles import Article
from app.models.domain.comments import Comment
from app.models.domain.users import User
from app.resources import strings
from app.services.comments import check_user_can_modify_comment
async def get_comment_by_id_from_path(
comment_id: int = Path(..., ge=1),
article: Article = Depends(articles.get_article_by_slug_from_path),
user: Optional[User] = Depends(
authentication.get_current_user_authorizer(required=False),
),
comments_repo: CommentsRepository = Depends(
database.get_repository(CommentsRepository),
),
) -> Comment:
try:
return await comments_repo.get_comment_by_id(
comment_id=comment_id,
article=article,
user=user,
)
except EntityDoesNotExist:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=strings.COMMENT_DOES_NOT_EXIST,
)
def check_comment_modification_permissions(
comment: Comment = Depends(get_comment_by_id_from_path),
user: User = Depends(authentication.get_current_user_authorizer()),
) -> None:
if not check_user_can_modify_comment(comment, user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=strings.USER_IS_NOT_AUTHOR_OF_ARTICLE,
)

View File

@ -0,0 +1,45 @@
# app/api/dependencies/database.py
from typing import AsyncIterator, Callable, Type
from asyncpg import Connection, Pool
from fastapi import Depends
from starlette.requests import Request
from app.db.repositories.base import BaseRepository
def _get_db_pool(request: Request) -> Pool:
"""
app.state.pool 取得连接池若未初始化给出清晰报错
"""
pool = getattr(request.app.state, "pool", None)
if pool is None:
raise RuntimeError("Database pool not initialized on app.state.pool")
return pool
async def _get_connection_from_pool(
pool: Pool = Depends(_get_db_pool),
) -> AsyncIterator[Connection]:
"""
私有实现从连接池借出一个连接使用后自动归还
"""
async with pool.acquire() as conn:
yield conn
# ✅ 公共别名:供路由里直接使用 Depends(get_connection)
get_connection = _get_connection_from_pool
def get_repository(
repo_type: Type[BaseRepository],
) -> Callable[[Connection], BaseRepository]:
"""
兼容旧用法Depends(get_repository(UserRepo))
内部依赖 get_connection因此两种写法都能共存
"""
def _get_repo(conn: Connection = Depends(get_connection)) -> BaseRepository:
return repo_type(conn)
return _get_repo

View File

@ -0,0 +1,29 @@
from typing import Optional
from fastapi import Depends, HTTPException, Path
from starlette.status import HTTP_404_NOT_FOUND
from app.api.dependencies.authentication import get_current_user_authorizer
from app.api.dependencies.database import get_repository
from app.db.errors import EntityDoesNotExist
from app.db.repositories.profiles import ProfilesRepository
from app.models.domain.profiles import Profile
from app.models.domain.users import User
from app.resources import strings
async def get_profile_by_username_from_path(
username: str = Path(..., min_length=1),
user: Optional[User] = Depends(get_current_user_authorizer(required=False)),
profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)),
) -> Profile:
try:
return await profiles_repo.get_profile_by_username(
username=username,
requested_user=user,
)
except EntityDoesNotExist:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=strings.USER_DOES_NOT_EXIST_ERROR,
)

View File

View File

@ -0,0 +1,7 @@
from fastapi import HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
async def http_error_handler(_: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse({"errors": [exc.detail]}, status_code=exc.status_code)

View File

@ -0,0 +1,28 @@
from typing import Union
from fastapi.exceptions import RequestValidationError
from fastapi.openapi.constants import REF_PREFIX
from fastapi.openapi.utils import validation_error_response_definition
from pydantic import ValidationError
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
async def http422_error_handler(
_: Request,
exc: Union[RequestValidationError, ValidationError],
) -> JSONResponse:
return JSONResponse(
{"errors": exc.errors()},
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
)
validation_error_response_definition["properties"] = {
"errors": {
"title": "Errors",
"type": "array",
"items": {"$ref": "{0}ValidationError".format(REF_PREFIX)},
},
}

View File

View File

@ -0,0 +1,418 @@
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from app.api.dependencies.admin import get_admin_user
from app.api.dependencies.database import get_repository
from app.db.repositories.admin import AdminRepository
from app.db.repositories.articles import ArticlesRepository
from app.db.repositories.home_featured import HomeFeaturedRepository
from app.db.repositories.menu_slots import DEFAULT_MENU_SLOTS, MenuSlotsRepository
from app.db.repositories.roles import RolesRepository
from app.db.repositories.users import UsersRepository
from app.models.domain.users import User
from app.models.schemas.admin import (
AdminHomeFeaturedUpdate,
AdminMenuSlot,
AdminMenuSlotListResponse,
AdminMenuSlotResponse,
AdminMenuSlotUpdate,
AdminDashboardStats,
AdminUserCreate,
AdminUserListResponse,
AdminUserResponse,
AdminUserUpdate,
)
from app.models.schemas.articles import (
ArticleForResponse,
ArticleInResponse,
ArticleInUpdate,
ListOfArticlesInResponse,
)
from app.models.schemas.roles import (
ListOfRolesInResponse,
RoleInCreate,
RoleInResponse,
RoleInUpdate,
)
from app.services.articles import get_slug_for_article
router = APIRouter(prefix="/admin", tags=["admin"])
DEFAULT_MENU_SLOT_KEYS = {slot["slot_key"] for slot in DEFAULT_MENU_SLOTS}
@router.get("/dashboard", response_model=AdminDashboardStats, name="admin:dashboard")
async def get_dashboard_stats(
_: User = Depends(get_admin_user),
admin_repo: AdminRepository = Depends(get_repository(AdminRepository)),
) -> AdminDashboardStats:
return await admin_repo.get_dashboard_stats()
@router.get("/users", response_model=AdminUserListResponse, name="admin:list-users")
async def list_admin_users(
_: User = Depends(get_admin_user),
search: Optional[str] = Query(default=None, description="Search by username/email"),
role_id: Optional[int] = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
admin_repo: AdminRepository = Depends(get_repository(AdminRepository)),
) -> AdminUserListResponse:
users, total = await admin_repo.list_users(
search=search,
role_id=role_id,
limit=limit,
offset=offset,
)
return AdminUserListResponse(users=users, total=total)
@router.post(
"/users",
response_model=AdminUserResponse,
status_code=status.HTTP_201_CREATED,
name="admin:create-user",
)
async def create_admin_user(
_: User = Depends(get_admin_user),
payload: AdminUserCreate = Body(..., embed=True, alias="user"),
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
roles_repo: RolesRepository = Depends(get_repository(RolesRepository)),
admin_repo: AdminRepository = Depends(get_repository(AdminRepository)),
) -> AdminUserResponse:
user = await users_repo.create_user(
username=payload.username,
email=payload.email,
password=payload.password,
)
# optional profile info
if payload.bio or payload.image:
user = await users_repo.update_user_by_id(
user_id=user.id,
bio=payload.bio,
image=payload.image,
)
if payload.role_ids:
await roles_repo.set_roles_for_user(
user_id=user.id,
role_ids=payload.role_ids,
)
summary = await admin_repo.get_user_summary(user.id)
if not summary:
raise HTTPException(status_code=500, detail="Failed to load created user")
return AdminUserResponse(user=summary)
@router.put(
"/users/{user_id}",
response_model=AdminUserResponse,
name="admin:update-user",
)
async def update_admin_user(
_: User = Depends(get_admin_user),
user_id: int = 0,
payload: AdminUserUpdate = Body(..., embed=True, alias="user"),
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
roles_repo: RolesRepository = Depends(get_repository(RolesRepository)),
admin_repo: AdminRepository = Depends(get_repository(AdminRepository)),
) -> AdminUserResponse:
await users_repo.update_user_by_id(
user_id=user_id,
username=payload.username,
email=payload.email,
password=payload.password,
bio=payload.bio,
image=payload.image,
)
if payload.role_ids is not None:
await roles_repo.set_roles_for_user(
user_id=user_id,
role_ids=payload.role_ids,
)
summary = await admin_repo.get_user_summary(user_id)
if not summary:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return AdminUserResponse(user=summary)
@router.delete(
"/users/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
name="admin:delete-user",
)
async def delete_admin_user(
_: User = Depends(get_admin_user),
user_id: int = 0,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> None:
await users_repo.delete_user_by_id(user_id=user_id)
@router.get("/roles", response_model=ListOfRolesInResponse, name="admin:list-roles")
async def list_roles(
_: User = Depends(get_admin_user),
roles_repo: RolesRepository = Depends(get_repository(RolesRepository)),
) -> ListOfRolesInResponse:
roles = await roles_repo.list_roles()
return ListOfRolesInResponse(roles=roles)
@router.post(
"/roles",
response_model=RoleInResponse,
status_code=status.HTTP_201_CREATED,
name="admin:create-role",
)
async def create_role(
_: User = Depends(get_admin_user),
payload: RoleInCreate = Body(..., embed=True, alias="role"),
roles_repo: RolesRepository = Depends(get_repository(RolesRepository)),
) -> RoleInResponse:
role = await roles_repo.create_role(
name=payload.name,
description=payload.description or "",
permissions=payload.permissions,
)
return RoleInResponse(role=role)
@router.put(
"/roles/{role_id}",
response_model=RoleInResponse,
name="admin:update-role",
)
async def update_role(
_: User = Depends(get_admin_user),
role_id: int = 0,
payload: RoleInUpdate = Body(..., embed=True, alias="role"),
roles_repo: RolesRepository = Depends(get_repository(RolesRepository)),
) -> RoleInResponse:
role = await roles_repo.update_role(
role_id=role_id,
name=payload.name,
description=payload.description,
permissions=payload.permissions,
)
return RoleInResponse(role=role)
@router.delete(
"/roles/{role_id}",
status_code=status.HTTP_204_NO_CONTENT,
name="admin:delete-role",
)
async def delete_role(
_: User = Depends(get_admin_user),
role_id: int = 0,
roles_repo: RolesRepository = Depends(get_repository(RolesRepository)),
) -> None:
await roles_repo.delete_role(role_id=role_id)
@router.get(
"/menu-slots",
response_model=AdminMenuSlotListResponse,
name="admin:list-menu-slots",
)
async def list_menu_slots(
_: User = Depends(get_admin_user),
menu_slots_repo: MenuSlotsRepository = Depends(get_repository(MenuSlotsRepository)),
) -> AdminMenuSlotListResponse:
rows = await menu_slots_repo.list_slots()
slots: List[AdminMenuSlot] = []
for row in rows:
default_label = next(
(slot["label"] for slot in DEFAULT_MENU_SLOTS if slot["slot_key"] == row["slot_key"]),
row["slot_key"],
)
slots.append(
AdminMenuSlot(
slot_key=row["slot_key"],
label=row["label"] or default_label,
tags=row["tags"] or [],
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
),
)
return AdminMenuSlotListResponse(slots=slots)
@router.put(
"/menu-slots/{slot_key}",
response_model=AdminMenuSlotResponse,
name="admin:update-menu-slot",
)
async def update_menu_slot(
_: User = Depends(get_admin_user),
slot_key: str = "",
payload: AdminMenuSlotUpdate = Body(..., embed=True, alias="slot"),
menu_slots_repo: MenuSlotsRepository = Depends(get_repository(MenuSlotsRepository)),
) -> AdminMenuSlotResponse:
if slot_key not in DEFAULT_MENU_SLOT_KEYS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid menu slot",
)
row = await menu_slots_repo.upsert_slot_tags(
slot_key=slot_key,
tags=payload.tags,
label=payload.label,
)
default_label = next(
(slot["label"] for slot in DEFAULT_MENU_SLOTS if slot["slot_key"] == slot_key),
slot_key,
)
slot = AdminMenuSlot(
slot_key=row["slot_key"],
label=row["label"] or default_label,
tags=row["tags"] or [],
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
)
return AdminMenuSlotResponse(slot=slot)
@router.get(
"/home-featured-articles",
response_model=ListOfArticlesInResponse,
name="admin:list-home-featured-articles",
)
async def list_home_featured_articles_admin(
_: User = Depends(get_admin_user),
home_repo: HomeFeaturedRepository = Depends(get_repository(HomeFeaturedRepository)),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ListOfArticlesInResponse:
slugs = await home_repo.list_slugs()
articles = await articles_repo.list_articles_by_slugs(
slugs=slugs,
requested_user=None,
)
articles_for_response = [
ArticleForResponse.from_orm(article)
for article in articles
]
return ListOfArticlesInResponse(
articles=articles_for_response,
articles_count=len(articles_for_response),
)
@router.put(
"/home-featured-articles",
response_model=ListOfArticlesInResponse,
name="admin:save-home-featured-articles",
)
async def save_home_featured_articles_admin(
_: User = Depends(get_admin_user),
payload: AdminHomeFeaturedUpdate = Body(...),
home_repo: HomeFeaturedRepository = Depends(get_repository(HomeFeaturedRepository)),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ListOfArticlesInResponse:
input_slugs = [item.slug.strip() for item in payload.articles if item.slug]
if not input_slugs:
await home_repo.save_slugs(slugs=[])
return ListOfArticlesInResponse(articles=[], articles_count=0)
slugs: List[str] = []
for slug in input_slugs:
if slug and slug not in slugs:
slugs.append(slug)
slugs = slugs[:10]
articles = await articles_repo.list_articles_by_slugs(
slugs=slugs,
requested_user=None,
)
found_slugs = {article.slug for article in articles}
missing = [slug for slug in slugs if slug not in found_slugs]
if missing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Articles not found: {', '.join(missing)}",
)
await home_repo.save_slugs(slugs=slugs)
articles_for_response = [
ArticleForResponse.from_orm(article)
for article in articles
]
return ListOfArticlesInResponse(
articles=articles_for_response,
articles_count=len(articles_for_response),
)
@router.get(
"/articles",
response_model=ListOfArticlesInResponse,
name="admin:list-articles",
)
async def list_articles_admin(
_: User = Depends(get_admin_user),
search: Optional[str] = Query(default=None),
author: Optional[str] = Query(default=None),
limit: int = Query(default=20, ge=1, le=200),
offset: int = Query(default=0, ge=0),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ListOfArticlesInResponse:
articles, total = await articles_repo.list_articles_for_admin(
search=search,
author=author,
limit=limit,
offset=offset,
)
articles_for_response = [
ArticleForResponse.from_orm(article)
for article in articles
]
return ListOfArticlesInResponse(articles=articles_for_response, articles_count=total)
@router.put(
"/articles/{slug}",
response_model=ArticleInResponse,
name="admin:update-article",
)
async def update_article_admin(
_: User = Depends(get_admin_user),
slug: str = "",
article_update: ArticleInUpdate = Body(..., embed=True, alias="article"),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ArticleInResponse:
current_article = await articles_repo.get_article_by_slug(
slug=slug,
requested_user=None,
)
new_slug = get_slug_for_article(article_update.title) if article_update.title else None
cover_provided = "cover" in article_update.__fields_set__
article = await articles_repo.admin_update_article(
article=current_article,
slug=new_slug,
title=article_update.title,
body=article_update.body,
description=article_update.description,
cover=article_update.cover,
is_top=article_update.is_top,
is_featured=article_update.is_featured,
sort_weight=article_update.sort_weight,
cover_provided=cover_provided,
)
return ArticleInResponse(article=ArticleForResponse.from_orm(article))
@router.delete(
"/articles/{slug}",
status_code=status.HTTP_204_NO_CONTENT,
name="admin:delete-article",
)
async def delete_article_admin(
_: User = Depends(get_admin_user),
slug: str = "",
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> None:
article = await articles_repo.get_article_by_slug(slug=slug, requested_user=None)
await articles_repo.admin_delete_article(article=article)

View File

@ -0,0 +1,50 @@
from fastapi import APIRouter
from app.api.routes import (
admin,
authentication,
comments,
home_featured,
profiles,
tags,
users,
password_reset,
uploads,
)
from app.api.routes.articles import api as articles
router = APIRouter()
# authentication /auth
router.include_router(authentication.router, tags=["authentication"], prefix="/auth")
# password reset /auth/password
router.include_router(password_reset.router, prefix="/auth/password")
# current user /user
router.include_router(users.router, tags=["users"], prefix="/user")
# profiles /profiles/{username}
router.include_router(profiles.router, tags=["profiles"], prefix="/profiles")
# articles /articles/**
router.include_router(articles.router, tags=["articles"])
# comments /articles/{slug}/comments/**
router.include_router(
comments.router,
tags=["comments"],
prefix="/articles/{slug}/comments",
)
# tags /tags
router.include_router(tags.router, tags=["tags"], prefix="/tags")
# upload image POST /upload-image
router.include_router(uploads.router)
# home featured /home-featured-articles
router.include_router(home_featured.router)
# admin backend /admin/**
router.include_router(admin.router)

View File

@ -0,0 +1,9 @@
# app\api\routes\articles\api.py
from fastapi import APIRouter
from app.api.routes.articles import articles_common, articles_resource
router = APIRouter()
router.include_router(articles_common.router, prefix="/articles")
router.include_router(articles_resource.router, prefix="/articles")

View File

@ -0,0 +1,105 @@
# app\api\routes\articles\articles_common.py
from fastapi import APIRouter, Depends, HTTPException, Query
from starlette import status
from app.api.dependencies.articles import get_article_by_slug_from_path
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.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,
ArticleInResponse,
ListOfArticlesInResponse,
)
from app.resources import strings
router = APIRouter()
@router.get(
"/feed",
response_model=ListOfArticlesInResponse,
name="articles:get-user-feed-articles",
)
async def get_articles_for_user_feed(
limit: int = Query(DEFAULT_ARTICLES_LIMIT, ge=1),
offset: int = Query(DEFAULT_ARTICLES_OFFSET, ge=0),
user: User = Depends(get_current_user_authorizer()),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ListOfArticlesInResponse:
articles = await articles_repo.get_articles_for_user_feed(
user=user,
limit=limit,
offset=offset,
)
articles_for_response = [
ArticleForResponse(**article.dict()) for article in articles
]
return ListOfArticlesInResponse(
articles=articles_for_response,
articles_count=len(articles),
)
@router.post(
"/{slug}/favorite",
response_model=ArticleInResponse,
name="articles:mark-article-favorite",
)
async def mark_article_as_favorite(
article: Article = Depends(get_article_by_slug_from_path),
user: User = Depends(get_current_user_authorizer()),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ArticleInResponse:
if not article.favorited:
await articles_repo.add_article_into_favorites(article=article, user=user)
return ArticleInResponse(
article=ArticleForResponse.from_orm(
article.copy(
update={
"favorited": True,
"favorites_count": article.favorites_count + 1,
},
),
),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=strings.ARTICLE_IS_ALREADY_FAVORITED,
)
@router.delete(
"/{slug}/favorite",
response_model=ArticleInResponse,
name="articles:unmark-article-favorite",
)
async def remove_article_from_favorites(
article: Article = Depends(get_article_by_slug_from_path),
user: User = Depends(get_current_user_authorizer()),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ArticleInResponse:
if article.favorited:
await articles_repo.remove_article_from_favorites(article=article, user=user)
return ArticleInResponse(
article=ArticleForResponse.from_orm(
article.copy(
update={
"favorited": False,
"favorites_count": article.favorites_count - 1,
},
),
),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=strings.ARTICLE_IS_NOT_FAVORITED,
)

View File

@ -0,0 +1,220 @@
# 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)

View File

@ -0,0 +1,321 @@
# app/api/routes/authentication.py
from __future__ import annotations
from typing import Optional, Any, TYPE_CHECKING
from datetime import datetime, timedelta
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from starlette.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED
from app.api.dependencies.database import get_repository
from app.core.config import get_app_settings
from app.core.settings.app import AppSettings
from app.db.errors import EntityDoesNotExist
from app.db.repositories.users import UsersRepository
# 条件导入:运行期可能没有 email_codes 仓库
try:
from app.db.repositories.email_codes import EmailCodesRepository # type: ignore
HAS_EMAIL_CODES_REPO = True
except Exception: # pragma: no cover
EmailCodesRepository = None # type: ignore
HAS_EMAIL_CODES_REPO = False
# 仅用于类型检查(让 Pylance/pyright 认识名字,但运行期不导入)
if TYPE_CHECKING: # pragma: no cover
from app.db.repositories.email_codes import EmailCodesRepository as _EmailCodesRepositoryT # noqa: F401
from app.models.schemas.users import (
UserInLogin,
UserInResponse,
UserWithToken,
RegisterWithEmailIn,
)
from app.models.schemas.email_code import EmailCodeSendIn, EmailCodeSendOut
from app.resources import strings
from app.services import jwt
from app.services.mailer import send_email
from app.services.authentication import (
check_email_is_taken,
assert_passwords_match,
make_unique_username,
)
router = APIRouter()
# ================= Cookie 工具(最小改造,无需新增文件) =================
REFRESH_COOKIE_NAME = "refresh_token"
def set_refresh_cookie(resp: Response, token: str, *, max_age_days: int = 30) -> None:
"""
仅通过 HttpOnly Cookie 下发 refresh
- SameSite=Lax避免跨站表单滥用
- Secure=True生产环境建议始终为 True如本地纯 HTTP 开发可按需改为 False
- Path 设为 /api/auth缩小作用域
"""
resp.set_cookie(
key=REFRESH_COOKIE_NAME,
value=token,
max_age=max_age_days * 24 * 3600,
httponly=True,
secure=True, # 如需在本地 http 调试,可改为 False
samesite="lax",
path="/api/auth",
)
def clear_refresh_cookie(resp: Response) -> None:
resp.delete_cookie(
key=REFRESH_COOKIE_NAME,
path="/api/auth",
httponly=True,
secure=True,
samesite="lax",
)
# 为了兼容“可选的验证码仓库”,构造一个可交给 Depends 的工厂
def _provide_optional_email_codes_repo():
if HAS_EMAIL_CODES_REPO:
return get_repository(EmailCodesRepository) # type: ignore[name-defined]
async def _none():
return None
return _none
# ========= 发送邮箱验证码 =========
@router.post(
"/email-code",
response_model=EmailCodeSendOut,
name="auth:email-code",
)
async def send_email_code(
payload: EmailCodeSendIn = Body(...),
settings: AppSettings = Depends(get_app_settings),
email_codes_repo: Optional[Any] = Depends(_provide_optional_email_codes_repo()),
) -> EmailCodeSendOut:
"""
发送邮箱验证码并写入数据库若仓库存在
"""
# 1) 生成验证码6 位数字)
rnd = __import__("random").randint(0, 999999)
code = f"{rnd:06d}"
# 2) 过期时间
expires_at = datetime.utcnow() + timedelta(minutes=settings.email_code_expires_minutes)
# 3) 记录到数据库(可选)
if email_codes_repo is not None:
await email_codes_repo.create_code( # type: ignore[attr-defined]
email=payload.email,
code=code,
scene=payload.scene,
expires_at=expires_at,
)
# 4) 发邮件
subject = f"【AI平台】{payload.scene} 验证码:{code}"
html = f"""
<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.6">
<p>您好</p>
<p>您正在进行 <b>{payload.scene}</b> 操作本次验证码为</p>
<p style="font-size:22px;font-weight:700;letter-spacing:2px">{code}</p>
<p>有效期{settings.email_code_expires_minutes} 分钟请勿泄露给他人</p>
</div>
"""
send_email(payload.email, subject, html)
return EmailCodeSendOut(ok=True)
# ========= 登录 =========
@router.post(
"/login",
response_model=UserInResponse,
response_model_exclude_none=True,
name="auth:login",
)
async def login(
response: Response,
user_login: UserInLogin = Body(..., embed=True, alias="user"),
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
settings: AppSettings = Depends(get_app_settings),
) -> UserInResponse:
"""邮箱 + 密码登录(签发 Access & Set-Cookie Refresh"""
wrong_login_error = HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.INCORRECT_LOGIN_INPUT,
)
try:
user = await users_repo.get_user_by_email(email=user_login.email)
except EntityDoesNotExist as existence_error:
raise wrong_login_error from existence_error
if not user.check_password(user_login.password):
raise wrong_login_error
secret = str(settings.secret_key.get_secret_value())
# Access(15m) + Refresh(30d)
access = jwt.create_access_token_for_user(user, secret)
refresh = jwt.create_refresh_token_for_user(user, secret)
# 仅通过 HttpOnly Cookie 下发 refresh
set_refresh_cookie(response, refresh, max_age_days=jwt.REFRESH_TOKEN_EXPIRE_DAYS)
return UserInResponse(
user=UserWithToken(
username=user.username,
email=user.email,
bio=user.bio,
image=user.image,
token=access, # 仍然在 body 返回 access保持前端兼容
email_verified=getattr(user, "email_verified", False),
roles=getattr(user, "roles", []),
),
)
# ========= 注册 =========
@router.post(
"",
status_code=HTTP_201_CREATED,
response_model=UserInResponse,
response_model_exclude_none=True,
name="auth:register",
)
async def register(
response: Response,
payload: RegisterWithEmailIn = Body(..., embed=True, alias="user"),
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
settings: AppSettings = Depends(get_app_settings),
email_codes_repo: Optional[Any] = Depends(_provide_optional_email_codes_repo()),
) -> UserInResponse:
"""
注册流程
1) 校验两次密码一致
2) 校验邮箱未被占用
3) 校验验证码若存在验证码仓库
4) 生成唯一用户名
5) 创建用户
6) 如仓库提供 set_email_verified则置为 True
7) 签发 Access & Set-Cookie Refresh
"""
# 1) 两次密码一致
try:
assert_passwords_match(payload.password, payload.confirm_password)
except ValueError:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail="Passwords do not match",
)
# 2) 邮箱是否占用
if await check_email_is_taken(users_repo, payload.email):
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.EMAIL_TAKEN,
)
# 3) 校验验证码
if email_codes_repo is not None:
ok = await email_codes_repo.verify_and_consume( # type: ignore[attr-defined]
email=payload.email,
code=payload.code,
scene="register",
)
if not ok:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification code",
)
# 4) 生成唯一用户名
username = await make_unique_username(users_repo, payload.email)
# 5) 创建用户
user = await users_repo.create_user(
username=username,
email=payload.email,
password=payload.password,
)
# 6) 若仓库支持置已验证,则更新并回读
if hasattr(users_repo, "set_email_verified"):
try:
await users_repo.set_email_verified(email=payload.email, verified=True) # type: ignore[attr-defined]
user = await users_repo.get_user_by_email(email=payload.email)
except Exception:
pass # 不阻塞主流程
# 7) 签发 Access & Refresh并下发 Cookie
secret = str(settings.secret_key.get_secret_value())
access = jwt.create_access_token_for_user(user, secret)
refresh = jwt.create_refresh_token_for_user(user, secret)
set_refresh_cookie(response, refresh, max_age_days=jwt.REFRESH_TOKEN_EXPIRE_DAYS)
return UserInResponse(
user=UserWithToken(
username=user.username,
email=user.email,
bio=user.bio,
image=user.image,
token=access,
email_verified=getattr(user, "email_verified", True),
roles=getattr(user, "roles", []),
),
)
# ========= 刷新 Access仅 Cookie 取 refresh=========
@router.post(
"/refresh",
name="auth:refresh",
)
async def refresh_access_token(
request: Request,
response: Response,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
settings: AppSettings = Depends(get_app_settings),
) -> dict:
"""
HttpOnly Cookie 读取 refresh校验后签发新的 access并重置 refresh Cookie
最小改造版本refresh 不轮换如需轮换/重放检测请走增表方案
"""
refresh = request.cookies.get(REFRESH_COOKIE_NAME)
if not refresh:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
secret = str(settings.secret_key.get_secret_value())
try:
username = jwt.get_username_from_token(refresh, secret, expected_subject=jwt.JWT_SUBJECT_REFRESH)
except ValueError:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
# 取用户(优先按 username
try:
# 大多数 RealWorld 模板都有该方法
user = await users_repo.get_user_by_username(username=username) # type: ignore[attr-defined]
except Exception:
# 若没有 get_user_by_username则退回按 email 查
try:
user = await users_repo.get_user_by_email(email=username)
except Exception as e:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="User not found") from e
# 签发新 access最小改造——同一个 refresh 继续使用(不轮换)
access = jwt.create_access_token_for_user(user, secret)
# 也可选择重置 refresh 的过期时间(同值覆盖),这里直接重设 Cookie
set_refresh_cookie(response, refresh, max_age_days=jwt.REFRESH_TOKEN_EXPIRE_DAYS)
return {"token": access, "expires_in": jwt.ACCESS_TOKEN_EXPIRE_MINUTES * 60}
# ========= 登出(清 Cookie前端清本地 access=========
@router.post(
"/logout",
name="auth:logout",
)
async def logout(response: Response) -> dict:
clear_refresh_cookie(response)
return {"ok": True}

View File

@ -0,0 +1,71 @@
from typing import Optional
from fastapi import APIRouter, Body, Depends, Response
from starlette import status
from app.api.dependencies.articles import get_article_by_slug_from_path
from app.api.dependencies.authentication import get_current_user_authorizer
from app.api.dependencies.comments import (
check_comment_modification_permissions,
get_comment_by_id_from_path,
)
from app.api.dependencies.database import get_repository
from app.db.repositories.comments import CommentsRepository
from app.models.domain.articles import Article
from app.models.domain.comments import Comment
from app.models.domain.users import User
from app.models.schemas.comments import (
CommentInCreate,
CommentInResponse,
ListOfCommentsInResponse,
)
router = APIRouter()
@router.get(
"",
response_model=ListOfCommentsInResponse,
name="comments:get-comments-for-article",
)
async def list_comments_for_article(
article: Article = Depends(get_article_by_slug_from_path),
user: Optional[User] = Depends(get_current_user_authorizer(required=False)),
comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)),
) -> ListOfCommentsInResponse:
comments = await comments_repo.get_comments_for_article(article=article, user=user)
return ListOfCommentsInResponse(comments=comments)
@router.post(
"",
status_code=status.HTTP_201_CREATED,
response_model=CommentInResponse,
name="comments:create-comment-for-article",
)
async def create_comment_for_article(
comment_create: CommentInCreate = Body(..., embed=True, alias="comment"),
article: Article = Depends(get_article_by_slug_from_path),
user: User = Depends(get_current_user_authorizer()),
comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)),
) -> CommentInResponse:
comment = await comments_repo.create_comment_for_article(
body=comment_create.body,
article=article,
user=user,
)
return CommentInResponse(comment=comment)
@router.delete(
"/{comment_id}",
status_code=status.HTTP_204_NO_CONTENT,
name="comments:delete-comment-from-article",
dependencies=[Depends(check_comment_modification_permissions)],
response_class=Response,
)
async def delete_comment_from_article(
comment: Comment = Depends(get_comment_by_id_from_path),
comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)),
) -> None:
await comments_repo.delete_comment(comment=comment)

View File

@ -0,0 +1,37 @@
from typing import Optional
from fastapi import APIRouter, Depends
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.home_featured import HomeFeaturedRepository
from app.models.domain.users import User
from app.models.schemas.articles import ArticleForResponse, ListOfArticlesInResponse
router = APIRouter()
@router.get(
"/home-featured-articles",
response_model=ListOfArticlesInResponse,
name="home-featured:list",
)
async def list_home_featured_articles(
user: Optional[User] = Depends(get_current_user_authorizer(required=False)),
home_repo: HomeFeaturedRepository = Depends(get_repository(HomeFeaturedRepository)),
articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)),
) -> ListOfArticlesInResponse:
slugs = await home_repo.list_slugs()
articles = await articles_repo.list_articles_by_slugs(
slugs=slugs,
requested_user=user,
)
articles_for_response = [
ArticleForResponse.from_orm(article)
for article in articles
]
return ListOfArticlesInResponse(
articles=articles_for_response,
articles_count=len(articles_for_response),
)

View File

@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, EmailStr, Field
from asyncpg import Connection
from app.api.dependencies.database import get_connection
from app.db.repositories.users import UsersRepository
from app.services.password_reset import send_reset_code_by_email, reset_password_with_code
# ❌ 不要再写 prefix这里只负责声明相对路径
router = APIRouter(tags=["auth-password"])
class PasswordForgotIn(BaseModel):
email: EmailStr
@router.post("/forgot")
async def forgot_password(
payload: PasswordForgotIn,
request: Request,
conn: Connection = Depends(get_connection),
):
users_repo = UsersRepository(conn)
await send_reset_code_by_email(request, conn, users_repo, payload.email)
return {"ok": True}
class PasswordResetIn(BaseModel):
email: EmailStr
code: str = Field(min_length=4, max_length=12)
password: str = Field(min_length=6)
confirm_password: str = Field(min_length=6)
@router.post("/reset")
async def reset_password(
payload: PasswordResetIn,
conn: Connection = Depends(get_connection),
):
if payload.password != payload.confirm_password:
return {"ok": False, "detail": "两次输入的密码不一致"}
users_repo = UsersRepository(conn)
await reset_password_with_code(
conn,
users_repo,
email=payload.email,
code=payload.code,
new_password=payload.password,
)
return {"ok": True}

View File

@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException
from starlette.status import HTTP_400_BAD_REQUEST
from app.api.dependencies.authentication import get_current_user_authorizer
from app.api.dependencies.database import get_repository
from app.api.dependencies.profiles import get_profile_by_username_from_path
from app.db.repositories.profiles import ProfilesRepository
from app.models.domain.profiles import Profile
from app.models.domain.users import User
from app.models.schemas.profiles import ProfileInResponse
from app.resources import strings
router = APIRouter()
@router.get(
"/{username}",
response_model=ProfileInResponse,
name="profiles:get-profile",
)
async def retrieve_profile_by_username(
profile: Profile = Depends(get_profile_by_username_from_path),
) -> ProfileInResponse:
return ProfileInResponse(profile=profile)
@router.post(
"/{username}/follow",
response_model=ProfileInResponse,
name="profiles:follow-user",
)
async def follow_for_user(
profile: Profile = Depends(get_profile_by_username_from_path),
user: User = Depends(get_current_user_authorizer()),
profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)),
) -> ProfileInResponse:
if user.username == profile.username:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.UNABLE_TO_FOLLOW_YOURSELF,
)
if profile.following:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.USER_IS_ALREADY_FOLLOWED,
)
await profiles_repo.add_user_into_followers(
target_user=profile,
requested_user=user,
)
return ProfileInResponse(profile=profile.copy(update={"following": True}))
@router.delete(
"/{username}/follow",
response_model=ProfileInResponse,
name="profiles:unsubscribe-from-user",
)
async def unsubscribe_from_user(
profile: Profile = Depends(get_profile_by_username_from_path),
user: User = Depends(get_current_user_authorizer()),
profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)),
) -> ProfileInResponse:
if user.username == profile.username:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.UNABLE_TO_UNSUBSCRIBE_FROM_YOURSELF,
)
if not profile.following:
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.USER_IS_NOT_FOLLOWED,
)
await profiles_repo.remove_user_from_followers(
target_user=profile,
requested_user=user,
)
return ProfileInResponse(profile=profile.copy(update={"following": False}))

View File

@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends
from app.api.dependencies.database import get_repository
from app.db.repositories.tags import TagsRepository
from app.models.schemas.tags import TagsInList
router = APIRouter()
@router.get("", response_model=TagsInList, name="tags:get-all")
async def get_all_tags(
tags_repo: TagsRepository = Depends(get_repository(TagsRepository)),
) -> TagsInList:
tags = await tags_repo.get_all_tags()
return TagsInList(tags=tags)

View File

@ -0,0 +1,35 @@
# app/api/routes/uploads.py
from fastapi import APIRouter, UploadFile, File, HTTPException, Request
from uuid import uuid4
from pathlib import Path
router = APIRouter(tags=["uploads"])
# 保存目录:项目根目录下 static/uploads
UPLOAD_DIR = Path("static/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
@router.post("/upload-image")
async def upload_image(
request: Request,
file: UploadFile = File(...),
):
# 只允许图片
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="只支持图片上传")
# 生成文件名
ext = (file.filename or "").rsplit(".", 1)[-1].lower() or "png"
name = f"{uuid4().hex}.{ext}"
save_path = UPLOAD_DIR / name
# 保存文件
content = await file.read()
save_path.write_bytes(content)
# 拼出完整 URL确保在 3000 端口页面里也能访问到 8000 的静态资源
base = str(request.base_url).rstrip("/") # e.g. "http://127.0.0.1:8000"
url = f"{base}/static/uploads/{name}"
return {"url": url}

View File

@ -0,0 +1,82 @@
# app\api\routes\users.py
from fastapi import APIRouter, Body, Depends, HTTPException
from starlette.status import HTTP_400_BAD_REQUEST
from app.api.dependencies.authentication import get_current_user_authorizer
from app.api.dependencies.database import get_repository
from app.core.config import get_app_settings
from app.core.settings.app import AppSettings
from app.db.repositories.users import UsersRepository
from app.models.domain.users import User
from app.models.schemas.users import UserInResponse, UserInUpdate, UserWithToken
from app.resources import strings
from app.services import jwt
from app.services.authentication import check_email_is_taken, check_username_is_taken
router = APIRouter()
@router.get("", response_model=UserInResponse, name="users:get-current-user")
async def retrieve_current_user(
user: User = Depends(get_current_user_authorizer()),
settings: AppSettings = Depends(get_app_settings),
) -> UserInResponse:
token = jwt.create_access_token_for_user(
user,
str(settings.secret_key.get_secret_value()),
)
return UserInResponse(
user=UserWithToken(
username=user.username,
email=user.email,
bio=user.bio,
image=user.image,
phone=getattr(user, "phone", None),
user_type=getattr(user, "user_type", None),
company_name=getattr(user, "company_name", None),
token=token,
roles=getattr(user, "roles", []),
),
)
@router.put("", response_model=UserInResponse, name="users:update-current-user")
async def update_current_user(
user_update: UserInUpdate = Body(..., embed=True, alias="user"),
current_user: User = Depends(get_current_user_authorizer()),
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
settings: AppSettings = Depends(get_app_settings),
) -> UserInResponse:
if user_update.username and user_update.username != current_user.username:
if await check_username_is_taken(users_repo, user_update.username):
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.USERNAME_TAKEN,
)
if user_update.email and user_update.email != current_user.email:
if await check_email_is_taken(users_repo, user_update.email):
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail=strings.EMAIL_TAKEN,
)
user = await users_repo.update_user(user=current_user, **user_update.dict())
token = jwt.create_access_token_for_user(
user,
str(settings.secret_key.get_secret_value()),
)
return UserInResponse(
user=UserWithToken(
username=user.username,
email=user.email,
bio=user.bio,
image=user.image,
phone=getattr(user, "phone", None),
user_type=getattr(user, "user_type", None),
company_name=getattr(user, "company_name", None),
token=token,
roles=getattr(user, "roles", []),
),
)

View File

View File

@ -0,0 +1,21 @@
from functools import lru_cache
from typing import Dict, Type
from app.core.settings.app import AppSettings
from app.core.settings.base import AppEnvTypes, BaseAppSettings
from app.core.settings.development import DevAppSettings
from app.core.settings.production import ProdAppSettings
from app.core.settings.test import TestAppSettings
environments: Dict[AppEnvTypes, Type[AppSettings]] = {
AppEnvTypes.dev: DevAppSettings,
AppEnvTypes.prod: ProdAppSettings,
AppEnvTypes.test: TestAppSettings,
}
@lru_cache
def get_app_settings() -> AppSettings:
app_env = BaseAppSettings().app_env
config = environments[app_env]
return config()

View File

@ -0,0 +1,25 @@
from typing import Callable
from fastapi import FastAPI
from loguru import logger
from app.core.settings.app import AppSettings
from app.db.events import close_db_connection, connect_to_db
def create_start_app_handler(
app: FastAPI,
settings: AppSettings,
) -> Callable: # type: ignore
async def start_app() -> None:
await connect_to_db(app, settings)
return start_app
def create_stop_app_handler(app: FastAPI) -> Callable: # type: ignore
@logger.catch
async def stop_app() -> None:
await close_db_connection(app)
return stop_app

View File

@ -0,0 +1,25 @@
import logging
from types import FrameType
from typing import cast
from loguru import logger
class InterceptHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None: # pragma: no cover
# Get corresponding Loguru level if it exists
try:
level = logger.level(record.levelname).name
except ValueError:
level = str(record.levelno)
# Find caller from where originated the logged message
frame, depth = logging.currentframe(), 2
while frame.f_code.co_filename == logging.__file__: # noqa: WPS609
frame = cast(FrameType, frame.f_back)
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level,
record.getMessage(),
)

View File

View File

@ -0,0 +1,77 @@
# app/core/settings/app.py
import logging
import sys
from typing import Any, Dict, List, Tuple, Optional
from loguru import logger
from pydantic import PostgresDsn, SecretStr, EmailStr
from app.core.logging import InterceptHandler
from app.core.settings.base import BaseAppSettings
class AppSettings(BaseAppSettings):
# ===== 基本 FastAPI 设置 =====
debug: bool = False
docs_url: str = "/docs"
openapi_prefix: str = ""
openapi_url: str = "/openapi.json"
redoc_url: str = "/redoc"
title: str = "FastAPI example application"
version: str = "0.0.0"
# ===== 数据库 =====
database_url: PostgresDsn
max_connection_count: int = 10
min_connection_count: int = 10
# ===== 安全/JWT =====
secret_key: SecretStr
api_prefix: str = "/api"
jwt_token_prefix: str = "Token"
# ===== CORS/Host =====
allowed_hosts: List[str] = ["*"]
# ===== 日志 =====
logging_level: int = logging.INFO
loggers: Tuple[str, str] = ("uvicorn.asgi", "uvicorn.access")
# ===== 邮件/验证码(新增配置) =====
mail_from: EmailStr = "no-reply@example.com"
# SMTP 基础(兼容 Py3.9:使用 Optional[...]
smtp_host: str = "localhost"
smtp_port: int = 25
smtp_user: Optional[SecretStr] = None
smtp_password: Optional[SecretStr] = None
smtp_tls: bool = False # True 时将尝试 STARTTLS
# 验证码配置
email_code_expires_minutes: int = 10
email_code_scenes: Tuple[str, ...] = ("register", "reset", "login")
# ===== 管理员自动信任的邮箱(逗号分隔,登录后自动授予 admin <20>?=====
admin_emails: List[str] = []
class Config:
validate_assignment = True
@property
def fastapi_kwargs(self) -> Dict[str, Any]:
return {
"debug": self.debug,
"docs_url": self.docs_url,
"openapi_prefix": self.openapi_prefix,
"openapi_url": self.openapi_url,
"redoc_url": self.redoc_url,
"title": self.title,
"version": self.version,
}
def configure_logging(self) -> None:
logging.getLogger().handlers = [InterceptHandler()]
for logger_name in self.loggers:
logging_logger = logging.getLogger(logger_name)
logging_logger.handlers = [InterceptHandler(level=self.logging_level)]
logger.configure(handlers=[{"sink": sys.stderr, "level": self.logging_level}])

View File

@ -0,0 +1,16 @@
from enum import Enum
from pydantic import BaseSettings
class AppEnvTypes(Enum):
prod: str = "prod"
dev: str = "dev"
test: str = "test"
class BaseAppSettings(BaseSettings):
app_env: AppEnvTypes = AppEnvTypes.prod
class Config:
env_file = ".env"

View File

@ -0,0 +1,14 @@
# app/core/settings/development.py
import logging
from app.core.settings.app import AppSettings
class DevAppSettings(AppSettings):
debug: bool = True
title: str = "Dev FastAPI example application"
logging_level: int = logging.DEBUG
class Config(AppSettings.Config):
# 开发环境读取 .env
env_file = ".env"

View File

@ -0,0 +1,8 @@
# app/core/settings/production.py
from app.core.settings.app import AppSettings
class ProdAppSettings(AppSettings):
class Config(AppSettings.Config):
# 生产环境读取 prod.env
env_file = "prod.env"

View File

@ -0,0 +1,19 @@
import logging
from pydantic import PostgresDsn, SecretStr
from app.core.settings.app import AppSettings
class TestAppSettings(AppSettings):
debug: bool = True
title: str = "Test FastAPI example application"
secret_key: SecretStr = SecretStr("test_secret")
database_url: PostgresDsn
max_connection_count: int = 5
min_connection_count: int = 5
logging_level: int = logging.DEBUG

View File

2
backend/app/db/errors.py Normal file
View File

@ -0,0 +1,2 @@
class EntityDoesNotExist(Exception):
"""Raised when entity was not found in database."""

25
backend/app/db/events.py Normal file
View File

@ -0,0 +1,25 @@
import asyncpg
from fastapi import FastAPI
from loguru import logger
from app.core.settings.app import AppSettings
async def connect_to_db(app: FastAPI, settings: AppSettings) -> None:
logger.info("Connecting to PostgreSQL")
app.state.pool = await asyncpg.create_pool(
str(settings.database_url),
min_size=settings.min_connection_count,
max_size=settings.max_connection_count,
)
logger.info("Connection established")
async def close_db_connection(app: FastAPI) -> None:
logger.info("Closing connection to database")
await app.state.pool.close()
logger.info("Connection closed")

View File

@ -0,0 +1,38 @@
import pathlib
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
sys.path.append(str(pathlib.Path(__file__).resolve().parents[3]))
from app.core.config import get_app_settings # isort:skip
SETTINGS = get_app_settings()
DATABASE_URL = SETTINGS.database_url
config = context.config
fileConfig(config.config_file_name) # type: ignore
target_metadata = None
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
run_migrations_online()

View File

@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,26 @@
"""add article views column
Revision ID: add_article_views
Revises: fdf8821871d7
Create Date: 2025-11-21
"""
import sqlalchemy as sa
from alembic import op
revision = "add_article_views"
down_revision = "fdf8821871d7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"articles",
sa.Column("views", sa.Integer(), nullable=False, server_default="0"),
)
op.alter_column("articles", "views", server_default=None)
def downgrade() -> None:
op.drop_column("articles", "views")

View File

@ -0,0 +1,120 @@
"""add roles and user_roles tables
Revision ID: add_roles_tables
Revises: add_article_views
Create Date: 2025-11-21
"""
from typing import Tuple
import sqlalchemy as sa
from alembic import op
from sqlalchemy import func, text
from sqlalchemy.dialects.postgresql import JSONB
revision = "add_roles_tables"
down_revision = "add_article_views"
branch_labels = None
depends_on = None
def timestamps() -> Tuple[sa.Column, sa.Column]:
return (
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.current_timestamp(),
),
)
def upgrade() -> None:
op.create_table(
"roles",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(length=64), nullable=False, unique=True),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column(
"permissions",
JSONB,
nullable=False,
server_default=text("'[]'::jsonb"),
),
*timestamps(),
)
op.execute(
"""
CREATE TRIGGER update_role_modtime
BEFORE UPDATE
ON roles
FOR EACH ROW
EXECUTE PROCEDURE update_updated_at_column();
""",
)
op.create_table(
"user_roles",
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role_id",
sa.Integer,
sa.ForeignKey("roles.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"assigned_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
),
)
op.create_primary_key(
"pk_user_roles",
"user_roles",
["user_id", "role_id"],
)
op.create_index("ix_user_roles_role_id", "user_roles", ["role_id"])
op.execute(
"""
INSERT INTO roles (name, description, permissions)
VALUES ('admin', 'System administrator with full privileges', '["*"]')
ON CONFLICT (name) DO NOTHING;
""",
)
op.execute(
"""
DO $$
DECLARE
admin_role_id INTEGER;
first_user_id INTEGER;
BEGIN
SELECT id INTO admin_role_id FROM roles WHERE name = 'admin';
SELECT id INTO first_user_id FROM users ORDER BY id ASC LIMIT 1;
IF admin_role_id IS NOT NULL AND first_user_id IS NOT NULL THEN
INSERT INTO user_roles (user_id, role_id)
VALUES (first_user_id, admin_role_id)
ON CONFLICT DO NOTHING;
END IF;
END $$;
""",
)
def downgrade() -> None:
op.drop_index("ix_user_roles_role_id", table_name="user_roles")
op.drop_table("user_roles")
op.drop_table("roles")

View File

@ -0,0 +1,216 @@
"""main tables
Revision ID: fdf8821871d7
Revises:
Create Date: 2019-09-22 01:36:44.791880
"""
from typing import Tuple
import sqlalchemy as sa
from alembic import op
from sqlalchemy import func
revision = "fdf8821871d7"
down_revision = None
branch_labels = None
depends_on = None
def create_updated_at_trigger() -> None:
op.execute(
"""
CREATE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS
$$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
"""
)
def timestamps() -> Tuple[sa.Column, sa.Column]:
return (
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.current_timestamp(),
),
)
def create_users_table() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("username", sa.Text, unique=True, nullable=False, index=True),
sa.Column("email", sa.Text, unique=True, nullable=False, index=True),
sa.Column("salt", sa.Text, nullable=False),
sa.Column("hashed_password", sa.Text),
sa.Column("bio", sa.Text, nullable=False, server_default=""),
sa.Column("image", sa.Text),
*timestamps(),
)
op.execute(
"""
CREATE TRIGGER update_user_modtime
BEFORE UPDATE
ON users
FOR EACH ROW
EXECUTE PROCEDURE update_updated_at_column();
"""
)
def create_followers_to_followings_table() -> None:
op.create_table(
"followers_to_followings",
sa.Column(
"follower_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"following_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
)
op.create_primary_key(
"pk_followers_to_followings",
"followers_to_followings",
["follower_id", "following_id"],
)
def create_articles_table() -> None:
op.create_table(
"articles",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("slug", sa.Text, unique=True, nullable=False, index=True),
sa.Column("title", sa.Text, nullable=False),
sa.Column("description", sa.Text, nullable=False),
sa.Column("body", sa.Text, nullable=False),
sa.Column(
"author_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL")
),
*timestamps(),
)
op.execute(
"""
CREATE TRIGGER update_article_modtime
BEFORE UPDATE
ON articles
FOR EACH ROW
EXECUTE PROCEDURE update_updated_at_column();
"""
)
def create_tags_table() -> None:
op.create_table("tags", sa.Column("tag", sa.Text, primary_key=True))
def create_articles_to_tags_table() -> None:
op.create_table(
"articles_to_tags",
sa.Column(
"article_id",
sa.Integer,
sa.ForeignKey("articles.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"tag",
sa.Text,
sa.ForeignKey("tags.tag", ondelete="CASCADE"),
nullable=False,
),
)
op.create_primary_key(
"pk_articles_to_tags", "articles_to_tags", ["article_id", "tag"]
)
def create_favorites_table() -> None:
op.create_table(
"favorites",
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"article_id",
sa.Integer,
sa.ForeignKey("articles.id", ondelete="CASCADE"),
nullable=False,
),
)
op.create_primary_key("pk_favorites", "favorites", ["user_id", "article_id"])
def create_commentaries_table() -> None:
op.create_table(
"commentaries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("body", sa.Text, nullable=False),
sa.Column(
"author_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"article_id",
sa.Integer,
sa.ForeignKey("articles.id", ondelete="CASCADE"),
nullable=False,
),
*timestamps(),
)
op.execute(
"""
CREATE TRIGGER update_comment_modtime
BEFORE UPDATE
ON commentaries
FOR EACH ROW
EXECUTE PROCEDURE update_updated_at_column();
"""
)
def upgrade() -> None:
create_updated_at_trigger()
create_users_table()
create_followers_to_followings_table()
create_articles_table()
create_tags_table()
create_articles_to_tags_table()
create_favorites_table()
create_commentaries_table()
def downgrade() -> None:
op.drop_table("commentaries")
op.drop_table("favorites")
op.drop_table("articles_to_tags")
op.drop_table("tags")
op.drop_table("articles")
op.drop_table("followers_to_followings")
op.drop_table("users")
op.execute("DROP FUNCTION update_updated_at_column")

View File

View File

@ -0,0 +1,16 @@
# app/db/queries/queries.py
import pathlib
import aiosql
_SQL_DIR = pathlib.Path(__file__).parent / "sql"
def _load_all_sql_text_utf8() -> str:
# 统一用 UTF-8 读取 sql 目录下所有 .sql 文件(按文件名排序)
parts: list[str] = []
for p in sorted(_SQL_DIR.glob("*.sql")):
parts.append(p.read_text(encoding="utf-8"))
parts.append("\n")
return "".join(parts)
# 用 from_str而不是 from_pathfrom_path 会按系统默认编码读取)
queries = aiosql.from_str(_load_all_sql_text_utf8(), driver_adapter="asyncpg")

View File

@ -0,0 +1,140 @@
"""Typings for queries generated by aiosql"""
from typing import Dict, Optional, Sequence
from asyncpg import Connection, Record
class TagsQueriesMixin:
async def get_all_tags(self, conn: Connection) -> Record: ...
async def create_new_tags(
self, conn: Connection, tags: Sequence[Dict[str, str]]
) -> None: ...
class UsersQueriesMixin:
async def get_user_by_email(self, conn: Connection, *, email: str) -> Record: ...
async def get_user_by_username(
self, conn: Connection, *, username: str
) -> Record: ...
async def get_user_by_id(self, conn: Connection, *, id: int) -> Record: ...
async def create_new_user(
self,
conn: Connection,
*,
username: str,
email: str,
salt: str,
hashed_password: str
) -> Record: ...
async def update_user_by_username(
self,
conn: Connection,
*,
username: str,
new_username: str,
new_email: str,
new_salt: str,
new_password: str,
new_bio: Optional[str],
new_image: Optional[str],
new_phone: Optional[str],
new_user_type: Optional[str],
new_company_name: Optional[str]
) -> Record: ...
async def admin_update_user_by_id(
self,
conn: Connection,
*,
id: int,
new_username: Optional[str],
new_email: Optional[str],
new_salt: Optional[str],
new_password: Optional[str],
new_bio: Optional[str],
new_image: Optional[str],
new_phone: Optional[str],
new_user_type: Optional[str],
new_company_name: Optional[str]
) -> Record: ...
class ProfilesQueriesMixin:
async def is_user_following_for_another(
self, conn: Connection, *, follower_username: str, following_username: str
) -> Record: ...
async def subscribe_user_to_another(
self, conn: Connection, *, follower_username: str, following_username: str
) -> None: ...
async def unsubscribe_user_from_another(
self, conn: Connection, *, follower_username: str, following_username: str
) -> None: ...
class CommentsQueriesMixin:
async def get_comments_for_article_by_slug(
self, conn: Connection, *, slug: str
) -> Record: ...
async def get_comment_by_id_and_slug(
self, conn: Connection, *, comment_id: int, article_slug: str
) -> Record: ...
async def create_new_comment(
self, conn: Connection, *, body: str, article_slug: str, author_username: str
) -> Record: ...
async def delete_comment_by_id(
self, conn: Connection, *, comment_id: int, author_username: str
) -> None: ...
class ArticlesQueriesMixin:
async def add_article_to_favorites(
self, conn: Connection, *, username: str, slug: str
) -> None: ...
async def remove_article_from_favorites(
self, conn: Connection, *, username: str, slug: str
) -> None: ...
async def is_article_in_favorites(
self, conn: Connection, *, username: str, slug: str
) -> Record: ...
async def get_favorites_count_for_article(
self, conn: Connection, *, slug: str
) -> Record: ...
async def get_tags_for_article_by_slug(
self, conn: Connection, *, slug: str
) -> Record: ...
async def get_article_by_slug(self, conn: Connection, *, slug: str) -> Record: ...
async def create_new_article(
self,
conn: Connection,
*,
slug: str,
title: str,
description: str,
body: str,
author_username: str
) -> Record: ...
async def add_tags_to_article(
self, conn: Connection, tags_slugs: Sequence[Dict[str, str]]
) -> None: ...
async def update_article(
self,
conn: Connection,
*,
slug: str,
author_username: str,
new_slug: str,
new_title: str,
new_body: str,
new_description: str
) -> Record: ...
async def delete_article(
self, conn: Connection, *, slug: str, author_username: str
) -> None: ...
async def get_articles_for_feed(
self, conn: Connection, *, follower_username: str, limit: int, offset: int
) -> Record: ...
class Queries(
TagsQueriesMixin,
UsersQueriesMixin,
ProfilesQueriesMixin,
CommentsQueriesMixin,
ArticlesQueriesMixin,
): ...
queries: Queries

View File

@ -0,0 +1,116 @@
-- name: add-article-to-favorites!
INSERT INTO favorites (user_id, article_id)
VALUES ((SELECT id FROM users WHERE username = :username),
(SELECT id FROM articles WHERE slug = :slug))
ON CONFLICT DO NOTHING;
-- name: remove-article-from-favorites!
DELETE
FROM favorites
WHERE user_id = (SELECT id FROM users WHERE username = :username)
AND article_id = (SELECT id FROM articles WHERE slug = :slug);
-- name: is-article-in-favorites^
SELECT CASE WHEN count(user_id) > 0 THEN TRUE ELSE FALSE END AS favorited
FROM favorites
WHERE user_id = (SELECT id FROM users WHERE username = :username)
AND article_id = (SELECT id FROM articles WHERE slug = :slug);
-- name: get-favorites-count-for-article^
SELECT count(*) as favorites_count
FROM favorites
WHERE article_id = (SELECT id FROM articles WHERE slug = :slug);
-- name: get-tags-for-article-by-slug
SELECT t.tag
FROM tags t
INNER JOIN articles_to_tags att ON
t.tag = att.tag
AND
att.article_id = (SELECT id FROM articles WHERE slug = :slug);
-- name: get-article-by-slug^
SELECT id,
slug,
title,
description,
body,
created_at,
updated_at,
(SELECT username FROM users WHERE id = author_id) AS author_username
FROM articles
WHERE slug = :slug
LIMIT 1;
-- name: create-new-article<!
WITH author_subquery AS (
SELECT id, username
FROM users
WHERE username = :author_username
)
INSERT
INTO articles (slug, title, description, body, author_id)
VALUES (:slug, :title, :description, :body, (SELECT id FROM author_subquery))
RETURNING
id,
slug,
title,
description,
body,
(SELECT username FROM author_subquery) as author_username,
created_at,
updated_at;
-- name: add-tags-to-article*!
INSERT INTO articles_to_tags (article_id, tag)
VALUES ((SELECT id FROM articles WHERE slug = :slug),
(SELECT tag FROM tags WHERE tag = :tag))
ON CONFLICT DO NOTHING;
-- name: update-article<!
UPDATE articles
SET slug = :new_slug,
title = :new_title,
body = :new_body,
description = :new_description
WHERE slug = :slug
AND author_id = (SELECT id FROM users WHERE username = :author_username)
RETURNING updated_at;
-- name: delete-article!
DELETE
FROM articles
WHERE slug = :slug
AND author_id = (SELECT id FROM users WHERE username = :author_username);
-- name: get-articles-for-feed
SELECT a.id,
a.slug,
a.title,
a.description,
a.body,
a.created_at,
a.updated_at,
(
SELECT username
FROM users
WHERE id = a.author_id
) AS author_username
FROM articles a
INNER JOIN followers_to_followings f ON
f.following_id = a.author_id AND
f.follower_id = (SELECT id FROM users WHERE username = :follower_username)
ORDER BY a.created_at
LIMIT :limit
OFFSET
:offset;

View File

@ -0,0 +1,40 @@
-- name: get-comments-for-article-by-slug
SELECT c.id,
c.body,
c.created_at,
c.updated_at,
(SELECT username FROM users WHERE id = c.author_id) as author_username
FROM commentaries c
INNER JOIN articles a ON c.article_id = a.id AND (a.slug = :slug);
-- name: get-comment-by-id-and-slug^
SELECT c.id,
c.body,
c.created_at,
c.updated_at,
(SELECT username FROM users WHERE id = c.author_id) as author_username
FROM commentaries c
INNER JOIN articles a ON c.article_id = a.id AND (a.slug = :article_slug)
WHERE c.id = :comment_id;
-- name: create-new-comment<!
WITH users_subquery AS (
(SELECT id, username FROM users WHERE username = :author_username)
)
INSERT
INTO commentaries (body, author_id, article_id)
VALUES (:body,
(SELECT id FROM users_subquery),
(SELECT id FROM articles WHERE slug = :article_slug))
RETURNING
id,
body,
(SELECT username FROM users_subquery) AS author_username,
created_at,
updated_at;
-- name: delete-comment-by-id!
DELETE
FROM commentaries
WHERE id = :comment_id
AND author_id = (SELECT id FROM users WHERE username = :author_username);

View File

@ -0,0 +1,38 @@
-- name: is-user-following-for-another^
SELECT CASE
WHEN following_id IS NULL THEN
FALSE
ELSE
TRUE
END AS is_following
FROM users u
LEFT OUTER JOIN followers_to_followings f ON u.id = f.follower_id
AND f.following_id = (
SELECT id
FROM users
WHERE username = :following_username)
WHERE u.username = :follower_username
LIMIT 1;
-- name: subscribe-user-to-another!
INSERT INTO followers_to_followings (follower_id, following_id)
VALUES ((
SELECT id
FROM users
WHERE username = :follower_username), (
SELECT id
FROM users
WHERE username = :following_username));
-- name: unsubscribe-user-from-another!
DELETE
FROM followers_to_followings
WHERE follower_id = (
SELECT id
FROM users
WHERE username = :follower_username)
AND following_id = (
SELECT id
FROM users
WHERE username = :following_username);

View File

@ -0,0 +1,184 @@
-- ============================================================================
-- Articles & Favorites & Tags
-- 要求 articles 表字段至少包含:
-- id, slug, title, description, body, cover, author_id, views,
-- is_top, is_featured, sort_weight, created_at, updated_at
-- ============================================================================
-- 创建文章(支持 cover
-- 单行返回 -> 使用 ^
-- name: create_new_article^
INSERT INTO articles (
slug,
title,
description,
body,
cover,
author_id,
views
) VALUES (
:slug,
:title,
:description,
:body,
:cover,
(SELECT id FROM users WHERE username = :author_username),
0
)
RETURNING
articles.id,
articles.slug,
articles.title,
articles.description,
articles.body,
articles.cover,
articles.views,
articles.is_top,
articles.is_featured,
articles.sort_weight,
articles.created_at,
articles.updated_at,
(SELECT username FROM users WHERE id = articles.author_id) AS author_username;
-- 更新文章(包含 cover
-- 单行(返回 updated_at -> ^
-- name: update_article^
UPDATE articles
SET
slug = COALESCE(:new_slug, slug),
title = COALESCE(:new_title, title),
body = COALESCE(:new_body, body),
description = COALESCE(:new_description, description),
cover = :new_cover
WHERE
slug = :slug
AND author_id = (SELECT id FROM users WHERE username = :author_username)
RETURNING updated_at;
-- 删除文章
-- 执行型,无返回 -> !
-- name: delete_article!
DELETE FROM articles
WHERE
slug = :slug
AND author_id = (SELECT id FROM users WHERE username = :author_username);
-- 根据 slug 获取单篇文章(带 cover
-- 单行返回 -> ^
-- name: get_article_by_slug^
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
FROM articles AS a
JOIN users AS u ON u.id = a.author_id
WHERE a.slug = :slug;
-- Feed / 列表文章(带 cover
-- 多行结果 -> 不能用 ^
-- name: get_articles_for_feed
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
FROM articles AS a
JOIN users AS u
ON u.id = a.author_id
JOIN followers_to_followings AS f
ON f.following_id = u.id
WHERE f.follower_id = (
SELECT id FROM users WHERE username = :follower_username
)
ORDER BY a.is_top DESC, a.sort_weight DESC, a.created_at DESC
LIMIT :limit OFFSET :offset;
-- ======================================================================
-- Tags 相关
-- ======================================================================
-- 给文章添加标签
-- 执行型 -> !
-- name: add_tags_to_article!
INSERT INTO articles_to_tags (article_id, tag)
SELECT a.id, :tag
FROM articles a
WHERE a.slug = :slug;
-- 获取文章的所有标签
-- name: get_tags_for_article_by_slug
SELECT t.tag
FROM articles_to_tags t
JOIN articles a ON a.id = t.article_id
WHERE a.slug = :slug
ORDER BY t.tag;
-- ======================================================================
-- Favorites 相关
-- ======================================================================
-- 统计收藏数
-- 单值 -> ^
-- name: get_favorites_count_for_article^
SELECT COUNT(*)::int AS favorites_count
FROM favorites f
JOIN articles a ON a.id = f.article_id
WHERE a.slug = :slug;
-- 是否已收藏
-- 单值布尔 -> ^
-- name: is_article_in_favorites^
SELECT EXISTS (
SELECT 1
FROM favorites f
JOIN articles a ON a.id = f.article_id
JOIN users u ON u.id = f.user_id
WHERE a.slug = :slug
AND u.username = :username
) AS favorited;
-- 加入收藏
-- 执行型 -> !
-- name: add_article_to_favorites!
INSERT INTO favorites (user_id, article_id)
SELECT
(SELECT id FROM users WHERE username = :username),
(SELECT id FROM articles WHERE slug = :slug)
ON CONFLICT DO NOTHING;
-- 取消收藏
-- 执行型 -> !
-- name: remove_article_from_favorites!
DELETE FROM favorites
WHERE user_id = (SELECT id FROM users WHERE username = :username)
AND article_id = (SELECT id FROM articles WHERE slug = :slug);
-- ======================================================================
-- Views 相关
-- ======================================================================
-- 访问量 +1返回最新值
-- name: increment_article_views^
UPDATE articles
SET views = views + 1
WHERE slug = :slug
RETURNING views;

View File

@ -0,0 +1,86 @@
-- name: list-roles
SELECT id,
name,
description,
permissions,
created_at,
updated_at
FROM roles
ORDER BY name;
-- name: get-role-by-id^
SELECT id,
name,
description,
permissions,
created_at,
updated_at
FROM roles
WHERE id = :role_id
LIMIT 1;
-- name: create-role^
INSERT INTO roles (name, description, permissions)
VALUES (:name, :description, :permissions)
RETURNING id,
name,
description,
permissions,
created_at,
updated_at;
-- name: update-role^
UPDATE roles
SET name = COALESCE(:name, name),
description = COALESCE(:description, description),
permissions = COALESCE(:permissions, permissions)
WHERE id = :role_id
RETURNING id,
name,
description,
permissions,
created_at,
updated_at;
-- name: delete-role!
DELETE FROM roles
WHERE id = :role_id;
-- name: get-roles-for-user
SELECT r.id,
r.name,
r.description,
r.permissions,
r.created_at,
r.updated_at
FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = :user_id
ORDER BY r.name;
-- name: assign-role-to-user!
INSERT INTO user_roles (user_id, role_id)
VALUES (:user_id, :role_id)
ON CONFLICT DO NOTHING;
-- name: revoke-role-from-user!
DELETE FROM user_roles
WHERE user_id = :user_id
AND role_id = :role_id;
-- name: user-has-role^
SELECT EXISTS (
SELECT 1
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = :user_id
AND r.name = :role_name
) AS has_role;

View File

@ -0,0 +1,9 @@
-- name: get-all-tags
SELECT tag
FROM tags;
-- name: create-new-tags*!
INSERT INTO tags (tag)
VALUES (:tag)
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,119 @@
-- name: get-user-by-email^
SELECT u.id,
u.username,
u.email,
u.salt,
u.hashed_password,
u.bio,
u.image,
u.phone,
u.user_type,
u.company_name,
u.created_at,
u.updated_at,
COALESCE(array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.email = :email
GROUP BY u.id
LIMIT 1;
-- name: get-user-by-username^
SELECT u.id,
u.username,
u.email,
u.salt,
u.hashed_password,
u.bio,
u.image,
u.phone,
u.user_type,
u.company_name,
u.created_at,
u.updated_at,
COALESCE(array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.username = :username
GROUP BY u.id
LIMIT 1;
-- name: create-new-user<!
INSERT INTO users (username, email, salt, hashed_password)
VALUES (:username, :email, :salt, :hashed_password)
RETURNING
id, created_at, updated_at;
-- name: update-user-by-username<!
UPDATE
users
SET username = :new_username,
email = :new_email,
salt = :new_salt,
hashed_password = :new_password,
bio = :new_bio,
image = :new_image,
phone = :new_phone,
user_type = :new_user_type,
company_name = :new_company_name
WHERE username = :username
RETURNING
updated_at;
-- name: get-user-by-id^
SELECT u.id,
u.username,
u.email,
u.salt,
u.hashed_password,
u.bio,
u.image,
u.phone,
u.user_type,
u.company_name,
u.created_at,
u.updated_at,
COALESCE(array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL), '{}') AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = :id
GROUP BY u.id
LIMIT 1;
-- name: admin-update-user-by-id^
UPDATE users
SET username = COALESCE(:new_username, username),
email = COALESCE(:new_email, email),
salt = COALESCE(:new_salt, salt),
hashed_password = COALESCE(:new_password, hashed_password),
bio = COALESCE(:new_bio, bio),
image = COALESCE(:new_image, image),
phone = COALESCE(:new_phone, phone),
user_type = COALESCE(:new_user_type, user_type),
company_name = COALESCE(:new_company_name, company_name)
WHERE id = :id
RETURNING id,
username,
email,
salt,
hashed_password,
bio,
image,
phone,
user_type,
company_name,
created_at,
updated_at;
-- name: admin-delete-user!
DELETE FROM users
WHERE id = :id;

View File

@ -0,0 +1,75 @@
from datetime import datetime
from typing import Optional
from pypika import Parameter as CommonParameter, Query, Table
class Parameter(CommonParameter):
def __init__(self, count: int) -> None:
super().__init__("${0}".format(count))
class TypedTable(Table):
__table__ = ""
def __init__(
self,
name: Optional[str] = None,
schema: Optional[str] = None,
alias: Optional[str] = None,
query_cls: Optional[Query] = None,
) -> None:
if name is None:
if self.__table__:
name = self.__table__
else:
name = self.__class__.__name__
super().__init__(name, schema, alias, query_cls)
class Users(TypedTable):
__table__ = "users"
id: int
username: str
class Articles(TypedTable):
__table__ = "articles"
id: int
slug: str
title: str
description: str
body: str
author_id: int
created_at: datetime
updated_at: datetime
class Tags(TypedTable):
__table__ = "tags"
tag: str
class ArticlesToTags(TypedTable):
__table__ = "articles_to_tags"
article_id: int
tag: str
class Favorites(TypedTable):
__table__ = "favorites"
article_id: int
user_id: int
users = Users()
articles = Articles()
tags = Tags()
articles_to_tags = ArticlesToTags()
favorites = Favorites()

View File

View File

@ -0,0 +1,161 @@
from __future__ import annotations
import json
from typing import List, Optional, Tuple
from app.db.repositories.base import BaseRepository
from app.models.schemas.admin import AdminRoleLite, AdminUserSummary, AdminDashboardStats
class AdminRepository(BaseRepository):
def _normalize_roles(self, payload) -> List[AdminRoleLite]:
roles_payload = payload or []
if isinstance(roles_payload, str):
try:
roles_payload = json.loads(roles_payload)
except ValueError:
roles_payload = []
return [
AdminRoleLite(**role)
for role in roles_payload
if role
]
def _record_to_user(self, record) -> AdminUserSummary:
roles = self._normalize_roles(record.get("roles"))
return AdminUserSummary(
id=record["id"],
username=record["username"],
email=record["email"],
bio=record.get("bio"),
image=record.get("image"),
roles=roles,
created_at=record["created_at"],
updated_at=record["updated_at"],
)
def _build_user_filters(
self,
search: Optional[str],
role_id: Optional[int],
) -> Tuple[str, List[object]]:
clauses: List[str] = []
params: List[object] = []
if search:
placeholder = f"${len(params) + 1}"
params.append(f"%{search}%")
clauses.append(
f"(u.username ILIKE {placeholder} OR u.email ILIKE {placeholder})",
)
if role_id:
placeholder = f"${len(params) + 1}"
params.append(role_id)
clauses.append(
f"EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role_id = {placeholder})",
)
if not clauses:
return "", params
return "WHERE " + " AND ".join(clauses), params
async def list_users(
self,
*,
search: Optional[str],
role_id: Optional[int],
limit: int,
offset: int,
) -> Tuple[List[AdminUserSummary], int]:
where_sql, params = self._build_user_filters(search, role_id)
base_params = list(params)
count_sql = f"SELECT COUNT(*) FROM users u {where_sql}"
total = await self.connection.fetchval(count_sql, *base_params)
list_params = list(base_params)
list_params.extend([limit, offset])
list_sql = f"""
SELECT
u.id,
u.username,
u.email,
u.bio,
u.image,
u.created_at,
u.updated_at,
COALESCE(
jsonb_agg(
DISTINCT jsonb_build_object(
'id', r.id,
'name', r.name,
'description', r.description,
'permissions', r.permissions
)
) FILTER (WHERE r.id IS NOT NULL),
'[]'::jsonb
) AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
{where_sql}
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT ${len(base_params) + 1}
OFFSET ${len(base_params) + 2}
"""
rows = await self.connection.fetch(list_sql, *list_params)
return [self._record_to_user(row) for row in rows], int(total or 0)
async def get_user_summary(self, user_id: int) -> Optional[AdminUserSummary]:
sql = """
SELECT
u.id,
u.username,
u.email,
u.bio,
u.image,
u.created_at,
u.updated_at,
COALESCE(
jsonb_agg(
DISTINCT jsonb_build_object(
'id', r.id,
'name', r.name,
'description', r.description,
'permissions', r.permissions
)
) FILTER (WHERE r.id IS NOT NULL),
'[]'::jsonb
) AS roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1
GROUP BY u.id
"""
record = await self.connection.fetchrow(sql, user_id)
if not record:
return None
return self._record_to_user(record)
async def get_dashboard_stats(self) -> AdminDashboardStats:
users_count = await self.connection.fetchval("SELECT COUNT(*) FROM users")
roles_count = await self.connection.fetchval("SELECT COUNT(*) FROM roles")
articles_count = await self.connection.fetchval("SELECT COUNT(*) FROM articles")
total_views = await self.connection.fetchval(
"SELECT COALESCE(SUM(views), 0) FROM articles",
)
published_today = await self.connection.fetchval(
"SELECT COUNT(*) FROM articles WHERE created_at >= (NOW() - INTERVAL '1 day')",
)
return AdminDashboardStats(
users=int(users_count or 0),
roles=int(roles_count or 0),
articles=int(articles_count or 0),
total_views=int(total_views or 0),
published_today=int(published_today or 0),
)

View File

@ -0,0 +1,624 @@
# 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)

View File

@ -0,0 +1,10 @@
from asyncpg.connection import Connection
class BaseRepository:
def __init__(self, conn: Connection) -> None:
self._conn = conn
@property
def connection(self) -> Connection:
return self._conn

View File

@ -0,0 +1,103 @@
from typing import List, Optional
from asyncpg import Connection, Record
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.models.domain.articles import Article
from app.models.domain.comments import Comment
from app.models.domain.users import User
class CommentsRepository(BaseRepository):
def __init__(self, conn: Connection) -> None:
super().__init__(conn)
self._profiles_repo = ProfilesRepository(conn)
async def get_comment_by_id(
self,
*,
comment_id: int,
article: Article,
user: Optional[User] = None,
) -> Comment:
comment_row = await queries.get_comment_by_id_and_slug(
self.connection,
comment_id=comment_id,
article_slug=article.slug,
)
if comment_row:
return await self._get_comment_from_db_record(
comment_row=comment_row,
author_username=comment_row["author_username"],
requested_user=user,
)
raise EntityDoesNotExist(
"comment with id {0} does not exist".format(comment_id),
)
async def get_comments_for_article(
self,
*,
article: Article,
user: Optional[User] = None,
) -> List[Comment]:
comments_rows = await queries.get_comments_for_article_by_slug(
self.connection,
slug=article.slug,
)
return [
await self._get_comment_from_db_record(
comment_row=comment_row,
author_username=comment_row["author_username"],
requested_user=user,
)
for comment_row in comments_rows
]
async def create_comment_for_article(
self,
*,
body: str,
article: Article,
user: User,
) -> Comment:
comment_row = await queries.create_new_comment(
self.connection,
body=body,
article_slug=article.slug,
author_username=user.username,
)
return await self._get_comment_from_db_record(
comment_row=comment_row,
author_username=comment_row["author_username"],
requested_user=user,
)
async def delete_comment(self, *, comment: Comment) -> None:
await queries.delete_comment_by_id(
self.connection,
comment_id=comment.id_,
author_username=comment.author.username,
)
async def _get_comment_from_db_record(
self,
*,
comment_row: Record,
author_username: str,
requested_user: Optional[User],
) -> Comment:
return Comment(
id_=comment_row["id"],
body=comment_row["body"],
author=await self._profiles_repo.get_profile_by_username(
username=author_username,
requested_user=requested_user,
),
created_at=comment_row["created_at"],
updated_at=comment_row["updated_at"],
)

View File

@ -0,0 +1,68 @@
# app/services/mailer.py
import smtplib
import ssl
from email.message import EmailMessage
from typing import Optional
from loguru import logger
from app.core.config import get_app_settings
def _build_message(*, from_email: str, to_email: str, subject: str, html: str) -> EmailMessage:
msg = EmailMessage()
msg["From"] = from_email
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content("Your email client does not support HTML.")
msg.add_alternative(html, subtype="html")
return msg
def send_email(to_email: str, subject: str, html: str) -> bool:
"""
同步发送成功返回 True失败返回 False并打印详细日志
- 端口 465使用 SMTP_SSL
- 其他端口使用 SMTP + (可选)STARTTLS
"""
s = get_app_settings()
from_email = str(s.mail_from)
smtp_host = s.smtp_host
smtp_port = int(s.smtp_port)
smtp_user: Optional[str] = s.smtp_user.get_secret_value() if s.smtp_user else None
smtp_pass: Optional[str] = s.smtp_password.get_secret_value() if s.smtp_password else None
msg = _build_message(from_email=from_email, to_email=to_email, subject=subject, html=html)
logger.info(
"SMTP send start → host={} port={} tls={} from={} to={}",
smtp_host, smtp_port, s.smtp_tls, from_email, to_email,
)
try:
if smtp_port == 465:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context, timeout=20) as server:
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
else:
with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as server:
server.ehlo()
if s.smtp_tls:
context = ssl.create_default_context()
server.starttls(context=context)
server.ehlo()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info("SMTP send OK to {}", to_email)
return True
except smtplib.SMTPResponseException as e:
# 能拿到服务端 code/resp 的错误
logger.error("SMTPResponseException: code={} msg={}", getattr(e, "smtp_code", None), getattr(e, "smtp_error", None))
return False
except Exception as e:
logger.exception("SMTP send failed: {}", e)
return False

View File

@ -0,0 +1,82 @@
# app/db/repositories/home_featured.py
from typing import List, Sequence
from asyncpg import Connection
from app.db.repositories.base import BaseRepository
class HomeFeaturedRepository(BaseRepository):
"""
维护首页推送文章的排序列表最多 10
仅存 slug + sort_order真正返回文章数据时再通过 ArticlesRepository 拉取
"""
def __init__(self, conn: Connection) -> None:
super().__init__(conn)
async def _ensure_table(self) -> None:
await self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS home_featured_articles (
slug TEXT PRIMARY KEY,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
""",
)
async def list_slugs(self, *, limit: int = 10) -> List[str]:
await self._ensure_table()
rows = await self.connection.fetch(
"""
SELECT slug
FROM home_featured_articles
ORDER BY sort_order ASC, updated_at DESC, created_at DESC
LIMIT $1;
""",
limit,
)
return [row["slug"] for row in rows]
async def save_slugs(self, *, slugs: Sequence[str], limit: int = 10) -> List[str]:
"""
保存首页推送顺序自动去重并截断到 limit
返回最终生效的 slug 顺序
"""
await self._ensure_table()
clean_slugs: List[str] = []
for slug in slugs:
normalized = str(slug).strip()
if not normalized:
continue
if normalized not in clean_slugs:
clean_slugs.append(normalized)
clean_slugs = clean_slugs[:limit]
async with self.connection.transaction():
if clean_slugs:
await self.connection.execute(
"""
DELETE FROM home_featured_articles
WHERE slug <> ALL($1::text[]);
""",
clean_slugs,
)
for idx, slug in enumerate(clean_slugs):
await self.connection.execute(
"""
INSERT INTO home_featured_articles (slug, sort_order)
VALUES ($1, $2)
ON CONFLICT (slug) DO UPDATE
SET sort_order = EXCLUDED.sort_order,
updated_at = NOW();
""",
slug,
idx,
)
else:
await self.connection.execute("DELETE FROM home_featured_articles;")
return clean_slugs

View File

@ -0,0 +1,90 @@
# app/db/repositories/menu_slots.py
from typing import List, Optional, Sequence
from asyncpg import Connection, Record
from app.db.repositories.base import BaseRepository
DEFAULT_MENU_SLOTS = [
{"slot_key": "news", "label": "资讯广场"},
{"slot_key": "tutorial", "label": "使用教程"},
{"slot_key": "community", "label": "社区"},
]
class MenuSlotsRepository(BaseRepository):
def __init__(self, conn: Connection) -> None:
super().__init__(conn)
async def _ensure_table(self) -> None:
await self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS menu_slots (
slot_key TEXT PRIMARY KEY,
label TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}'::text[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
""",
)
async def _ensure_default_slots(self) -> None:
await self._ensure_table()
for slot in DEFAULT_MENU_SLOTS:
await self.connection.execute(
"""
INSERT INTO menu_slots (slot_key, label)
VALUES ($1, $2)
ON CONFLICT (slot_key) DO NOTHING;
""",
slot["slot_key"],
slot["label"],
)
async def list_slots(self) -> List[Record]:
await self._ensure_default_slots()
rows = await self.connection.fetch(
"""
SELECT slot_key, label, tags, created_at, updated_at
FROM menu_slots
ORDER BY slot_key ASC;
""",
)
return list(rows)
async def get_slot(self, slot_key: str) -> Optional[Record]:
await self._ensure_default_slots()
return await self.connection.fetchrow(
"""
SELECT slot_key, label, tags, created_at, updated_at
FROM menu_slots
WHERE slot_key = $1
LIMIT 1;
""",
slot_key,
)
async def upsert_slot_tags(
self,
*,
slot_key: str,
tags: Sequence[str],
label: Optional[str] = None,
) -> Record:
await self._ensure_default_slots()
clean_tags = [t.strip() for t in tags if str(t).strip()]
return await self.connection.fetchrow(
"""
INSERT INTO menu_slots (slot_key, label, tags)
VALUES ($1, COALESCE($2, $1), $3::text[])
ON CONFLICT (slot_key) DO UPDATE
SET tags = EXCLUDED.tags,
label = COALESCE(EXCLUDED.label, menu_slots.label),
updated_at = NOW()
RETURNING slot_key, label, tags, created_at, updated_at;
""",
slot_key,
label or slot_key,
clean_tags,
)

View File

@ -0,0 +1,61 @@
# app/db/repositories/password_reset.py
import hashlib
from typing import Optional, Dict, Any
from datetime import datetime, timedelta, timezone
from asyncpg import Connection
from app.db.queries.queries import queries
class PasswordResetRepository:
def __init__(self, conn: Connection) -> None:
self.connection = conn
@staticmethod
def _hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def create(
self,
*,
user_id: int,
token: str,
ttl_minutes: int,
request_ip: Optional[str],
user_agent: Optional[str],
) -> Dict[str, Any]:
"""
创建一次性重置令牌仅存 token 的哈希
返回数据库返回的行dict/record 兼容为 Dict[str, Any]
"""
return await queries.create_password_reset_token(
self.connection,
user_id=user_id,
token_hash=self._hash(token),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes),
request_ip=request_ip,
user_agent=user_agent,
)
async def get_valid(self, *, token: str) -> Optional[Dict[str, Any]]:
"""
根据明文 token 查找并校验是否可用
- 存在
- 未使用
- 未过期
返回行 dict无效则返回 None
"""
row = await queries.get_password_reset_token_by_hash(
self.connection, token_hash=self._hash(token)
)
if not row:
return None
if row["used_at"] is not None:
return None
if row["expires_at"] <= datetime.now(timezone.utc):
return None
return row
async def mark_used(self, *, token_id: int) -> None:
"""将重置令牌标记为已使用。"""
await queries.mark_password_reset_token_used(self.connection, id=token_id)

View File

@ -0,0 +1,74 @@
from typing import Optional, Union
from asyncpg import Connection
from app.db.queries.queries import queries
from app.db.repositories.base import BaseRepository
from app.db.repositories.users import UsersRepository
from app.models.domain.profiles import Profile
from app.models.domain.users import User
UserLike = Union[User, Profile]
class ProfilesRepository(BaseRepository):
def __init__(self, conn: Connection):
super().__init__(conn)
self._users_repo = UsersRepository(conn)
async def get_profile_by_username(
self,
*,
username: str,
requested_user: Optional[UserLike],
) -> Profile:
user = await self._users_repo.get_user_by_username(username=username)
profile = Profile(username=user.username, bio=user.bio, image=user.image)
if requested_user:
profile.following = await self.is_user_following_for_another_user(
target_user=user,
requested_user=requested_user,
)
return profile
async def is_user_following_for_another_user(
self,
*,
target_user: UserLike,
requested_user: UserLike,
) -> bool:
return (
await queries.is_user_following_for_another(
self.connection,
follower_username=requested_user.username,
following_username=target_user.username,
)
)["is_following"]
async def add_user_into_followers(
self,
*,
target_user: UserLike,
requested_user: UserLike,
) -> None:
async with self.connection.transaction():
await queries.subscribe_user_to_another(
self.connection,
follower_username=requested_user.username,
following_username=target_user.username,
)
async def remove_user_from_followers(
self,
*,
target_user: UserLike,
requested_user: UserLike,
) -> None:
async with self.connection.transaction():
await queries.unsubscribe_user_from_another(
self.connection,
follower_username=requested_user.username,
following_username=target_user.username,
)

View File

@ -0,0 +1,143 @@
from __future__ import annotations
import json
from typing import Iterable, List, Optional
from app.db.errors import EntityDoesNotExist
from app.db.queries.queries import queries
from app.db.repositories.base import BaseRepository
from app.models.domain.roles import Role
class RolesRepository(BaseRepository):
def _convert_role_row(self, row) -> dict:
permissions = row.get("permissions") if row else []
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except ValueError:
permissions = []
permissions = permissions or []
return {
**row,
"permissions": permissions,
}
async def list_roles(self) -> List[Role]:
rows = await queries.list_roles(self.connection)
return [Role(**self._convert_role_row(row)) for row in rows]
async def get_role_by_id(self, role_id: int) -> Role:
row = await queries.get_role_by_id(self.connection, role_id=role_id)
if not row:
raise EntityDoesNotExist(f"role {role_id} does not exist")
return Role(**self._convert_role_row(row))
async def get_role_by_name(self, *, name: str) -> Optional[Role]:
row = await self.connection.fetchrow(
"""
SELECT id, name, description, permissions, created_at, updated_at
FROM roles
WHERE name = $1
""",
name,
)
if not row:
return None
return Role(**self._convert_role_row(dict(row)))
async def create_role(
self,
*,
name: str,
description: Optional[str] = "",
permissions: Optional[Iterable[str]] = None,
) -> Role:
row = await queries.create_role(
self.connection,
name=name,
description=description or "",
permissions=list(permissions or []),
)
return Role(**self._convert_role_row(row))
async def update_role(
self,
*,
role_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
permissions: Optional[Iterable[str]] = None,
) -> Role:
row = await queries.update_role(
self.connection,
role_id=role_id,
name=name,
description=description,
permissions=list(permissions) if permissions is not None else None,
)
if not row:
raise EntityDoesNotExist(f"role {role_id} does not exist")
return Role(**self._convert_role_row(row))
async def ensure_role(
self,
*,
name: str,
description: Optional[str] = "",
permissions: Optional[Iterable[str]] = None,
) -> Role:
existing = await self.get_role_by_name(name=name)
if existing:
return existing
return await self.create_role(
name=name,
description=description or "",
permissions=permissions or [],
)
async def delete_role(self, *, role_id: int) -> None:
await queries.delete_role(self.connection, role_id=role_id)
async def get_roles_for_user(self, *, user_id: int) -> List[Role]:
rows = await queries.get_roles_for_user(self.connection, user_id=user_id)
return [Role(**self._convert_role_row(row)) for row in rows]
async def get_role_names_for_user(self, *, user_id: int) -> List[str]:
return [role.name for role in await self.get_roles_for_user(user_id=user_id)]
async def assign_role_to_user(self, *, user_id: int, role_id: int) -> None:
await queries.assign_role_to_user(
self.connection,
user_id=user_id,
role_id=role_id,
)
async def revoke_role_from_user(self, *, user_id: int, role_id: int) -> None:
await queries.revoke_role_from_user(
self.connection,
user_id=user_id,
role_id=role_id,
)
async def set_roles_for_user(self, *, user_id: int, role_ids: Iterable[int]) -> None:
role_ids = list(dict.fromkeys(role_ids))
async with self.connection.transaction():
await self.connection.execute(
"DELETE FROM user_roles WHERE user_id = $1",
user_id,
)
for role_id in role_ids:
await queries.assign_role_to_user(
self.connection,
user_id=user_id,
role_id=role_id,
)
async def user_has_role(self, *, user_id: int, role_name: str) -> bool:
row = await queries.user_has_role(
self.connection,
user_id=user_id,
role_name=role_name,
)
return bool(row and row.get("has_role"))

View File

@ -0,0 +1,13 @@
from typing import List, Sequence
from app.db.queries.queries import queries
from app.db.repositories.base import BaseRepository
class TagsRepository(BaseRepository):
async def get_all_tags(self) -> List[str]:
tags_row = await queries.get_all_tags(self.connection)
return [tag[0] for tag in tags_row]
async def create_tags_that_dont_exist(self, *, tags: Sequence[str]) -> None:
await queries.create_new_tags(self.connection, [{"tag": tag} for tag in tags])

View File

@ -0,0 +1,186 @@
from typing import Optional
from loguru import logger
from app.db.errors import EntityDoesNotExist
from app.db.queries.queries import queries
from app.db.repositories.base import BaseRepository
from app.db.repositories.roles import RolesRepository
from app.models.domain.users import User, UserInDB
from app.core.config import get_app_settings
class UsersRepository(BaseRepository):
"""
User repository with helpers for both public auth flows and admin features.
"""
def __init__(self, conn) -> None:
super().__init__(conn)
self._roles_repo = RolesRepository(conn)
async def _attach_roles(self, user: Optional[UserInDB]) -> Optional[UserInDB]:
if user and getattr(user, "id", None):
if not user.roles:
# 兜底从 user_roles/roles 联查,确保 roles 填充
rows = await self.connection.fetch(
"""
SELECT r.name
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1
ORDER BY r.name
""",
user.id,
)
user.roles = [row["name"] for row in rows]
return user
async def get_user_by_email_optional(self, *, email: str) -> Optional[UserInDB]:
user_row = await queries.get_user_by_email(self.connection, email=email)
if not user_row:
return None
return await self._attach_roles(UserInDB(**user_row))
async def get_user_id_by_email(self, *, email: str) -> Optional[int]:
user_id = await self.connection.fetchval(
"SELECT id FROM users WHERE email = $1",
email,
)
return int(user_id) if user_id is not None else None
async def get_user_by_id(self, *, id_: int) -> UserInDB:
user_row = await queries.get_user_by_id(self.connection, id=id_)
if not user_row:
raise EntityDoesNotExist(f"user with id={id_} does not exist")
return await self._attach_roles(UserInDB(**user_row))
async def get_user_by_email(self, *, email: str) -> UserInDB:
user_row = await queries.get_user_by_email(self.connection, email=email)
if not user_row:
raise EntityDoesNotExist(f"user with email {email} does not exist")
return await self._attach_roles(UserInDB(**user_row))
async def get_user_by_username(self, *, username: str) -> UserInDB:
user_row = await queries.get_user_by_username(
self.connection,
username=username,
)
if not user_row:
raise EntityDoesNotExist(f"user with username {username} does not exist")
return await self._attach_roles(UserInDB(**user_row))
async def create_user(
self,
*,
username: str,
email: str,
password: str,
) -> UserInDB:
user = UserInDB(username=username, email=email)
user.change_password(password)
async with self.connection.transaction():
user_row = await queries.create_new_user(
self.connection,
username=user.username,
email=user.email,
salt=user.salt,
hashed_password=user.hashed_password,
)
created = user.copy(update=dict(user_row))
created.roles = []
return created
async def update_user( # noqa: WPS211
self,
*,
user: User,
username: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
bio: Optional[str] = None,
image: Optional[str] = None,
phone: Optional[str] = None,
user_type: Optional[str] = None,
company_name: Optional[str] = None,
) -> UserInDB:
user_in_db = await self.get_user_by_username(username=user.username)
user_in_db.username = username or user_in_db.username
user_in_db.email = email or user_in_db.email
user_in_db.bio = bio if bio is not None else user_in_db.bio
user_in_db.image = image if image is not None else user_in_db.image
user_in_db.phone = phone if phone is not None else user_in_db.phone
user_in_db.user_type = user_type if user_type is not None else user_in_db.user_type
user_in_db.company_name = company_name if company_name is not None else user_in_db.company_name
if password:
user_in_db.change_password(password)
async with self.connection.transaction():
user_in_db.updated_at = await queries.update_user_by_username(
self.connection,
username=user.username,
new_username=user_in_db.username,
new_email=user_in_db.email,
new_salt=user_in_db.salt,
new_password=user_in_db.hashed_password,
new_bio=user_in_db.bio,
new_image=user_in_db.image,
new_phone=user_in_db.phone,
new_user_type=user_in_db.user_type,
new_company_name=user_in_db.company_name,
)
return await self._attach_roles(user_in_db)
async def set_email_verified(self, *, email: str, verified: bool = True) -> None:
await queries.set_user_email_verified(
self.connection,
email=email,
verified=verified,
)
async def update_user_by_id( # noqa: WPS211
self,
*,
user_id: int,
username: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
bio: Optional[str] = None,
image: Optional[str] = None,
phone: Optional[str] = None,
user_type: Optional[str] = None,
company_name: Optional[str] = None,
) -> UserInDB:
user_in_db = await self.get_user_by_id(id_=user_id)
user_in_db.username = username or user_in_db.username
user_in_db.email = email or user_in_db.email
user_in_db.bio = bio if bio is not None else user_in_db.bio
user_in_db.image = image if image is not None else user_in_db.image
user_in_db.phone = phone if phone is not None else user_in_db.phone
user_in_db.user_type = user_type if user_type is not None else user_in_db.user_type
user_in_db.company_name = company_name if company_name is not None else user_in_db.company_name
if password:
user_in_db.change_password(password)
updated_row = await queries.admin_update_user_by_id(
self.connection,
id=user_id,
new_username=user_in_db.username,
new_email=user_in_db.email,
new_salt=user_in_db.salt,
new_password=user_in_db.hashed_password,
new_bio=user_in_db.bio,
new_image=user_in_db.image,
new_phone=user_in_db.phone,
new_user_type=user_in_db.user_type,
new_company_name=user_in_db.company_name,
)
return await self._attach_roles(UserInDB(**updated_row))
async def delete_user_by_id(self, *, user_id: int) -> None:
await queries.admin_delete_user(self.connection, id=user_id)

58
backend/app/main.py Normal file
View File

@ -0,0 +1,58 @@
# app/main.py (或你当前这个文件名)
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.staticfiles import StaticFiles # ✅ 新增:静态文件
from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware
from app.api.errors.http_error import http_error_handler
from app.api.errors.validation_error import http422_error_handler
from app.api.routes.api import router as api_router
from app.core.config import get_app_settings
from app.core.events import create_start_app_handler, create_stop_app_handler
def get_application() -> FastAPI:
settings = get_app_settings()
settings.configure_logging()
application = FastAPI(**settings.fastapi_kwargs)
application.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origin_regex=".*",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
application.add_event_handler(
"startup",
create_start_app_handler(application, settings),
)
application.add_event_handler(
"shutdown",
create_stop_app_handler(application),
)
application.add_exception_handler(HTTPException, http_error_handler)
application.add_exception_handler(RequestValidationError, http422_error_handler)
# 所有业务 API 挂在 /api 前缀下(上传接口也在这里:/api/upload-image
application.include_router(api_router, prefix=settings.api_prefix)
# ✅ 静态资源:让 /static/... 可直接访问(封面、正文图片等)
application.mount(
"/static",
StaticFiles(directory="static"),
name="static",
)
return application
app = get_application()

View File

View File

@ -0,0 +1,19 @@
import datetime
from pydantic import BaseModel, Field, validator
class DateTimeModelMixin(BaseModel):
created_at: datetime.datetime = None # type: ignore
updated_at: datetime.datetime = None # type: ignore
@validator("created_at", "updated_at", pre=True)
def default_datetime(
cls, # noqa: N805
value: datetime.datetime, # noqa: WPS110
) -> datetime.datetime:
return value or datetime.datetime.now()
class IDModelMixin(BaseModel):
id_: int = Field(0, alias="id")

View File

View File

@ -0,0 +1,27 @@
# app/models/domain/articles.py
from typing import List, Optional
from app.models.common import DateTimeModelMixin, IDModelMixin
from app.models.domain.profiles import Profile
from app.models.domain.rwmodel import RWModel
class Article(IDModelMixin, DateTimeModelMixin, RWModel):
slug: str
title: str
description: str
body: str
# 封面(可选,不影响老数据)
cover: Optional[str] = None
# 置顶 / 推荐 / 权重camelCase 输出)
is_top: bool = False
is_featured: bool = False
sort_weight: int = 0
tags: List[str]
author: Profile
favorited: bool
favorites_count: int
views: int = 0

View File

@ -0,0 +1,8 @@
from app.models.common import DateTimeModelMixin, IDModelMixin
from app.models.domain.profiles import Profile
from app.models.domain.rwmodel import RWModel
class Comment(IDModelMixin, DateTimeModelMixin, RWModel):
body: str
author: Profile

View File

@ -0,0 +1,10 @@
from typing import Optional
from app.models.domain.rwmodel import RWModel
class Profile(RWModel):
username: str
bio: str = ""
image: Optional[str] = None
following: bool = False

View File

@ -0,0 +1,10 @@
from typing import List
from app.models.common import DateTimeModelMixin, IDModelMixin
from app.models.domain.rwmodel import RWModel
class Role(IDModelMixin, DateTimeModelMixin, RWModel):
name: str
description: str = ""
permissions: List[str] = []

View File

@ -0,0 +1,21 @@
import datetime
from pydantic import BaseConfig, BaseModel
def convert_datetime_to_realworld(dt: datetime.datetime) -> str:
return dt.replace(tzinfo=datetime.timezone.utc).isoformat().replace("+00:00", "Z")
def convert_field_to_camel_case(string: str) -> str:
return "".join(
word if index == 0 else word.capitalize()
for index, word in enumerate(string.split("_"))
)
class RWModel(BaseModel):
class Config(BaseConfig):
allow_population_by_field_name = True
json_encoders = {datetime.datetime: convert_datetime_to_realworld}
alias_generator = convert_field_to_camel_case

View File

@ -0,0 +1,39 @@
# app/models/domain/users.py
from typing import List, Optional
from pydantic import Field
from app.models.common import DateTimeModelMixin, IDModelMixin
from app.models.domain.rwmodel import RWModel
from app.services import security
class User(RWModel):
"""
公开用户信息会被用于文章作者Profile 等场景
新增 email_verified 字段便于前端展示与登录后逻辑判断
"""
username: str
email: str
bio: str = ""
image: Optional[str] = None
phone: Optional[str] = None
user_type: Optional[str] = None
company_name: Optional[str] = None
email_verified: bool = False
roles: List[str] = Field(default_factory=list)
class UserInDB(IDModelMixin, DateTimeModelMixin, User):
"""
数据库存储模型私有字段
"""
salt: str = ""
hashed_password: str = ""
def check_password(self, password: str) -> bool:
return security.verify_password(self.salt + password, self.hashed_password)
def change_password(self, password: str) -> None:
self.salt = security.generate_salt()
self.hashed_password = security.get_password_hash(self.salt + password)

View File

View File

@ -0,0 +1,88 @@
from datetime import datetime
from typing import List, Optional
from pydantic import EmailStr, Field
from app.models.schemas.rwschema import RWSchema
class AdminRoleLite(RWSchema):
id: int
name: str
description: str = ""
permissions: List[str] = Field(default_factory=list)
class AdminUserSummary(RWSchema):
id: int
username: str
email: EmailStr
bio: Optional[str] = None
image: Optional[str] = None
roles: List[AdminRoleLite] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class AdminUserCreate(RWSchema):
username: str
email: EmailStr
password: str = Field(min_length=6, max_length=64)
bio: Optional[str] = None
image: Optional[str] = None
role_ids: List[int] = Field(default_factory=list)
class AdminUserUpdate(RWSchema):
username: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = Field(default=None, min_length=6, max_length=64)
bio: Optional[str] = None
image: Optional[str] = None
role_ids: Optional[List[int]] = None
class AdminUserResponse(RWSchema):
user: AdminUserSummary
class AdminUserListResponse(RWSchema):
users: List[AdminUserSummary]
total: int
class AdminDashboardStats(RWSchema):
users: int
roles: int
articles: int
total_views: int
published_today: int
class AdminMenuSlot(RWSchema):
slot_key: str = Field(..., alias="slotKey")
label: str
tags: List[str] = Field(default_factory=list)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class AdminMenuSlotUpdate(RWSchema):
tags: List[str] = Field(default_factory=list)
label: Optional[str] = None
class AdminMenuSlotResponse(RWSchema):
slot: AdminMenuSlot
class AdminMenuSlotListResponse(RWSchema):
slots: List[AdminMenuSlot] = Field(default_factory=list)
class AdminHomeFeaturedItem(RWSchema):
slug: str
class AdminHomeFeaturedUpdate(RWSchema):
articles: List[AdminHomeFeaturedItem] = Field(default_factory=list)

View File

@ -0,0 +1,75 @@
from typing import List, Optional
from pydantic import BaseModel, Field
from app.models.domain.articles import Article
from app.models.schemas.rwschema import RWSchema
DEFAULT_ARTICLES_LIMIT = 20
DEFAULT_ARTICLES_OFFSET = 0
class ArticleForResponse(RWSchema, Article):
"""
返回给前端的文章结构
- 继承 Article包含 covertagsauthor
- tags 字段通过 alias 暴露为 tagList兼容前端
"""
tags: List[str] = Field(..., alias="tagList")
class ArticleInResponse(RWSchema):
article: ArticleForResponse
class ArticleInCreate(RWSchema):
"""
创建文章时请求体
{
"article": {
"title": "...",
"description": "...",
"body": "...",
"tagList": ["..."],
"cover": "可选封面URL"
}
}
"""
title: str
description: str
body: str
tags: List[str] = Field([], alias="tagList")
cover: Optional[str] = None
class ArticleInUpdate(RWSchema):
"""
更新文章时请求体全部可选
- 不传的字段不改
- cover:
- 不传不改
- null / ""清空封面配合 repo cover_provided 使用
- 传字符串更新为新封面
"""
title: Optional[str] = None
description: Optional[str] = None
body: Optional[str] = None
cover: Optional[str] = None
is_top: Optional[bool] = None
is_featured: Optional[bool] = None
sort_weight: Optional[int] = None
class ListOfArticlesInResponse(RWSchema):
articles: List[ArticleForResponse]
articles_count: int
class ArticlesFilters(BaseModel):
tag: Optional[str] = None
tags: Optional[List[str]] = None
author: Optional[str] = None
favorited: Optional[str] = None
search: Optional[str] = None
limit: int = Field(DEFAULT_ARTICLES_LIMIT, ge=1)
offset: int = Field(DEFAULT_ARTICLES_OFFSET, ge=0)

View File

@ -0,0 +1,16 @@
from typing import List
from app.models.domain.comments import Comment
from app.models.schemas.rwschema import RWSchema
class ListOfCommentsInResponse(RWSchema):
comments: List[Comment]
class CommentInResponse(RWSchema):
comment: Comment
class CommentInCreate(RWSchema):
body: str

View File

@ -0,0 +1,18 @@
# app/models/schemas/email_code.py
from enum import Enum
from pydantic import BaseModel, EmailStr
class EmailScene(str, Enum):
register = "register"
reset = "reset"
login = "login"
class EmailCodeSendIn(BaseModel):
email: EmailStr
scene: EmailScene = EmailScene.register
class EmailCodeSendOut(BaseModel):
ok: bool = True

View File

@ -0,0 +1,12 @@
from datetime import datetime
from pydantic import BaseModel
class JWTMeta(BaseModel):
exp: datetime
sub: str
class JWTUser(BaseModel):
username: str

View File

@ -0,0 +1,7 @@
from pydantic import BaseModel
from app.models.domain.profiles import Profile
class ProfileInResponse(BaseModel):
profile: Profile

Some files were not shown because too many files have changed in this diff Show More