first commit
This commit is contained in:
commit
1f3eeb9193
10
_tmp_fix.py
Normal file
10
_tmp_fix.py
Normal 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
21
backend/.dockerignore
Normal 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
20
backend/.env.example
Normal 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
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
20
backend/.github/dependabot.yml
vendored
Normal 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
68
backend/.github/workflows/conduit.yml
vendored
Normal 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
26
backend/.github/workflows/deploy.yml
vendored
Normal 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
50
backend/.github/workflows/styles.yml
vendored
Normal 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
70
backend/.github/workflows/tests.yml
vendored
Normal 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
110
backend/.gitignore
vendored
Normal 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
21
backend/Dockerfile
Normal 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
21
backend/LICENSE
Normal 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
157
backend/README.rst
Normal 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
36
backend/alembic.ini
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/dependencies/__init__.py
Normal file
0
backend/app/api/dependencies/__init__.py
Normal file
70
backend/app/api/dependencies/admin.py
Normal file
70
backend/app/api/dependencies/admin.py
Normal 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",
|
||||||
|
)
|
||||||
67
backend/app/api/dependencies/articles.py
Normal file
67
backend/app/api/dependencies/articles.py
Normal 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,
|
||||||
|
)
|
||||||
123
backend/app/api/dependencies/authentication.py
Normal file
123
backend/app/api/dependencies/authentication.py
Normal 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
|
||||||
47
backend/app/api/dependencies/comments.py
Normal file
47
backend/app/api/dependencies/comments.py
Normal 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,
|
||||||
|
)
|
||||||
45
backend/app/api/dependencies/database.py
Normal file
45
backend/app/api/dependencies/database.py
Normal 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
|
||||||
29
backend/app/api/dependencies/profiles.py
Normal file
29
backend/app/api/dependencies/profiles.py
Normal 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,
|
||||||
|
)
|
||||||
0
backend/app/api/errors/__init__.py
Normal file
0
backend/app/api/errors/__init__.py
Normal file
7
backend/app/api/errors/http_error.py
Normal file
7
backend/app/api/errors/http_error.py
Normal 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)
|
||||||
28
backend/app/api/errors/validation_error.py
Normal file
28
backend/app/api/errors/validation_error.py
Normal 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)},
|
||||||
|
},
|
||||||
|
}
|
||||||
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
418
backend/app/api/routes/admin.py
Normal file
418
backend/app/api/routes/admin.py
Normal 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)
|
||||||
50
backend/app/api/routes/api.py
Normal file
50
backend/app/api/routes/api.py
Normal 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)
|
||||||
0
backend/app/api/routes/articles/__init__.py
Normal file
0
backend/app/api/routes/articles/__init__.py
Normal file
9
backend/app/api/routes/articles/api.py
Normal file
9
backend/app/api/routes/articles/api.py
Normal 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")
|
||||||
105
backend/app/api/routes/articles/articles_common.py
Normal file
105
backend/app/api/routes/articles/articles_common.py
Normal 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,
|
||||||
|
)
|
||||||
220
backend/app/api/routes/articles/articles_resource.py
Normal file
220
backend/app/api/routes/articles/articles_resource.py
Normal 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)
|
||||||
321
backend/app/api/routes/authentication.py
Normal file
321
backend/app/api/routes/authentication.py
Normal 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}
|
||||||
71
backend/app/api/routes/comments.py
Normal file
71
backend/app/api/routes/comments.py
Normal 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)
|
||||||
37
backend/app/api/routes/home_featured.py
Normal file
37
backend/app/api/routes/home_featured.py
Normal 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),
|
||||||
|
)
|
||||||
47
backend/app/api/routes/password_reset.py
Normal file
47
backend/app/api/routes/password_reset.py
Normal 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}
|
||||||
84
backend/app/api/routes/profiles.py
Normal file
84
backend/app/api/routes/profiles.py
Normal 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}))
|
||||||
15
backend/app/api/routes/tags.py
Normal file
15
backend/app/api/routes/tags.py
Normal 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)
|
||||||
35
backend/app/api/routes/uploads.py
Normal file
35
backend/app/api/routes/uploads.py
Normal 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}
|
||||||
82
backend/app/api/routes/users.py
Normal file
82
backend/app/api/routes/users.py
Normal 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", []),
|
||||||
|
),
|
||||||
|
)
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
21
backend/app/core/config.py
Normal file
21
backend/app/core/config.py
Normal 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()
|
||||||
25
backend/app/core/events.py
Normal file
25
backend/app/core/events.py
Normal 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
|
||||||
25
backend/app/core/logging.py
Normal file
25
backend/app/core/logging.py
Normal 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(),
|
||||||
|
)
|
||||||
0
backend/app/core/settings/__init__.py
Normal file
0
backend/app/core/settings/__init__.py
Normal file
77
backend/app/core/settings/app.py
Normal file
77
backend/app/core/settings/app.py
Normal 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}])
|
||||||
16
backend/app/core/settings/base.py
Normal file
16
backend/app/core/settings/base.py
Normal 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"
|
||||||
14
backend/app/core/settings/development.py
Normal file
14
backend/app/core/settings/development.py
Normal 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"
|
||||||
8
backend/app/core/settings/production.py
Normal file
8
backend/app/core/settings/production.py
Normal 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"
|
||||||
19
backend/app/core/settings/test.py
Normal file
19
backend/app/core/settings/test.py
Normal 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
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
2
backend/app/db/errors.py
Normal file
2
backend/app/db/errors.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
class EntityDoesNotExist(Exception):
|
||||||
|
"""Raised when entity was not found in database."""
|
||||||
25
backend/app/db/events.py
Normal file
25
backend/app/db/events.py
Normal 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")
|
||||||
38
backend/app/db/migrations/env.py
Normal file
38
backend/app/db/migrations/env.py
Normal 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()
|
||||||
23
backend/app/db/migrations/script.py.mako
Normal file
23
backend/app/db/migrations/script.py.mako
Normal 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"}
|
||||||
@ -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")
|
||||||
120
backend/app/db/migrations/versions/20251122_add_roles_tables.py
Normal file
120
backend/app/db/migrations/versions/20251122_add_roles_tables.py
Normal 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")
|
||||||
216
backend/app/db/migrations/versions/fdf8821871d7_main_tables.py
Normal file
216
backend/app/db/migrations/versions/fdf8821871d7_main_tables.py
Normal 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")
|
||||||
0
backend/app/db/queries/__init__.py
Normal file
0
backend/app/db/queries/__init__.py
Normal file
16
backend/app/db/queries/queries.py
Normal file
16
backend/app/db/queries/queries.py
Normal 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_path(from_path 会按系统默认编码读取)
|
||||||
|
queries = aiosql.from_str(_load_all_sql_text_utf8(), driver_adapter="asyncpg")
|
||||||
140
backend/app/db/queries/queries.pyi
Normal file
140
backend/app/db/queries/queries.pyi
Normal 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
|
||||||
116
backend/app/db/queries/sql/articles.sql
Normal file
116
backend/app/db/queries/sql/articles.sql
Normal 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;
|
||||||
40
backend/app/db/queries/sql/comments.sql
Normal file
40
backend/app/db/queries/sql/comments.sql
Normal 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);
|
||||||
38
backend/app/db/queries/sql/profiles.sql
Normal file
38
backend/app/db/queries/sql/profiles.sql
Normal 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);
|
||||||
184
backend/app/db/queries/sql/queries.sql
Normal file
184
backend/app/db/queries/sql/queries.sql
Normal 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;
|
||||||
86
backend/app/db/queries/sql/roles.sql
Normal file
86
backend/app/db/queries/sql/roles.sql
Normal 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;
|
||||||
9
backend/app/db/queries/sql/tags.sql
Normal file
9
backend/app/db/queries/sql/tags.sql
Normal 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;
|
||||||
119
backend/app/db/queries/sql/users.sql
Normal file
119
backend/app/db/queries/sql/users.sql
Normal 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;
|
||||||
75
backend/app/db/queries/tables.py
Normal file
75
backend/app/db/queries/tables.py
Normal 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()
|
||||||
0
backend/app/db/repositories/__init__.py
Normal file
0
backend/app/db/repositories/__init__.py
Normal file
161
backend/app/db/repositories/admin.py
Normal file
161
backend/app/db/repositories/admin.py
Normal 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),
|
||||||
|
)
|
||||||
624
backend/app/db/repositories/articles.py
Normal file
624
backend/app/db/repositories/articles.py
Normal 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)
|
||||||
10
backend/app/db/repositories/base.py
Normal file
10
backend/app/db/repositories/base.py
Normal 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
|
||||||
103
backend/app/db/repositories/comments.py
Normal file
103
backend/app/db/repositories/comments.py
Normal 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"],
|
||||||
|
)
|
||||||
68
backend/app/db/repositories/email_codes.py
Normal file
68
backend/app/db/repositories/email_codes.py
Normal 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
|
||||||
82
backend/app/db/repositories/home_featured.py
Normal file
82
backend/app/db/repositories/home_featured.py
Normal 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
|
||||||
90
backend/app/db/repositories/menu_slots.py
Normal file
90
backend/app/db/repositories/menu_slots.py
Normal 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,
|
||||||
|
)
|
||||||
61
backend/app/db/repositories/password_reset.py
Normal file
61
backend/app/db/repositories/password_reset.py
Normal 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)
|
||||||
74
backend/app/db/repositories/profiles.py
Normal file
74
backend/app/db/repositories/profiles.py
Normal 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,
|
||||||
|
)
|
||||||
143
backend/app/db/repositories/roles.py
Normal file
143
backend/app/db/repositories/roles.py
Normal 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"))
|
||||||
13
backend/app/db/repositories/tags.py
Normal file
13
backend/app/db/repositories/tags.py
Normal 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])
|
||||||
186
backend/app/db/repositories/users.py
Normal file
186
backend/app/db/repositories/users.py
Normal 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
58
backend/app/main.py
Normal 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()
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
19
backend/app/models/common.py
Normal file
19
backend/app/models/common.py
Normal 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")
|
||||||
0
backend/app/models/domain/__init__.py
Normal file
0
backend/app/models/domain/__init__.py
Normal file
27
backend/app/models/domain/articles.py
Normal file
27
backend/app/models/domain/articles.py
Normal 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
|
||||||
8
backend/app/models/domain/comments.py
Normal file
8
backend/app/models/domain/comments.py
Normal 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
|
||||||
10
backend/app/models/domain/profiles.py
Normal file
10
backend/app/models/domain/profiles.py
Normal 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
|
||||||
10
backend/app/models/domain/roles.py
Normal file
10
backend/app/models/domain/roles.py
Normal 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] = []
|
||||||
21
backend/app/models/domain/rwmodel.py
Normal file
21
backend/app/models/domain/rwmodel.py
Normal 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
|
||||||
39
backend/app/models/domain/users.py
Normal file
39
backend/app/models/domain/users.py
Normal 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)
|
||||||
0
backend/app/models/schemas/__init__.py
Normal file
0
backend/app/models/schemas/__init__.py
Normal file
88
backend/app/models/schemas/admin.py
Normal file
88
backend/app/models/schemas/admin.py
Normal 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)
|
||||||
75
backend/app/models/schemas/articles.py
Normal file
75
backend/app/models/schemas/articles.py
Normal 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(包含 cover、tags、author 等)
|
||||||
|
- 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)
|
||||||
16
backend/app/models/schemas/comments.py
Normal file
16
backend/app/models/schemas/comments.py
Normal 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
|
||||||
18
backend/app/models/schemas/email_code.py
Normal file
18
backend/app/models/schemas/email_code.py
Normal 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
|
||||||
12
backend/app/models/schemas/jwt.py
Normal file
12
backend/app/models/schemas/jwt.py
Normal 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
|
||||||
7
backend/app/models/schemas/profiles.py
Normal file
7
backend/app/models/schemas/profiles.py
Normal 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
Loading…
x
Reference in New Issue
Block a user