Initial project commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.idea
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
uploads
|
||||
@@ -0,0 +1,22 @@
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/ciyuan_viewfinder
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/ciyuan_viewfinder
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
SECRET_KEY=change-me-to-a-random-secret-key
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Storage: "local" or "s3" (s3 compatible with MinIO/OSS/COS)
|
||||
STORAGE_BACKEND=local
|
||||
LOCAL_STORAGE_PATH=./uploads
|
||||
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=ciyuan-viewfinder
|
||||
S3_REGION=
|
||||
S3_PUBLIC_URL=
|
||||
|
||||
# Tencent Maps API Key (apply at https://lbs.qq.com/)
|
||||
TENCENT_MAP_KEY=
|
||||
|
||||
SENTRY_DSN=
|
||||
@@ -0,0 +1,50 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Python tooling
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pyre/
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Runtime data
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
@@ -0,0 +1 @@
|
||||
3.10
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,6 @@
|
||||
# 后端默认管理员账号
|
||||
|
||||
- 管理后台登录账号(手机号):`13900000001`
|
||||
- 管理后台登录密码:`demo123456`
|
||||
|
||||
> 说明:以上为 `seed_demo_data.py` 初始化的演示管理员账号(role=`admin`)。
|
||||
@@ -0,0 +1,43 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
# sqlalchemy.url is overridden in env.py from app settings
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[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,0 +1,57 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
import app.models # noqa: F401 – ensure all models are registered
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-03-27
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from geoalchemy2 import Geometry
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
|
||||
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("phone", sa.String(20), unique=True, nullable=True),
|
||||
sa.Column("email", sa.String(255), unique=True, nullable=True),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("nickname", sa.String(50), nullable=False),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("city", sa.String(100), nullable=True),
|
||||
sa.Column("identity", sa.String(20), server_default="both"),
|
||||
sa.Column("role", sa.String(20), server_default="user"),
|
||||
sa.Column("is_active", sa.Boolean, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"spots",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("title", sa.String(200), nullable=False, index=True),
|
||||
sa.Column("city", sa.String(100), nullable=False, index=True),
|
||||
sa.Column("location", Geometry("POINT", srid=4326), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("transport", sa.Text, nullable=True),
|
||||
sa.Column("best_time", sa.String(200), nullable=True),
|
||||
sa.Column("difficulty", sa.Text, nullable=True),
|
||||
sa.Column("audit_status", sa.String(20), server_default="pending"),
|
||||
sa.Column("reject_reason", sa.String(500), nullable=True),
|
||||
sa.Column("creator_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"spot_images",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("spot_id", sa.Integer, sa.ForeignKey("spots.id"), nullable=False),
|
||||
sa.Column("image_url", sa.String(500), nullable=False),
|
||||
sa.Column("is_cover", sa.Boolean, server_default=sa.text("false")),
|
||||
sa.Column("audit_status", sa.String(20), server_default="pending"),
|
||||
sa.Column("sort_order", sa.Integer, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"favorites",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("spot_id", sa.Integer, sa.ForeignKey("spots.id"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "spot_id", name="uq_user_spot"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"point_ledger",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("change", sa.Integer, nullable=False),
|
||||
sa.Column("balance", sa.Integer, nullable=False),
|
||||
sa.Column("reason", sa.String(200), nullable=False),
|
||||
sa.Column("ref_type", sa.String(50), nullable=True),
|
||||
sa.Column("ref_id", sa.Integer, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("point_ledger")
|
||||
op.drop_table("favorites")
|
||||
op.drop_table("spot_images")
|
||||
op.drop_table("spots")
|
||||
op.drop_table("users")
|
||||
op.execute("DROP EXTENSION IF EXISTS postgis")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add audit_logs table
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-03-27
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("operator_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("action", sa.String(100), nullable=False, index=True),
|
||||
sa.Column("target_type", sa.String(50), nullable=False),
|
||||
sa.Column("target_id", sa.Integer, nullable=True),
|
||||
sa.Column("detail", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("audit_logs")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""phase2 community: comments, reports, ratings, tags, spot_tags, spot rating columns
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-03-27
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: Union[str, None] = "0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("spots", sa.Column("avg_rating", sa.Float, nullable=True))
|
||||
op.add_column("spots", sa.Column("rating_count", sa.Integer, server_default="0", nullable=False))
|
||||
|
||||
op.create_table(
|
||||
"comments",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("spot_id", sa.Integer, sa.ForeignKey("spots.id"), nullable=False, index=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer, sa.ForeignKey("comments.id"), nullable=True),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("audit_status", sa.String(20), server_default="approved"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"reports",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("reporter_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("target_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column("target_id", sa.Integer, nullable=False),
|
||||
sa.Column("reason", sa.Text, nullable=False),
|
||||
sa.Column("status", sa.String(20), server_default="pending"),
|
||||
sa.Column("handler_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("conclusion", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ratings",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("spot_id", sa.Integer, sa.ForeignKey("spots.id"), nullable=False, index=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("score", sa.Integer, nullable=False),
|
||||
sa.Column("short_comment", sa.String(200), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "spot_id", name="uq_user_spot_rating"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(50), unique=True, nullable=False),
|
||||
sa.Column("category", sa.String(50), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true"),
|
||||
sa.Column("usage_count", sa.Integer, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"spot_tags",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("spot_id", sa.Integer, sa.ForeignKey("spots.id"), nullable=False),
|
||||
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("spot_id", "tag_id", name="uq_spot_tag"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("spot_tags")
|
||||
op.drop_table("tags")
|
||||
op.drop_table("ratings")
|
||||
op.drop_table("reports")
|
||||
op.drop_table("comments")
|
||||
op.drop_column("spots", "rating_count")
|
||||
op.drop_column("spots", "avg_rating")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""add is_free and price to spots
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("spots", sa.Column("is_free", sa.Boolean(), server_default=sa.text("true"), nullable=False))
|
||||
op.add_column("spots", sa.Column("price", sa.Numeric(10, 2), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("spots", "price")
|
||||
op.drop_column("spots", "is_free")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""replace price with price_min and price_max
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column("spots", "price", new_column_name="price_min")
|
||||
op.add_column("spots", sa.Column("price_max", sa.Numeric(10, 2), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("spots", "price_max")
|
||||
op.alter_column("spots", "price_min", new_column_name="price")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""add bio to users
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0006"
|
||||
down_revision = "0005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("bio", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "bio")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add corrections table
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-03-29
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0007"
|
||||
down_revision = "0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"corrections",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("spot_id", sa.Integer(), sa.ForeignKey("spots.id"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("field_name", sa.String(50), nullable=False),
|
||||
sa.Column("suggested_value", sa.Text(), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default="pending", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_corrections_spot_id", "corrections", ["spot_id"])
|
||||
op.create_index("ix_corrections_status", "corrections", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_corrections_status", "corrections")
|
||||
op.drop_index("ix_corrections_spot_id", "corrections")
|
||||
op.drop_table("corrections")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add_favorite_count_and_notifications
|
||||
|
||||
Revision ID: 3b977a379456
|
||||
Revises: 0007
|
||||
Create Date: 2026-03-31 12:30:46.524068
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '3b977a379456'
|
||||
down_revision: Union[str, None] = '0007'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('notifications',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(length=50), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=True),
|
||||
sa.Column('ref_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('ref_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notifications_type'), 'notifications', ['type'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
|
||||
op.add_column('spots', sa.Column('favorite_count', sa.Integer(), nullable=False, server_default=sa.text('0')))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('spots', 'favorite_count')
|
||||
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_type'), table_name='notifications')
|
||||
op.drop_table('notifications')
|
||||
@@ -0,0 +1,69 @@
|
||||
"""add_shooting_system
|
||||
|
||||
Revision ID: 7bf40aa6c4b5
|
||||
Revises: 3b977a379456
|
||||
Create Date: 2026-03-31 12:41:00.407691
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '7bf40aa6c4b5'
|
||||
down_revision: Union[str, None] = '3b977a379456'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('shooting_requests',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('creator_id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('city', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('style', sa.String(length=100), nullable=True),
|
||||
sa.Column('shoot_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_free', sa.Boolean(), nullable=False),
|
||||
sa.Column('budget_min', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('budget_max', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('role_needed', sa.String(length=20), nullable=False),
|
||||
sa.Column('max_applicants', sa.Integer(), nullable=False),
|
||||
sa.Column('contact_info', sa.String(length=200), nullable=True),
|
||||
sa.Column('spot_id', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('audit_status', sa.String(length=20), nullable=False),
|
||||
sa.Column('reject_reason', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['spot_id'], ['spots.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_shooting_requests_city'), 'shooting_requests', ['city'], unique=False)
|
||||
op.create_index(op.f('ix_shooting_requests_creator_id'), 'shooting_requests', ['creator_id'], unique=False)
|
||||
op.create_index(op.f('ix_shooting_requests_status'), 'shooting_requests', ['status'], unique=False)
|
||||
op.create_table('shooting_applications',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('request_id', sa.Integer(), nullable=False),
|
||||
sa.Column('applicant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['applicant_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['request_id'], ['shooting_requests.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_shooting_applications_applicant_id'), 'shooting_applications', ['applicant_id'], unique=False)
|
||||
op.create_index(op.f('ix_shooting_applications_request_id'), 'shooting_applications', ['request_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_shooting_applications_request_id'), table_name='shooting_applications')
|
||||
op.drop_index(op.f('ix_shooting_applications_applicant_id'), table_name='shooting_applications')
|
||||
op.drop_table('shooting_applications')
|
||||
op.drop_index(op.f('ix_shooting_requests_status'), table_name='shooting_requests')
|
||||
op.drop_index(op.f('ix_shooting_requests_creator_id'), table_name='shooting_requests')
|
||||
op.drop_index(op.f('ix_shooting_requests_city'), table_name='shooting_requests')
|
||||
op.drop_table('shooting_requests')
|
||||
@@ -0,0 +1,82 @@
|
||||
"""add_event_system
|
||||
|
||||
Revision ID: a35876e08b8e
|
||||
Revises: 7bf40aa6c4b5
|
||||
Create Date: 2026-03-31 12:51:29.923126
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'a35876e08b8e'
|
||||
down_revision: Union[str, None] = '7bf40aa6c4b5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('events',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('creator_id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('city', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('cover_url', sa.String(length=500), nullable=True),
|
||||
sa.Column('location_name', sa.String(length=200), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('max_participants', sa.Integer(), nullable=False),
|
||||
sa.Column('spot_id', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('audit_status', sa.String(length=20), nullable=False),
|
||||
sa.Column('reject_reason', sa.String(length=500), nullable=True),
|
||||
sa.Column('registration_count', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['spot_id'], ['spots.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_events_city'), 'events', ['city'], unique=False)
|
||||
op.create_index(op.f('ix_events_creator_id'), 'events', ['creator_id'], unique=False)
|
||||
op.create_index(op.f('ix_events_status'), 'events', ['status'], unique=False)
|
||||
op.create_table('event_photos',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||
sa.Column('uploader_id', sa.Integer(), nullable=False),
|
||||
sa.Column('image_url', sa.String(length=500), nullable=False),
|
||||
sa.Column('caption', sa.String(length=200), nullable=True),
|
||||
sa.Column('spot_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
|
||||
sa.ForeignKeyConstraint(['spot_id'], ['spots.id'], ),
|
||||
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_event_photos_event_id'), 'event_photos', ['event_id'], unique=False)
|
||||
op.create_table('event_registrations',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_event_registrations_event_id'), 'event_registrations', ['event_id'], unique=False)
|
||||
op.create_index(op.f('ix_event_registrations_user_id'), 'event_registrations', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_event_registrations_user_id'), table_name='event_registrations')
|
||||
op.drop_index(op.f('ix_event_registrations_event_id'), table_name='event_registrations')
|
||||
op.drop_table('event_registrations')
|
||||
op.drop_index(op.f('ix_event_photos_event_id'), table_name='event_photos')
|
||||
op.drop_table('event_photos')
|
||||
op.drop_index(op.f('ix_events_status'), table_name='events')
|
||||
op.drop_index(op.f('ix_events_creator_id'), table_name='events')
|
||||
op.drop_index(op.f('ix_events_city'), table_name='events')
|
||||
op.drop_table('events')
|
||||
@@ -0,0 +1,74 @@
|
||||
"""add_commercial_system
|
||||
|
||||
Revision ID: c28508a3a8d4
|
||||
Revises: a35876e08b8e
|
||||
Create Date: 2026-03-31 15:01:24.482893
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'c28508a3a8d4'
|
||||
down_revision: Union[str, None] = 'a35876e08b8e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('membership_plans',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('duration_days', sa.Integer(), nullable=False),
|
||||
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('benefits', sa.Text(), nullable=True),
|
||||
sa.Column('extra_uploads', sa.Integer(), nullable=False),
|
||||
sa.Column('extra_top_count', sa.Integer(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('promotions',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('image_url', sa.String(length=500), nullable=False),
|
||||
sa.Column('link_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('link_id', sa.Integer(), nullable=True),
|
||||
sa.Column('link_url', sa.String(length=500), nullable=True),
|
||||
sa.Column('position', sa.String(length=30), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('start_time', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('end_time', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('impressions', sa.Integer(), nullable=False),
|
||||
sa.Column('clicks', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_promotions_is_active'), 'promotions', ['is_active'], unique=False)
|
||||
op.create_index(op.f('ix_promotions_position'), 'promotions', ['position'], unique=False)
|
||||
op.create_table('user_memberships',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('plan_id', sa.Integer(), nullable=False),
|
||||
sa.Column('start_date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('end_date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['plan_id'], ['membership_plans.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_user_memberships_user_id'), 'user_memberships', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_user_memberships_user_id'), table_name='user_memberships')
|
||||
op.drop_table('user_memberships')
|
||||
op.drop_index(op.f('ix_promotions_position'), table_name='promotions')
|
||||
op.drop_index(op.f('ix_promotions_is_active'), table_name='promotions')
|
||||
op.drop_table('promotions')
|
||||
op.drop_table('membership_plans')
|
||||
@@ -0,0 +1,93 @@
|
||||
"""add_app_nav_config_and_promotion_refs
|
||||
|
||||
Revision ID: e9f8b1234cde
|
||||
Revises: c28508a3a8d4
|
||||
Create Date: 2026-04-12 16:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "e9f8b1234cde"
|
||||
down_revision: Union[str, None] = "c28508a3a8d4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("promotions", sa.Column("spot_id", sa.Integer(), nullable=True))
|
||||
op.add_column("promotions", sa.Column("event_id", sa.Integer(), nullable=True))
|
||||
op.add_column("promotions", sa.Column("shooting_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key("fk_promotions_spot_id", "promotions", "spots", ["spot_id"], ["id"])
|
||||
op.create_foreign_key("fk_promotions_event_id", "promotions", "events", ["event_id"], ["id"])
|
||||
op.create_foreign_key("fk_promotions_shooting_id", "promotions", "shooting_requests", ["shooting_id"], ["id"])
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE promotions
|
||||
SET spot_id = link_id
|
||||
WHERE link_type = 'spot' AND link_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE promotions
|
||||
SET event_id = link_id
|
||||
WHERE link_type = 'event' AND link_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE promotions
|
||||
SET shooting_id = link_id
|
||||
WHERE link_type = 'shooting' AND link_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"app_nav_configs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("key", sa.String(length=50), nullable=False),
|
||||
sa.Column("label", sa.String(length=50), nullable=False),
|
||||
sa.Column("page_path", sa.String(length=200), nullable=False),
|
||||
sa.Column("icon", sa.String(length=50), nullable=False),
|
||||
sa.Column("active_icon", sa.String(length=50), nullable=False),
|
||||
sa.Column("color", sa.String(length=20), nullable=False, server_default="#999999"),
|
||||
sa.Column("active_color", sa.String(length=20), nullable=False, server_default="#6366f1"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key"),
|
||||
)
|
||||
op.create_index(op.f("ix_app_nav_configs_is_active"), "app_nav_configs", ["is_active"], unique=False)
|
||||
op.create_index(op.f("ix_app_nav_configs_key"), "app_nav_configs", ["key"], unique=True)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO app_nav_configs (key, label, page_path, icon, active_icon, color, active_color, is_active, sort_order)
|
||||
VALUES
|
||||
('discover', '发现', 'pages/index/index', 'compass', 'compass-filled', '#999999', '#6366f1', true, 1),
|
||||
('activity', '活动', 'pages/activity/index', 'calendar', 'calendar-filled', '#999999', '#6366f1', true, 2),
|
||||
('upload', '投稿', 'pages/spot/create', 'plusempty', 'plusempty', '#999999', '#6366f1', true, 3),
|
||||
('message', '消息', 'pages/mine/notifications', 'chat', 'chat-filled', '#999999', '#6366f1', true, 4),
|
||||
('mine', '我的', 'pages/mine/index', 'person', 'person-filled', '#999999', '#6366f1', true, 5)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_app_nav_configs_key"), table_name="app_nav_configs")
|
||||
op.drop_index(op.f("ix_app_nav_configs_is_active"), table_name="app_nav_configs")
|
||||
op.drop_table("app_nav_configs")
|
||||
|
||||
op.drop_constraint("fk_promotions_shooting_id", "promotions", type_="foreignkey")
|
||||
op.drop_constraint("fk_promotions_event_id", "promotions", type_="foreignkey")
|
||||
op.drop_constraint("fk_promotions_spot_id", "promotions", type_="foreignkey")
|
||||
op.drop_column("promotions", "shooting_id")
|
||||
op.drop_column("promotions", "event_id")
|
||||
op.drop_column("promotions", "spot_id")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""add_system_configs
|
||||
|
||||
Revision ID: f1a2b3c4d5e6
|
||||
Revises: e9f8b1234cde
|
||||
Create Date: 2026-04-12 20:40:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "f1a2b3c4d5e6"
|
||||
down_revision: Union[str, None] = "e9f8b1234cde"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"system_configs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("config_key", sa.String(length=100), nullable=False),
|
||||
sa.Column("category", sa.String(length=50), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("config_json", sa.Text(), nullable=False, server_default="{}"),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("updated_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("config_key"),
|
||||
)
|
||||
op.create_index(op.f("ix_system_configs_config_key"), "system_configs", ["config_key"], unique=True)
|
||||
op.create_index(op.f("ix_system_configs_category"), "system_configs", ["category"], unique=False)
|
||||
op.create_index(op.f("ix_system_configs_is_active"), "system_configs", ["is_active"], unique=False)
|
||||
|
||||
system_configs_table = sa.table(
|
||||
"system_configs",
|
||||
sa.column("config_key", sa.String(length=100)),
|
||||
sa.column("category", sa.String(length=50)),
|
||||
sa.column("title", sa.String(length=200)),
|
||||
sa.column("config_json", sa.Text()),
|
||||
sa.column("description", sa.Text()),
|
||||
sa.column("is_active", sa.Boolean()),
|
||||
sa.column("sort_order", sa.Integer()),
|
||||
)
|
||||
op.bulk_insert(
|
||||
system_configs_table,
|
||||
[
|
||||
{
|
||||
"config_key": "notification_template_default",
|
||||
"category": "notification_template",
|
||||
"title": "默认通知模板",
|
||||
"config_json": '{"type":"system","title_template":"{title}","content_template":"{content}","channels":["in_app"]}',
|
||||
"description": "系统默认通知模板(JSON字符串)",
|
||||
"is_active": True,
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"config_key": "notification_rule_auto_dispatch",
|
||||
"category": "notification_rule",
|
||||
"title": "通知自动触发规则",
|
||||
"config_json": '{"enabled":true,"triggers":[{"event":"report_resolved","template":"notification_template_default"}],"rate_limit_per_minute":60}',
|
||||
"description": "通知规则引擎配置(JSON字符串)",
|
||||
"is_active": True,
|
||||
"sort_order": 2,
|
||||
},
|
||||
{
|
||||
"config_key": "report_sop_default",
|
||||
"category": "report_sop",
|
||||
"title": "举报处理SOP",
|
||||
"config_json": '{"steps":[{"status":"pending","action":"初筛"},{"status":"processing","action":"调查取证"},{"status":"resolved","action":"处置并回执"},{"status":"rejected","action":"驳回并说明"}],"sla_hours":24}',
|
||||
"description": "举报工单标准化流程(JSON字符串)",
|
||||
"is_active": True,
|
||||
"sort_order": 3,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_system_configs_is_active"), table_name="system_configs")
|
||||
op.drop_index(op.f("ix_system_configs_category"), table_name="system_configs")
|
||||
op.drop_index(op.f("ix_system_configs_config_key"), table_name="system_configs")
|
||||
op.drop_table("system_configs")
|
||||
@@ -0,0 +1,54 @@
|
||||
from sqladmin.authentication import AuthenticationBackend
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.security import verify_password
|
||||
from app.db.session import sync_engine
|
||||
from app.models.user import User
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class AdminAuthBackend(AuthenticationBackend):
|
||||
async def login(self, request: Request) -> bool:
|
||||
form = await request.form()
|
||||
username = form.get("username", "")
|
||||
password = form.get("password", "")
|
||||
|
||||
with Session(sync_engine) as session:
|
||||
result = session.execute(
|
||||
select(User).where(
|
||||
(User.phone == username) | (User.email == username)
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if user.role not in ("admin", "moderator"):
|
||||
return False
|
||||
|
||||
if not verify_password(str(password), user.password_hash):
|
||||
return False
|
||||
|
||||
request.session.update({"admin_user_id": user.id})
|
||||
return True
|
||||
|
||||
async def logout(self, request: Request) -> bool:
|
||||
request.session.clear()
|
||||
return True
|
||||
|
||||
async def authenticate(self, request: Request) -> bool:
|
||||
user_id = request.session.get("admin_user_id")
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
with Session(sync_engine) as session:
|
||||
result = session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or user.role not in ("admin", "moderator"):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Admin media library API - list & upload images (session-authenticated)."""
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/admin/api/media", tags=["admin-media"])
|
||||
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _check_admin_session(request: Request):
|
||||
uid = request.session.get("admin_user_id")
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_media(request: Request):
|
||||
_check_admin_session(request)
|
||||
|
||||
images = []
|
||||
|
||||
if settings.STORAGE_BACKEND == "local":
|
||||
upload_dir = Path(settings.LOCAL_STORAGE_PATH)
|
||||
if upload_dir.exists():
|
||||
for f in sorted(upload_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
|
||||
images.append({
|
||||
"url": f"/uploads/{f.name}",
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size,
|
||||
})
|
||||
else:
|
||||
storage = request.app.state.storage
|
||||
try:
|
||||
resp = storage.client.list_objects_v2(Bucket=storage.bucket, Prefix="images/", MaxKeys=200)
|
||||
for obj in resp.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
ext = Path(key).suffix.lower()
|
||||
if ext in ALLOWED_EXTENSIONS:
|
||||
images.append({
|
||||
"url": storage._get_url(key),
|
||||
"name": Path(key).name,
|
||||
"size": obj.get("Size", 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"images": images})
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(request: Request, file: UploadFile):
|
||||
_check_admin_session(request)
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="缺少文件")
|
||||
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件类型: {ext}")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大,最大10MB")
|
||||
|
||||
storage = request.app.state.storage
|
||||
url = storage.upload(data, file.filename, file.content_type or "")
|
||||
|
||||
return JSONResponse({"url": url, "name": file.filename})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
"""Custom WTForms fields/widgets for the admin image picker."""
|
||||
from markupsafe import Markup
|
||||
from wtforms import StringField
|
||||
from wtforms.widgets import TextInput
|
||||
|
||||
|
||||
class ImagePickerWidget(TextInput):
|
||||
def __call__(self, field, **kwargs):
|
||||
kwargs.setdefault("class", "form-control image-picker-input")
|
||||
kwargs["style"] = kwargs.get("style", "") + ";display:none"
|
||||
kwargs["id"] = field.id
|
||||
field_id = field.id
|
||||
val = field._value() if hasattr(field, "_value") else (field.data or "")
|
||||
|
||||
hidden = (
|
||||
f'<input type="text" name="{field.name}" id="{field_id}" '
|
||||
f'value="{val}" class="form-control image-picker-input" style="display:none">'
|
||||
)
|
||||
|
||||
preview_src = val or ""
|
||||
preview_display = "block" if preview_src else "none"
|
||||
|
||||
html = f"""
|
||||
<div class="image-picker-wrapper" data-field="{field_id}">
|
||||
{hidden}
|
||||
<div class="image-picker-preview" id="preview-{field_id}" style="display:{preview_display}">
|
||||
<img src="{preview_src}" id="preview-img-{field_id}" alt="预览">
|
||||
<button type="button" class="image-picker-remove" onclick="imagePickerClear('{field_id}')"
|
||||
title="移除图片">×</button>
|
||||
</div>
|
||||
<div class="image-picker-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
onclick="imagePickerOpen('{field_id}')">
|
||||
<i class="fa-solid fa-images"></i> 从素材库选择
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success"
|
||||
onclick="imagePickerUpload('{field_id}')">
|
||||
<i class="fa-solid fa-upload"></i> 上传新图片
|
||||
</button>
|
||||
<input type="file" id="file-{field_id}" accept="image/*"
|
||||
style="display:none" onchange="imagePickerFileSelected('{field_id}', this)">
|
||||
</div>
|
||||
<div class="image-picker-url-display" id="url-display-{field_id}"
|
||||
style="margin-top:6px;font-size:12px;color:#94a3b8;word-break:break-all">
|
||||
{val}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return Markup(html)
|
||||
|
||||
|
||||
class ImagePickerField(StringField):
|
||||
widget = ImagePickerWidget()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.models.app_nav_config import AppNavConfig
|
||||
from app.schemas.app_nav_config import AppNavConfigOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/nav", response_model=list[AppNavConfigOut])
|
||||
async def list_nav_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(AppNavConfig)
|
||||
.where(AppNavConfig.is_active.is_(True))
|
||||
.order_by(AppNavConfig.sort_order.asc(), AppNavConfig.id.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,135 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.core.rate_limit import RateLimiter
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
RefreshTokenRequest,
|
||||
TokenResponse,
|
||||
UserRegister,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=TokenResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(RateLimiter(times=5, seconds=60))],
|
||||
)
|
||||
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||
if not payload.phone and not payload.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Phone or email is required",
|
||||
)
|
||||
|
||||
filters = []
|
||||
if payload.phone:
|
||||
filters.append(User.phone == payload.phone)
|
||||
if payload.email:
|
||||
filters.append(User.email == payload.email)
|
||||
|
||||
result = await db.execute(select(User).where(or_(*filters)))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Phone or email already registered",
|
||||
)
|
||||
|
||||
user = User(
|
||||
phone=payload.phone,
|
||||
email=payload.email,
|
||||
password_hash=get_password_hash(payload.password),
|
||||
nickname=payload.nickname,
|
||||
city=payload.city,
|
||||
identity=payload.identity or "both",
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
dependencies=[Depends(RateLimiter(times=10, seconds=60))],
|
||||
)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
or_(User.phone == form_data.username, User.email == form_data.username)
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect account or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(
|
||||
payload: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
data = decode_token(payload.refresh_token)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
if data.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
)
|
||||
|
||||
user_id = data.get("sub")
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.comment import Comment
|
||||
from app.models.report import Report
|
||||
from app.models.spot import Spot
|
||||
from app.models.user import User
|
||||
from app.schemas.comment import CommentCreate, CommentOut, ReportCreate
|
||||
from app.schemas.common import PageResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/spots/{spot_id}/comments", response_model=CommentOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_comment(
|
||||
spot_id: int,
|
||||
payload: CommentCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.services.content_safety import check_text
|
||||
safety = check_text(payload.content)
|
||||
if not safety["safe"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"评论包含敏感词:{'、'.join(safety['matched'][:3])}",
|
||||
)
|
||||
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
if payload.parent_id is not None:
|
||||
parent_result = await db.execute(
|
||||
select(Comment).where(Comment.id == payload.parent_id, Comment.spot_id == spot_id)
|
||||
)
|
||||
if not parent_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Parent comment not found")
|
||||
|
||||
comment = Comment(
|
||||
spot_id=spot_id,
|
||||
user_id=current_user.id,
|
||||
parent_id=payload.parent_id,
|
||||
content=payload.content,
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
await db.refresh(comment)
|
||||
return comment
|
||||
|
||||
|
||||
@router.get("/spots/{spot_id}/comments", response_model=PageResponse[CommentOut])
|
||||
async def list_comments(
|
||||
spot_id: int,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base_filter = (
|
||||
Comment.spot_id == spot_id,
|
||||
Comment.parent_id.is_(None),
|
||||
Comment.audit_status == "approved",
|
||||
)
|
||||
|
||||
count_result = await db.execute(select(func.count(Comment.id)).where(*base_filter))
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(Comment)
|
||||
.where(*base_filter)
|
||||
.order_by(Comment.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
top_comments = result.scalars().all()
|
||||
|
||||
if top_comments:
|
||||
top_ids = [c.id for c in top_comments]
|
||||
replies_result = await db.execute(
|
||||
select(Comment)
|
||||
.where(
|
||||
Comment.parent_id.in_(top_ids),
|
||||
Comment.audit_status == "approved",
|
||||
)
|
||||
.order_by(Comment.created_at.asc())
|
||||
)
|
||||
all_replies = replies_result.scalars().all()
|
||||
|
||||
replies_map: dict[int, list] = {}
|
||||
for r in all_replies:
|
||||
replies_map.setdefault(r.parent_id, []).append(r)
|
||||
else:
|
||||
replies_map = {}
|
||||
|
||||
items = []
|
||||
for c in top_comments:
|
||||
out = CommentOut.model_validate(c)
|
||||
out.replies = [CommentOut.model_validate(r) for r in replies_map.get(c.id, [])]
|
||||
items.append(out)
|
||||
|
||||
return PageResponse(total=total, items=items)
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_comment(
|
||||
comment_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Comment).where(Comment.id == comment_id))
|
||||
comment = result.scalar_one_or_none()
|
||||
if not comment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if comment.user_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
await db.delete(comment)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}/report", status_code=status.HTTP_201_CREATED)
|
||||
async def report_comment(
|
||||
comment_id: int,
|
||||
payload: ReportCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Comment).where(Comment.id == comment_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
report = Report(
|
||||
reporter_id=current_user.id,
|
||||
target_type="comment",
|
||||
target_id=comment_id,
|
||||
reason=payload.reason,
|
||||
)
|
||||
db.add(report)
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "Report submitted"}
|
||||
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.correction import Correction
|
||||
from app.models.spot import Spot
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.correction import CorrectionCreate, CorrectionOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
FIELD_LABELS = {
|
||||
"title": "地点名称",
|
||||
"city": "所在城市",
|
||||
"description": "地点介绍",
|
||||
"transport": "交通方式",
|
||||
"best_time": "最佳拍摄时间",
|
||||
"difficulty": "路径难度",
|
||||
"price": "收费信息",
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/spots/{spot_id}/corrections",
|
||||
response_model=CorrectionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_correction(
|
||||
spot_id: int,
|
||||
payload: CorrectionCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
if spot.creator_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot submit correction for your own spot",
|
||||
)
|
||||
|
||||
correction = Correction(
|
||||
spot_id=spot_id,
|
||||
user_id=current_user.id,
|
||||
field_name=payload.field_name,
|
||||
suggested_value=payload.suggested_value,
|
||||
reason=payload.reason,
|
||||
)
|
||||
db.add(correction)
|
||||
await db.commit()
|
||||
await db.refresh(correction)
|
||||
return correction
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spots/{spot_id}/corrections",
|
||||
response_model=PageResponse[CorrectionOut],
|
||||
)
|
||||
async def list_corrections(
|
||||
spot_id: int,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = Correction.spot_id == spot_id
|
||||
count_result = await db.execute(select(func.count(Correction.id)).where(base))
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(Correction)
|
||||
.where(base)
|
||||
.order_by(Correction.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return PageResponse(total=total, items=[CorrectionOut.model_validate(c) for c in items])
|
||||
@@ -0,0 +1,388 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db, get_optional_current_user
|
||||
from app.models.event import Event, EventPhoto, EventRegistration
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.event import (
|
||||
EventBrief,
|
||||
EventCreate,
|
||||
EventDetail,
|
||||
EventPhotoCreate,
|
||||
EventPhotoOut,
|
||||
EventUpdate,
|
||||
RegistrationOut,
|
||||
)
|
||||
from app.services.notification_service import send_notification
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_brief(ev: Event) -> EventBrief:
|
||||
return EventBrief(
|
||||
id=ev.id,
|
||||
title=ev.title,
|
||||
city=ev.city,
|
||||
cover_url=ev.cover_url,
|
||||
location_name=ev.location_name,
|
||||
start_time=ev.start_time,
|
||||
end_time=ev.end_time,
|
||||
status=ev.status,
|
||||
audit_status=ev.audit_status,
|
||||
creator=ev.creator,
|
||||
registration_count=ev.registration_count,
|
||||
max_participants=ev.max_participants,
|
||||
created_at=ev.created_at,
|
||||
)
|
||||
|
||||
|
||||
# --- Event CRUD ---
|
||||
|
||||
@router.post("/", response_model=EventBrief, status_code=status.HTTP_201_CREATED)
|
||||
async def create_event(
|
||||
payload: EventCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ev = Event(
|
||||
creator_id=current_user.id,
|
||||
title=payload.title,
|
||||
city=payload.city,
|
||||
description=payload.description,
|
||||
cover_url=payload.cover_url,
|
||||
location_name=payload.location_name,
|
||||
start_time=payload.start_time,
|
||||
end_time=payload.end_time,
|
||||
max_participants=payload.max_participants,
|
||||
spot_id=payload.spot_id,
|
||||
status="upcoming",
|
||||
audit_status="pending",
|
||||
)
|
||||
db.add(ev)
|
||||
await db.commit()
|
||||
await db.refresh(ev)
|
||||
return _to_brief(ev)
|
||||
|
||||
|
||||
@router.get("/", response_model=PageResponse[EventBrief])
|
||||
async def list_events(
|
||||
city: str | None = None,
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(Event).where(Event.audit_status == "approved")
|
||||
count_base = select(func.count(Event.id)).where(Event.audit_status == "approved")
|
||||
|
||||
if city:
|
||||
base = base.where(Event.city.ilike(f"%{city}%"))
|
||||
count_base = count_base.where(Event.city.ilike(f"%{city}%"))
|
||||
if status_filter:
|
||||
base = base.where(Event.status == status_filter)
|
||||
count_base = count_base.where(Event.status == status_filter)
|
||||
|
||||
total = (await db.execute(count_base)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(base.order_by(Event.start_time.asc().nulls_last()).offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PageResponse(total=total, items=[_to_brief(ev) for ev in items])
|
||||
|
||||
|
||||
@router.get("/mine", response_model=PageResponse[EventBrief])
|
||||
async def list_my_events(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(Event).where(Event.creator_id == current_user.id)
|
||||
count_base = select(func.count(Event.id)).where(Event.creator_id == current_user.id)
|
||||
|
||||
total = (await db.execute(count_base)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(base.order_by(Event.created_at.desc()).offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
return PageResponse(total=total, items=[_to_brief(ev) for ev in items])
|
||||
|
||||
|
||||
@router.get("/my-registrations", response_model=PageResponse[RegistrationOut])
|
||||
async def list_my_registrations(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(EventRegistration).where(EventRegistration.user_id == current_user.id)
|
||||
count_base = select(func.count(EventRegistration.id)).where(EventRegistration.user_id == current_user.id)
|
||||
|
||||
total = (await db.execute(count_base)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(base.order_by(EventRegistration.created_at.desc()).offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
return PageResponse(total=total, items=items)
|
||||
|
||||
|
||||
@router.get("/{event_id}", response_model=EventDetail)
|
||||
async def get_event(
|
||||
event_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
ev = result.scalar_one_or_none()
|
||||
if not ev:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
has_registered = False
|
||||
if current_user:
|
||||
reg = await db.execute(
|
||||
select(EventRegistration).where(
|
||||
EventRegistration.event_id == event_id,
|
||||
EventRegistration.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
has_registered = reg.scalar_one_or_none() is not None
|
||||
|
||||
photos_result = await db.execute(
|
||||
select(EventPhoto).where(EventPhoto.event_id == event_id).order_by(EventPhoto.created_at.desc()).limit(50)
|
||||
)
|
||||
photos = photos_result.scalars().all()
|
||||
|
||||
return EventDetail(
|
||||
id=ev.id,
|
||||
title=ev.title,
|
||||
city=ev.city,
|
||||
cover_url=ev.cover_url,
|
||||
location_name=ev.location_name,
|
||||
start_time=ev.start_time,
|
||||
end_time=ev.end_time,
|
||||
status=ev.status,
|
||||
audit_status=ev.audit_status,
|
||||
creator=ev.creator,
|
||||
registration_count=ev.registration_count,
|
||||
max_participants=ev.max_participants,
|
||||
created_at=ev.created_at,
|
||||
description=ev.description,
|
||||
spot_id=ev.spot_id,
|
||||
reject_reason=ev.reject_reason,
|
||||
has_registered=has_registered,
|
||||
photos=photos,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{event_id}", response_model=EventBrief)
|
||||
async def update_event(
|
||||
event_id: int,
|
||||
payload: EventUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
ev = result.scalar_one_or_none()
|
||||
if not ev:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if ev.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(ev, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(ev)
|
||||
return _to_brief(ev)
|
||||
|
||||
|
||||
@router.post("/{event_id}/cancel")
|
||||
async def cancel_event(
|
||||
event_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
ev = result.scalar_one_or_none()
|
||||
if not ev:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if ev.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
ev.status = "cancelled"
|
||||
|
||||
regs = await db.execute(
|
||||
select(EventRegistration).where(EventRegistration.event_id == event_id)
|
||||
)
|
||||
for reg in regs.scalars().all():
|
||||
await send_notification(
|
||||
db, reg.user_id, "event",
|
||||
f"活动「{ev.title}」已取消",
|
||||
content="很遗憾,该活动已被取消。",
|
||||
ref_type="event", ref_id=ev.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "cancelled"}
|
||||
|
||||
|
||||
# --- Registration ---
|
||||
|
||||
@router.post("/{event_id}/register", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register_event(
|
||||
event_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
ev = result.scalar_one_or_none()
|
||||
if not ev:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if ev.audit_status != "approved":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="活动尚未通过审核")
|
||||
if ev.status not in ("upcoming",):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="活动不在报名阶段")
|
||||
if ev.max_participants > 0 and ev.registration_count >= ev.max_participants:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="报名人数已满")
|
||||
|
||||
existing = await db.execute(
|
||||
select(EventRegistration).where(
|
||||
EventRegistration.event_id == event_id,
|
||||
EventRegistration.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="已经报名过了")
|
||||
|
||||
reg = EventRegistration(event_id=event_id, user_id=current_user.id, status="registered")
|
||||
db.add(reg)
|
||||
ev.registration_count += 1
|
||||
|
||||
await send_notification(
|
||||
db, ev.creator_id, "event",
|
||||
f"{current_user.nickname} 报名了您的活动「{ev.title}」",
|
||||
ref_type="event", ref_id=ev.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(reg)
|
||||
return reg
|
||||
|
||||
|
||||
@router.delete("/{event_id}/register")
|
||||
async def cancel_registration(
|
||||
event_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventRegistration).where(
|
||||
EventRegistration.event_id == event_id,
|
||||
EventRegistration.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
reg = result.scalar_one_or_none()
|
||||
if not reg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到报名记录")
|
||||
|
||||
ev_result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
ev = ev_result.scalar_one_or_none()
|
||||
if ev and ev.registration_count > 0:
|
||||
ev.registration_count -= 1
|
||||
|
||||
await db.delete(reg)
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "取消报名成功"}
|
||||
|
||||
|
||||
@router.get("/{event_id}/registrations", response_model=list[RegistrationOut])
|
||||
async def list_registrations(
|
||||
event_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ev_result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
ev = ev_result.scalar_one_or_none()
|
||||
if not ev:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if ev.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
result = await db.execute(
|
||||
select(EventRegistration)
|
||||
.where(EventRegistration.event_id == event_id)
|
||||
.order_by(EventRegistration.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
# --- Event Photos ---
|
||||
|
||||
@router.post("/{event_id}/photos", response_model=EventPhotoOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_event_photo(
|
||||
event_id: int,
|
||||
payload: EventPhotoCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ev_result = await db.execute(select(Event).where(Event.id == event_id))
|
||||
ev = ev_result.scalar_one_or_none()
|
||||
if not ev:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
reg = await db.execute(
|
||||
select(EventRegistration).where(
|
||||
EventRegistration.event_id == event_id,
|
||||
EventRegistration.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
is_participant = reg.scalar_one_or_none() is not None
|
||||
is_creator = ev.creator_id == current_user.id
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if not (is_participant or is_creator or is_admin):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有活动参与者才能上传照片")
|
||||
|
||||
photo = EventPhoto(
|
||||
event_id=event_id,
|
||||
uploader_id=current_user.id,
|
||||
image_url=payload.image_url,
|
||||
caption=payload.caption,
|
||||
spot_id=payload.spot_id,
|
||||
)
|
||||
db.add(photo)
|
||||
await db.commit()
|
||||
await db.refresh(photo)
|
||||
return photo
|
||||
|
||||
|
||||
@router.get("/{event_id}/photos", response_model=list[EventPhotoOut])
|
||||
async def list_event_photos(
|
||||
event_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventPhoto)
|
||||
.where(EventPhoto.event_id == event_id)
|
||||
.order_by(EventPhoto.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.delete("/{event_id}/photos/{photo_id}")
|
||||
async def delete_event_photo(
|
||||
event_id: int,
|
||||
photo_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventPhoto).where(EventPhoto.id == photo_id, EventPhoto.event_id == event_id)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
if not photo:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if photo.uploader_id != current_user.id and current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
await db.delete(photo)
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "deleted"}
|
||||
@@ -0,0 +1,112 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.spot import Spot
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.spot import SpotBrief
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _spot_to_brief(spot: Spot) -> SpotBrief:
|
||||
cover = next((img for img in spot.images if img.is_cover), None)
|
||||
if cover is None and spot.images:
|
||||
cover = spot.images[0]
|
||||
return SpotBrief(
|
||||
id=spot.id,
|
||||
title=spot.title,
|
||||
city=spot.city,
|
||||
longitude=spot.longitude,
|
||||
latitude=spot.latitude,
|
||||
cover_image_url=cover.image_url if cover else None,
|
||||
audit_status=spot.audit_status,
|
||||
created_at=spot.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{spot_id}", status_code=status.HTTP_201_CREATED)
|
||||
async def add_favorite(
|
||||
spot_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(Favorite).where(
|
||||
Favorite.user_id == current_user.id, Favorite.spot_id == spot_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Already favorited"
|
||||
)
|
||||
|
||||
fav = Favorite(user_id=current_user.id, spot_id=spot_id)
|
||||
db.add(fav)
|
||||
await db.execute(
|
||||
select(Spot).where(Spot.id == spot_id)
|
||||
)
|
||||
spot_obj = (await db.execute(select(Spot).where(Spot.id == spot_id))).scalar_one()
|
||||
spot_obj.favorite_count = (spot_obj.favorite_count or 0) + 1
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "success"}
|
||||
|
||||
|
||||
@router.delete("/{spot_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_favorite(
|
||||
spot_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Favorite).where(
|
||||
Favorite.user_id == current_user.id, Favorite.spot_id == spot_id
|
||||
)
|
||||
)
|
||||
fav = result.scalar_one_or_none()
|
||||
if not fav:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Favorite not found"
|
||||
)
|
||||
spot_obj = (await db.execute(select(Spot).where(Spot.id == spot_id))).scalar_one_or_none()
|
||||
if spot_obj:
|
||||
spot_obj.favorite_count = max((spot_obj.favorite_count or 0) - 1, 0)
|
||||
await db.delete(fav)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/", response_model=PageResponse[SpotBrief])
|
||||
async def list_favorites(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count_query = select(func.count(Favorite.id)).where(
|
||||
Favorite.user_id == current_user.id
|
||||
)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = (
|
||||
select(Favorite)
|
||||
.where(Favorite.user_id == current_user.id)
|
||||
.order_by(Favorite.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
favorites = result.scalars().all()
|
||||
|
||||
return PageResponse(
|
||||
total=total,
|
||||
items=[_spot_to_brief(f.spot) for f in favorites],
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CACHE_TTL = 3600
|
||||
|
||||
|
||||
def _cache_key(prefix: str, **kwargs: str) -> str:
|
||||
raw = "&".join(f"{k}={v}" for k, v in sorted(kwargs.items()) if v)
|
||||
return f"map:{prefix}:{hashlib.md5(raw.encode()).hexdigest()}"
|
||||
|
||||
|
||||
@router.get("/geocoder/reverse")
|
||||
async def reverse_geocode(
|
||||
request: Request,
|
||||
location: str = Query(..., description="lat,lng"),
|
||||
):
|
||||
redis = request.app.state.redis
|
||||
ck = _cache_key("rev", location=location)
|
||||
if redis:
|
||||
cached = await redis.get(ck)
|
||||
if cached:
|
||||
import json
|
||||
return json.loads(cached)
|
||||
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.get(
|
||||
"https://apis.map.qq.com/ws/geocoder/v1/",
|
||||
params={
|
||||
"location": location,
|
||||
"key": settings.TENCENT_MAP_KEY,
|
||||
"get_poi": 1,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if redis and data.get("status") == 0:
|
||||
import json
|
||||
await redis.set(ck, json.dumps(data), ex=CACHE_TTL)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/place/search")
|
||||
async def place_search(
|
||||
request: Request,
|
||||
keyword: str = Query(...),
|
||||
boundary: str = Query(...),
|
||||
):
|
||||
redis = request.app.state.redis
|
||||
ck = _cache_key("place", keyword=keyword, boundary=boundary)
|
||||
if redis:
|
||||
cached = await redis.get(ck)
|
||||
if cached:
|
||||
import json
|
||||
return json.loads(cached)
|
||||
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.get(
|
||||
"https://apis.map.qq.com/ws/place/v1/search",
|
||||
params={
|
||||
"keyword": keyword,
|
||||
"boundary": boundary,
|
||||
"page_size": 10,
|
||||
"page_index": 1,
|
||||
"key": settings.TENCENT_MAP_KEY,
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if redis and data.get("status") == 0:
|
||||
import json
|
||||
await redis.set(ck, json.dumps(data), ex=CACHE_TTL)
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.membership import MembershipPlan, UserMembership
|
||||
from app.models.user import User
|
||||
from app.schemas.membership import MembershipPlanOut, PurchaseMembership, UserMembershipOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/plans", response_model=list[MembershipPlanOut])
|
||||
async def list_plans(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(MembershipPlan)
|
||||
.where(MembershipPlan.is_active == True)
|
||||
.order_by(MembershipPlan.sort_order.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserMembershipOut | None)
|
||||
async def get_my_membership(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(UserMembership).where(
|
||||
UserMembership.user_id == current_user.id,
|
||||
UserMembership.is_active == True,
|
||||
UserMembership.end_date >= now,
|
||||
).order_by(UserMembership.end_date.desc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@router.post("/purchase", response_model=UserMembershipOut)
|
||||
async def purchase_membership(
|
||||
payload: PurchaseMembership,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
plan_result = await db.execute(
|
||||
select(MembershipPlan).where(
|
||||
MembershipPlan.id == payload.plan_id,
|
||||
MembershipPlan.is_active == True,
|
||||
)
|
||||
)
|
||||
plan = plan_result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会员方案不存在或已下架")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
existing = await db.execute(
|
||||
select(UserMembership).where(
|
||||
UserMembership.user_id == current_user.id,
|
||||
UserMembership.is_active == True,
|
||||
UserMembership.end_date >= now,
|
||||
).order_by(UserMembership.end_date.desc()).limit(1)
|
||||
)
|
||||
current_membership = existing.scalar_one_or_none()
|
||||
|
||||
start = current_membership.end_date if current_membership else now
|
||||
end = start + timedelta(days=plan.duration_days)
|
||||
|
||||
membership = UserMembership(
|
||||
user_id=current_user.id,
|
||||
plan_id=plan.id,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(membership)
|
||||
await db.commit()
|
||||
await db.refresh(membership)
|
||||
return membership
|
||||
@@ -0,0 +1,97 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.notification import Notification
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NotificationOut(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
title: str
|
||||
content: str | None = None
|
||||
ref_type: str | None = None
|
||||
ref_id: int | None = None
|
||||
is_read: bool = False
|
||||
created_at: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UnreadCount(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/", response_model=PageResponse[NotificationOut])
|
||||
async def list_notifications(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(func.count(Notification.id)).where(Notification.user_id == current_user.id)
|
||||
total = (await db.execute(base)).scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(Notification)
|
||||
.where(Notification.user_id == current_user.id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return PageResponse(total=total, items=items)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCount)
|
||||
async def get_unread_count(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count = (await db.execute(
|
||||
select(func.count(Notification.id)).where(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read.is_(False),
|
||||
)
|
||||
)).scalar() or 0
|
||||
return UnreadCount(count=count)
|
||||
|
||||
|
||||
@router.post("/read-all", status_code=status.HTTP_200_OK)
|
||||
async def mark_all_read(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(
|
||||
update(Notification)
|
||||
.where(Notification.user_id == current_user.id, Notification.is_read.is_(False))
|
||||
.values(is_read=True)
|
||||
)
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "success"}
|
||||
|
||||
|
||||
@router.post("/{notification_id}/read")
|
||||
async def mark_read(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
n = result.scalar_one_or_none()
|
||||
if n:
|
||||
n.is_read = True
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "success"}
|
||||
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.point_ledger import PointLedger
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.point import PointBalance, PointRecord
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=PointBalance)
|
||||
async def get_my_points(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(PointLedger)
|
||||
.where(PointLedger.user_id == current_user.id)
|
||||
.order_by(PointLedger.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last = result.scalar_one_or_none()
|
||||
return PointBalance(balance=last.balance if last else 0)
|
||||
|
||||
|
||||
@router.get("/me/records", response_model=PageResponse[PointRecord])
|
||||
async def get_my_point_records(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(PointLedger).where(PointLedger.user_id == current_user.id)
|
||||
|
||||
total_result = await db.execute(
|
||||
select(func.count(PointLedger.id)).where(PointLedger.user_id == current_user.id)
|
||||
)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
base.order_by(PointLedger.id.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
return PageResponse(total=total, items=records)
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.models.promotion import Promotion
|
||||
from app.schemas.promotion import PromotionClick, PromotionOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PromotionOut])
|
||||
async def list_promotions(
|
||||
position: str = Query(default="home_banner"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(Promotion).where(
|
||||
and_(
|
||||
Promotion.is_active == True,
|
||||
Promotion.position == position,
|
||||
(Promotion.start_time == None) | (Promotion.start_time <= now),
|
||||
(Promotion.end_time == None) | (Promotion.end_time >= now),
|
||||
)
|
||||
).order_by(Promotion.sort_order.asc())
|
||||
)
|
||||
items = result.scalars().all()
|
||||
|
||||
for item in items:
|
||||
item.impressions += 1
|
||||
await db.commit()
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/click")
|
||||
async def record_click(
|
||||
payload: PromotionClick,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Promotion).where(Promotion.id == payload.promotion_id)
|
||||
)
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
promo.clicks += 1
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "ok"}
|
||||
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.rating import Rating
|
||||
from app.models.spot import Spot
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.rating import RatingCreate, RatingOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/spots/{spot_id}/rate", response_model=RatingOut)
|
||||
async def rate_spot(
|
||||
spot_id: int,
|
||||
payload: RatingCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(Rating).where(Rating.user_id == current_user.id, Rating.spot_id == spot_id)
|
||||
)
|
||||
rating = existing.scalar_one_or_none()
|
||||
|
||||
if rating:
|
||||
rating.score = payload.score
|
||||
rating.short_comment = payload.short_comment
|
||||
else:
|
||||
rating = Rating(
|
||||
spot_id=spot_id,
|
||||
user_id=current_user.id,
|
||||
score=payload.score,
|
||||
short_comment=payload.short_comment,
|
||||
)
|
||||
db.add(rating)
|
||||
|
||||
await db.flush()
|
||||
|
||||
agg = await db.execute(
|
||||
select(func.avg(Rating.score), func.count(Rating.id)).where(Rating.spot_id == spot_id)
|
||||
)
|
||||
avg_score, count = agg.one()
|
||||
spot.avg_rating = round(float(avg_score), 2) if avg_score else None
|
||||
spot.rating_count = count or 0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(rating)
|
||||
return rating
|
||||
|
||||
|
||||
@router.get("/spots/{spot_id}/ratings", response_model=PageResponse[RatingOut])
|
||||
async def list_ratings(
|
||||
spot_id: int,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count_result = await db.execute(
|
||||
select(func.count(Rating.id)).where(Rating.spot_id == spot_id)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(Rating)
|
||||
.where(Rating.spot_id == spot_id)
|
||||
.order_by(Rating.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
ratings = result.scalars().all()
|
||||
|
||||
return PageResponse(total=total, items=[RatingOut.model_validate(r) for r in ratings])
|
||||
@@ -0,0 +1,74 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.models.spot import Spot
|
||||
from app.models.tag import SpotTag, Tag
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.spot import SpotBrief
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _spot_to_brief(spot: Spot) -> SpotBrief:
|
||||
cover = next((img for img in spot.images if img.is_cover), None)
|
||||
if cover is None and spot.images:
|
||||
cover = spot.images[0]
|
||||
return SpotBrief(
|
||||
id=spot.id,
|
||||
title=spot.title,
|
||||
city=spot.city,
|
||||
longitude=spot.longitude,
|
||||
latitude=spot.latitude,
|
||||
cover_image_url=cover.image_url if cover else None,
|
||||
audit_status=spot.audit_status,
|
||||
avg_rating=spot.avg_rating,
|
||||
created_at=spot.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse[SpotBrief])
|
||||
async def search_spots(
|
||||
q: str = Query(..., min_length=1),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not q.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Query cannot be empty")
|
||||
|
||||
pattern = f"%{q}%"
|
||||
|
||||
tag_spot_ids = (
|
||||
select(SpotTag.spot_id)
|
||||
.join(Tag, SpotTag.tag_id == Tag.id)
|
||||
.where(Tag.name.ilike(pattern))
|
||||
.distinct()
|
||||
.correlate(None)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
filters = (
|
||||
Spot.audit_status == "approved",
|
||||
or_(
|
||||
Spot.title.ilike(pattern),
|
||||
Spot.description.ilike(pattern),
|
||||
Spot.id.in_(tag_spot_ids),
|
||||
),
|
||||
)
|
||||
|
||||
count_result = await db.execute(select(func.count(Spot.id)).where(*filters))
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(Spot)
|
||||
.where(*filters)
|
||||
.order_by(Spot.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
spots = result.scalars().all()
|
||||
|
||||
return PageResponse(total=total, items=[_spot_to_brief(s) for s in spots])
|
||||
@@ -0,0 +1,402 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db, get_optional_current_user
|
||||
from app.models.shooting import ShootingApplication, ShootingRequest
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.shooting import (
|
||||
ApplicationCreate,
|
||||
ApplicationOut,
|
||||
ShootingRequestBrief,
|
||||
ShootingRequestCreate,
|
||||
ShootingRequestDetail,
|
||||
ShootingRequestUpdate,
|
||||
)
|
||||
from app.services.notification_service import send_notification
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ROLE_LABELS = {
|
||||
"photographer": "摄影师",
|
||||
"cosplayer": "Coser",
|
||||
"both": "不限",
|
||||
}
|
||||
|
||||
|
||||
def _to_brief(sr: ShootingRequest) -> ShootingRequestBrief:
|
||||
return ShootingRequestBrief(
|
||||
id=sr.id,
|
||||
title=sr.title,
|
||||
city=sr.city,
|
||||
style=sr.style,
|
||||
shoot_date=sr.shoot_date,
|
||||
is_free=sr.is_free,
|
||||
budget_min=float(sr.budget_min) if sr.budget_min is not None else None,
|
||||
budget_max=float(sr.budget_max) if sr.budget_max is not None else None,
|
||||
role_needed=sr.role_needed,
|
||||
status=sr.status,
|
||||
audit_status=sr.audit_status,
|
||||
creator=sr.creator,
|
||||
application_count=len(sr.applications) if sr.applications else 0,
|
||||
created_at=sr.created_at,
|
||||
)
|
||||
|
||||
|
||||
# --- ShootingRequest CRUD ---
|
||||
|
||||
@router.post("/", response_model=ShootingRequestBrief, status_code=status.HTTP_201_CREATED)
|
||||
async def create_shooting_request(
|
||||
payload: ShootingRequestCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sr = ShootingRequest(
|
||||
creator_id=current_user.id,
|
||||
title=payload.title,
|
||||
city=payload.city,
|
||||
description=payload.description,
|
||||
style=payload.style,
|
||||
shoot_date=payload.shoot_date,
|
||||
is_free=payload.is_free,
|
||||
budget_min=payload.budget_min if not payload.is_free else None,
|
||||
budget_max=payload.budget_max if not payload.is_free else None,
|
||||
role_needed=payload.role_needed,
|
||||
max_applicants=payload.max_applicants,
|
||||
contact_info=payload.contact_info,
|
||||
spot_id=payload.spot_id,
|
||||
status="open",
|
||||
audit_status="pending",
|
||||
)
|
||||
db.add(sr)
|
||||
await db.commit()
|
||||
await db.refresh(sr)
|
||||
return _to_brief(sr)
|
||||
|
||||
|
||||
@router.get("/", response_model=PageResponse[ShootingRequestBrief])
|
||||
async def list_shooting_requests(
|
||||
city: str | None = None,
|
||||
style: str | None = None,
|
||||
role_needed: str | None = None,
|
||||
is_free: bool | None = None,
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(ShootingRequest).where(ShootingRequest.audit_status == "approved")
|
||||
count_base = select(func.count(ShootingRequest.id)).where(ShootingRequest.audit_status == "approved")
|
||||
|
||||
if city:
|
||||
base = base.where(ShootingRequest.city.ilike(f"%{city}%"))
|
||||
count_base = count_base.where(ShootingRequest.city.ilike(f"%{city}%"))
|
||||
if style:
|
||||
base = base.where(ShootingRequest.style.ilike(f"%{style}%"))
|
||||
count_base = count_base.where(ShootingRequest.style.ilike(f"%{style}%"))
|
||||
if role_needed:
|
||||
base = base.where(ShootingRequest.role_needed == role_needed)
|
||||
count_base = count_base.where(ShootingRequest.role_needed == role_needed)
|
||||
if is_free is not None:
|
||||
base = base.where(ShootingRequest.is_free == is_free)
|
||||
count_base = count_base.where(ShootingRequest.is_free == is_free)
|
||||
if status_filter:
|
||||
base = base.where(ShootingRequest.status == status_filter)
|
||||
count_base = count_base.where(ShootingRequest.status == status_filter)
|
||||
|
||||
total = (await db.execute(count_base)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(base.order_by(ShootingRequest.created_at.desc()).offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PageResponse(total=total, items=[_to_brief(sr) for sr in items])
|
||||
|
||||
|
||||
@router.get("/mine", response_model=PageResponse[ShootingRequestBrief])
|
||||
async def list_my_shooting_requests(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(ShootingRequest).where(ShootingRequest.creator_id == current_user.id)
|
||||
count_base = select(func.count(ShootingRequest.id)).where(ShootingRequest.creator_id == current_user.id)
|
||||
|
||||
total = (await db.execute(count_base)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(base.order_by(ShootingRequest.created_at.desc()).offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PageResponse(total=total, items=[_to_brief(sr) for sr in items])
|
||||
|
||||
|
||||
@router.get("/my-applications", response_model=PageResponse[ApplicationOut])
|
||||
async def list_my_applications(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
base = select(ShootingApplication).where(ShootingApplication.applicant_id == current_user.id)
|
||||
count_base = select(func.count(ShootingApplication.id)).where(ShootingApplication.applicant_id == current_user.id)
|
||||
|
||||
total = (await db.execute(count_base)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(base.order_by(ShootingApplication.created_at.desc()).offset(offset).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PageResponse(total=total, items=items)
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=ShootingRequestDetail)
|
||||
async def get_shooting_request(
|
||||
request_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||||
sr = result.scalar_one_or_none()
|
||||
if not sr:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
has_applied = False
|
||||
if current_user:
|
||||
app_result = await db.execute(
|
||||
select(ShootingApplication).where(
|
||||
ShootingApplication.request_id == request_id,
|
||||
ShootingApplication.applicant_id == current_user.id,
|
||||
)
|
||||
)
|
||||
has_applied = app_result.scalar_one_or_none() is not None
|
||||
|
||||
return ShootingRequestDetail(
|
||||
id=sr.id,
|
||||
title=sr.title,
|
||||
city=sr.city,
|
||||
style=sr.style,
|
||||
shoot_date=sr.shoot_date,
|
||||
is_free=sr.is_free,
|
||||
budget_min=float(sr.budget_min) if sr.budget_min is not None else None,
|
||||
budget_max=float(sr.budget_max) if sr.budget_max is not None else None,
|
||||
role_needed=sr.role_needed,
|
||||
status=sr.status,
|
||||
audit_status=sr.audit_status,
|
||||
creator=sr.creator,
|
||||
application_count=len(sr.applications) if sr.applications else 0,
|
||||
created_at=sr.created_at,
|
||||
description=sr.description,
|
||||
max_applicants=sr.max_applicants,
|
||||
contact_info=sr.contact_info if (current_user and (sr.creator_id == current_user.id or current_user.role in ("admin", "moderator"))) else None,
|
||||
spot_id=sr.spot_id,
|
||||
reject_reason=sr.reject_reason,
|
||||
has_applied=has_applied,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{request_id}", response_model=ShootingRequestBrief)
|
||||
async def update_shooting_request(
|
||||
request_id: int,
|
||||
payload: ShootingRequestUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||||
sr = result.scalar_one_or_none()
|
||||
if not sr:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(sr, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(sr)
|
||||
return _to_brief(sr)
|
||||
|
||||
|
||||
@router.post("/{request_id}/close")
|
||||
async def close_shooting_request(
|
||||
request_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||||
sr = result.scalar_one_or_none()
|
||||
if not sr:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
sr.status = "closed"
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "closed"}
|
||||
|
||||
|
||||
# --- Applications ---
|
||||
|
||||
@router.post("/{request_id}/apply", response_model=ApplicationOut, status_code=status.HTTP_201_CREATED)
|
||||
async def apply_to_shooting(
|
||||
request_id: int,
|
||||
payload: ApplicationCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||||
sr = result.scalar_one_or_none()
|
||||
if not sr:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if sr.status != "open":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该约拍已关闭")
|
||||
if sr.audit_status != "approved":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该约拍尚未通过审核")
|
||||
if sr.creator_id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能报名自己的约拍")
|
||||
|
||||
existing = await db.execute(
|
||||
select(ShootingApplication).where(
|
||||
ShootingApplication.request_id == request_id,
|
||||
ShootingApplication.applicant_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="已经报名过了")
|
||||
|
||||
app = ShootingApplication(
|
||||
request_id=request_id,
|
||||
applicant_id=current_user.id,
|
||||
message=payload.message,
|
||||
status="pending",
|
||||
)
|
||||
db.add(app)
|
||||
|
||||
await send_notification(
|
||||
db, sr.creator_id, "shooting",
|
||||
f"有人报名了您的约拍「{sr.title}」",
|
||||
content=f"{current_user.nickname} 报名了您的约拍",
|
||||
ref_type="shooting", ref_id=sr.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(app)
|
||||
return app
|
||||
|
||||
|
||||
@router.get("/{request_id}/applications", response_model=list[ApplicationOut])
|
||||
async def list_applications(
|
||||
request_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||||
sr = result.scalar_one_or_none()
|
||||
if not sr:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can view applications")
|
||||
|
||||
result = await db.execute(
|
||||
select(ShootingApplication)
|
||||
.where(ShootingApplication.request_id == request_id)
|
||||
.order_by(ShootingApplication.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{request_id}/applications/{app_id}/accept")
|
||||
async def accept_application(
|
||||
request_id: int,
|
||||
app_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sr_result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||||
sr = sr_result.scalar_one_or_none()
|
||||
if not sr or sr.creator_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
app_result = await db.execute(
|
||||
select(ShootingApplication).where(
|
||||
ShootingApplication.id == app_id,
|
||||
ShootingApplication.request_id == request_id,
|
||||
)
|
||||
)
|
||||
app = app_result.scalar_one_or_none()
|
||||
if not app:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
|
||||
if app.status != "pending":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报名已被处理")
|
||||
|
||||
app.status = "accepted"
|
||||
|
||||
accepted_count = sum(1 for a in sr.applications if a.status == "accepted") + 1
|
||||
if accepted_count >= sr.max_applicants:
|
||||
sr.status = "matched"
|
||||
|
||||
await send_notification(
|
||||
db, app.applicant_id, "shooting",
|
||||
f"您的约拍报名「{sr.title}」已被接受",
|
||||
content="对方已接受您的报名,请查看详情。",
|
||||
ref_type="shooting", ref_id=sr.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "accepted"}
|
||||
|
||||
|
||||
@router.post("/{request_id}/applications/{app_id}/reject")
|
||||
async def reject_application(
|
||||
request_id: int,
|
||||
app_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sr_result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||||
sr = sr_result.scalar_one_or_none()
|
||||
if not sr or sr.creator_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
app_result = await db.execute(
|
||||
select(ShootingApplication).where(
|
||||
ShootingApplication.id == app_id,
|
||||
ShootingApplication.request_id == request_id,
|
||||
)
|
||||
)
|
||||
app = app_result.scalar_one_or_none()
|
||||
if not app:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
|
||||
|
||||
app.status = "rejected"
|
||||
|
||||
await send_notification(
|
||||
db, app.applicant_id, "shooting",
|
||||
f"您的约拍报名「{sr.title}」未通过",
|
||||
ref_type="shooting", ref_id=sr.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "rejected"}
|
||||
|
||||
|
||||
@router.delete("/{request_id}/applications/withdraw")
|
||||
async def withdraw_application(
|
||||
request_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ShootingApplication).where(
|
||||
ShootingApplication.request_id == request_id,
|
||||
ShootingApplication.applicant_id == current_user.id,
|
||||
)
|
||||
)
|
||||
app = result.scalar_one_or_none()
|
||||
if not app:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
|
||||
if app.status == "accepted":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已被接受的报名无法撤回")
|
||||
|
||||
await db.delete(app)
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "withdrawn"}
|
||||
@@ -0,0 +1,454 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from geoalchemy2.functions import ST_DWithin, ST_MakePoint, ST_SetSRID, ST_X, ST_Y
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.deps import get_current_active_user, get_db, get_optional_current_user
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.spot import Spot, SpotImage
|
||||
from app.models.tag import SpotTag, Tag
|
||||
from app.models.user import User
|
||||
from app.schemas.common import PageResponse
|
||||
from app.schemas.spot import (
|
||||
SpotBrief,
|
||||
SpotCreate,
|
||||
SpotDetail,
|
||||
SpotImageCreate,
|
||||
SpotImageOut,
|
||||
SpotUpdate,
|
||||
)
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import send_notification
|
||||
from app.services.point_service import grant_points
|
||||
|
||||
|
||||
class RejectPayload(BaseModel):
|
||||
reason: str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _spot_to_brief(spot: Spot, lng: float | None = None, lat: float | None = None) -> SpotBrief:
|
||||
cover = next((img for img in spot.images if img.is_cover), None)
|
||||
if cover is None and spot.images:
|
||||
cover = spot.images[0]
|
||||
return SpotBrief(
|
||||
id=spot.id,
|
||||
title=spot.title,
|
||||
city=spot.city,
|
||||
longitude=lng if lng is not None else spot.longitude,
|
||||
latitude=lat if lat is not None else spot.latitude,
|
||||
cover_image_url=cover.image_url if cover else None,
|
||||
audit_status=spot.audit_status,
|
||||
avg_rating=spot.avg_rating,
|
||||
favorite_count=spot.favorite_count or 0,
|
||||
is_free=spot.is_free,
|
||||
price_min=float(spot.price_min) if spot.price_min is not None else None,
|
||||
price_max=float(spot.price_max) if spot.price_max is not None else None,
|
||||
created_at=spot.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=SpotBrief, status_code=status.HTTP_201_CREATED)
|
||||
async def create_spot(
|
||||
payload: SpotCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.services.content_safety import check_text
|
||||
for field_val in [payload.title, payload.description]:
|
||||
if field_val:
|
||||
result = check_text(field_val)
|
||||
if not result["safe"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"内容包含敏感词:{'、'.join(result['matched'][:3])}",
|
||||
)
|
||||
|
||||
point = func.ST_SetSRID(func.ST_MakePoint(payload.longitude, payload.latitude), 4326)
|
||||
spot = Spot(
|
||||
title=payload.title,
|
||||
city=payload.city,
|
||||
location=point,
|
||||
description=payload.description,
|
||||
transport=payload.transport,
|
||||
best_time=payload.best_time,
|
||||
difficulty=payload.difficulty,
|
||||
is_free=payload.is_free,
|
||||
price_min=payload.price_min if not payload.is_free else None,
|
||||
price_max=payload.price_max if not payload.is_free else None,
|
||||
audit_status="pending",
|
||||
creator_id=current_user.id,
|
||||
)
|
||||
db.add(spot)
|
||||
await db.flush()
|
||||
|
||||
for idx, url in enumerate(payload.image_urls):
|
||||
img = SpotImage(
|
||||
spot_id=spot.id,
|
||||
image_url=url,
|
||||
is_cover=(idx == 0),
|
||||
sort_order=idx,
|
||||
)
|
||||
db.add(img)
|
||||
|
||||
if payload.tag_ids:
|
||||
tag_result = await db.execute(
|
||||
select(Tag).where(Tag.id.in_(payload.tag_ids), Tag.is_active.is_(True))
|
||||
)
|
||||
valid_tags = tag_result.scalars().all()
|
||||
for tag in valid_tags:
|
||||
db.add(SpotTag(spot_id=spot.id, tag_id=tag.id))
|
||||
tag.usage_count = (tag.usage_count or 0) + 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(spot)
|
||||
return _spot_to_brief(spot)
|
||||
|
||||
|
||||
@router.get("/", response_model=PageResponse[SpotBrief])
|
||||
async def list_spots(
|
||||
city: str | None = None,
|
||||
tag_id: int | None = None,
|
||||
creator_id: int | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
lng_col = ST_X(Spot.location).label("lng")
|
||||
lat_col = ST_Y(Spot.location).label("lat")
|
||||
query = select(Spot, lng_col, lat_col)
|
||||
count_query = select(func.count(Spot.id))
|
||||
|
||||
is_admin = current_user and current_user.role in ("admin", "moderator")
|
||||
if not is_admin:
|
||||
query = query.where(Spot.audit_status == "approved")
|
||||
count_query = count_query.where(Spot.audit_status == "approved")
|
||||
|
||||
if creator_id is not None:
|
||||
query = query.where(Spot.creator_id == creator_id)
|
||||
count_query = count_query.where(Spot.creator_id == creator_id)
|
||||
|
||||
if city:
|
||||
city_filter = Spot.city.ilike(f"%{city}%")
|
||||
query = query.where(city_filter)
|
||||
count_query = count_query.where(city_filter)
|
||||
|
||||
if tag_id is not None:
|
||||
tag_filter = select(SpotTag.spot_id).where(SpotTag.tag_id == tag_id).scalar_subquery()
|
||||
query = query.where(Spot.id.in_(tag_filter))
|
||||
count_query = count_query.where(Spot.id.in_(tag_filter))
|
||||
|
||||
sort_column = getattr(Spot, sort_by, Spot.created_at)
|
||||
query = query.order_by(sort_column.desc())
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(query.offset(offset).limit(page_size))
|
||||
rows = result.all()
|
||||
|
||||
return PageResponse(
|
||||
total=total,
|
||||
items=[_spot_to_brief(row[0], lng=row[1], lat=row[2]) for row in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mine", response_model=PageResponse[SpotBrief])
|
||||
async def get_my_spots(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
audit_status: str | None = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
lng_col = ST_X(Spot.location).label("lng")
|
||||
lat_col = ST_Y(Spot.location).label("lat")
|
||||
base = select(Spot, lng_col, lat_col).where(Spot.creator_id == current_user.id)
|
||||
count_base = select(func.count(Spot.id)).where(Spot.creator_id == current_user.id)
|
||||
|
||||
if audit_status:
|
||||
base = base.where(Spot.audit_status == audit_status)
|
||||
count_base = count_base.where(Spot.audit_status == audit_status)
|
||||
|
||||
total_result = await db.execute(count_base)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(base.order_by(Spot.created_at.desc()).offset(offset).limit(page_size))
|
||||
rows = result.all()
|
||||
|
||||
return PageResponse(total=total, items=[_spot_to_brief(r[0], lng=r[1], lat=r[2]) for r in rows])
|
||||
|
||||
|
||||
@router.get("/pending", response_model=PageResponse[SpotBrief])
|
||||
async def get_pending_spots(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count(Spot.id)).where(Spot.audit_status == "pending")
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
|
||||
.where(Spot.audit_status == "pending")
|
||||
.order_by(Spot.created_at.asc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return PageResponse(total=total, items=[_spot_to_brief(r[0], lng=r[1], lat=r[2]) for r in rows])
|
||||
|
||||
|
||||
@router.get("/nearby", response_model=list[SpotBrief])
|
||||
async def get_nearby_spots(
|
||||
longitude: float = Query(...),
|
||||
latitude: float = Query(...),
|
||||
radius_km: float = Query(default=5.0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# ST_DWithin with geography uses metres
|
||||
point = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
|
||||
query = (
|
||||
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
|
||||
.where(Spot.audit_status == "approved")
|
||||
.where(
|
||||
ST_DWithin(
|
||||
Spot.location,
|
||||
func.ST_GeogFromWKB(func.ST_AsBinary(point)),
|
||||
radius_km * 1000,
|
||||
)
|
||||
)
|
||||
.limit(50)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
return [_spot_to_brief(r[0], lng=r[1], lat=r[2]) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{spot_id}", response_model=SpotDetail)
|
||||
async def get_spot(
|
||||
spot_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
|
||||
.where(Spot.id == spot_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
spot, lng, lat = row
|
||||
|
||||
is_favorited = False
|
||||
if current_user:
|
||||
fav_result = await db.execute(
|
||||
select(Favorite).where(
|
||||
Favorite.user_id == current_user.id, Favorite.spot_id == spot_id
|
||||
)
|
||||
)
|
||||
is_favorited = fav_result.scalar_one_or_none() is not None
|
||||
|
||||
cover = next((img for img in spot.images if img.is_cover), None)
|
||||
if cover is None and spot.images:
|
||||
cover = spot.images[0]
|
||||
|
||||
return SpotDetail(
|
||||
id=spot.id,
|
||||
title=spot.title,
|
||||
city=spot.city,
|
||||
longitude=lng,
|
||||
latitude=lat,
|
||||
cover_image_url=cover.image_url if cover else None,
|
||||
audit_status=spot.audit_status,
|
||||
avg_rating=spot.avg_rating,
|
||||
is_free=spot.is_free,
|
||||
price_min=float(spot.price_min) if spot.price_min is not None else None,
|
||||
price_max=float(spot.price_max) if spot.price_max is not None else None,
|
||||
created_at=spot.created_at,
|
||||
description=spot.description,
|
||||
transport=spot.transport,
|
||||
best_time=spot.best_time,
|
||||
difficulty=spot.difficulty,
|
||||
creator=spot.creator,
|
||||
images=[
|
||||
SpotImageOut.model_validate(img) for img in spot.images
|
||||
],
|
||||
tags=spot.tags,
|
||||
rating_count=spot.rating_count,
|
||||
is_favorited=is_favorited,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{spot_id}", response_model=SpotBrief)
|
||||
async def update_spot(
|
||||
spot_id: int,
|
||||
payload: SpotUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if spot.creator_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
tag_ids = update_data.pop("tag_ids", None)
|
||||
|
||||
if "longitude" in update_data or "latitude" in update_data:
|
||||
lng = update_data.pop("longitude", spot.longitude)
|
||||
lat = update_data.pop("latitude", spot.latitude)
|
||||
spot.location = func.ST_SetSRID(func.ST_MakePoint(lng, lat), 4326)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(spot, field, value)
|
||||
|
||||
if tag_ids is not None:
|
||||
await db.execute(
|
||||
select(SpotTag).where(SpotTag.spot_id == spot_id)
|
||||
)
|
||||
from sqlalchemy import delete as sa_delete
|
||||
await db.execute(sa_delete(SpotTag).where(SpotTag.spot_id == spot_id))
|
||||
if tag_ids:
|
||||
tag_result = await db.execute(
|
||||
select(Tag).where(Tag.id.in_(tag_ids), Tag.is_active.is_(True))
|
||||
)
|
||||
valid_tags = tag_result.scalars().all()
|
||||
for tag in valid_tags:
|
||||
db.add(SpotTag(spot_id=spot.id, tag_id=tag.id))
|
||||
|
||||
db.add(spot)
|
||||
await db.commit()
|
||||
await db.refresh(spot)
|
||||
return _spot_to_brief(spot)
|
||||
|
||||
|
||||
@router.delete("/{spot_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_spot(
|
||||
spot_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if spot.creator_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
# Soft delete via audit_status
|
||||
spot.audit_status = "deleted"
|
||||
db.add(spot)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{spot_id}/approve")
|
||||
async def approve_spot(
|
||||
spot_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
spot.audit_status = "approved"
|
||||
spot.reject_reason = None
|
||||
db.add(spot)
|
||||
|
||||
await grant_points(db, spot.creator_id, 10, "地点审核通过", "spot_approved", spot.id)
|
||||
await log_action(db, current_user.id, "spot.approve", "spot", spot.id)
|
||||
await send_notification(
|
||||
db, spot.creator_id, "audit",
|
||||
f"您投稿的「{spot.title}」已通过审核",
|
||||
content="恭喜!您的取景地投稿已通过审核,现已公开展示。",
|
||||
ref_type="spot", ref_id=spot.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "approved"}
|
||||
|
||||
|
||||
@router.post("/{spot_id}/reject")
|
||||
async def reject_spot(
|
||||
spot_id: int,
|
||||
payload: RejectPayload,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
spot.audit_status = "rejected"
|
||||
spot.reject_reason = payload.reason
|
||||
db.add(spot)
|
||||
|
||||
await log_action(
|
||||
db, current_user.id, "spot.reject", "spot", spot.id,
|
||||
detail={"reason": payload.reason},
|
||||
)
|
||||
await send_notification(
|
||||
db, spot.creator_id, "audit",
|
||||
f"您投稿的「{spot.title}」未通过审核",
|
||||
content=f"原因:{payload.reason}",
|
||||
ref_type="spot", ref_id=spot.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "rejected"}
|
||||
|
||||
|
||||
@router.post("/{spot_id}/images", response_model=SpotImageOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_spot_image(
|
||||
spot_id: int,
|
||||
payload: SpotImageCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if spot.creator_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
image = SpotImage(
|
||||
spot_id=spot_id,
|
||||
image_url=payload.image_url,
|
||||
is_cover=payload.is_cover,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
db.add(image)
|
||||
await db.commit()
|
||||
await db.refresh(image)
|
||||
return image
|
||||
@@ -0,0 +1,63 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.comment import Comment
|
||||
from app.models.event import Event, EventRegistration
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.promotion import Promotion
|
||||
from app.models.rating import Rating
|
||||
from app.models.shooting import ShootingRequest, ShootingApplication
|
||||
from app.models.spot import Spot
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_stats_overview(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if current_user.role not in ("admin", "moderator"):
|
||||
return {"detail": "Not allowed"}
|
||||
|
||||
user_count = (await db.execute(select(func.count(User.id)))).scalar() or 0
|
||||
spot_count = (await db.execute(select(func.count(Spot.id)))).scalar() or 0
|
||||
approved_spot_count = (await db.execute(
|
||||
select(func.count(Spot.id)).where(Spot.audit_status == "approved")
|
||||
)).scalar() or 0
|
||||
pending_spot_count = (await db.execute(
|
||||
select(func.count(Spot.id)).where(Spot.audit_status == "pending")
|
||||
)).scalar() or 0
|
||||
comment_count = (await db.execute(select(func.count(Comment.id)))).scalar() or 0
|
||||
rating_count = (await db.execute(select(func.count(Rating.id)))).scalar() or 0
|
||||
favorite_count = (await db.execute(select(func.count(Favorite.id)))).scalar() or 0
|
||||
shooting_count = (await db.execute(select(func.count(ShootingRequest.id)))).scalar() or 0
|
||||
event_count = (await db.execute(select(func.count(Event.id)))).scalar() or 0
|
||||
|
||||
promo_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(Promotion.impressions), 0),
|
||||
func.coalesce(func.sum(Promotion.clicks), 0),
|
||||
)
|
||||
)
|
||||
promo_row = promo_result.one()
|
||||
total_impressions = int(promo_row[0])
|
||||
total_clicks = int(promo_row[1])
|
||||
|
||||
return {
|
||||
"user_count": user_count,
|
||||
"spot_count": spot_count,
|
||||
"approved_spot_count": approved_spot_count,
|
||||
"pending_spot_count": pending_spot_count,
|
||||
"comment_count": comment_count,
|
||||
"rating_count": rating_count,
|
||||
"favorite_count": favorite_count,
|
||||
"shooting_count": shooting_count,
|
||||
"event_count": event_count,
|
||||
"promo_impressions": total_impressions,
|
||||
"promo_clicks": total_clicks,
|
||||
"promo_ctr": round(total_clicks / total_impressions * 100, 2) if total_impressions > 0 else 0,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.models.spot import Spot
|
||||
from app.models.tag import SpotTag, Tag
|
||||
from app.models.user import User
|
||||
from app.schemas.tag import TagOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TagAttach(BaseModel):
|
||||
tag_id: int
|
||||
|
||||
|
||||
@router.get("/tags", response_model=list[TagOut])
|
||||
async def list_tags(
|
||||
sort: str = Query(default="hot", regex="^(hot|name)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Tag).where(Tag.is_active.is_(True))
|
||||
if sort == "hot":
|
||||
query = query.order_by(Tag.usage_count.desc())
|
||||
else:
|
||||
query = query.order_by(Tag.name.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/spots/{spot_id}/tags", status_code=status.HTTP_201_CREATED)
|
||||
async def add_tag_to_spot(
|
||||
spot_id: int,
|
||||
payload: TagAttach,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
tag_result = await db.execute(select(Tag).where(Tag.id == payload.tag_id))
|
||||
tag = tag_result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(SpotTag).where(SpotTag.spot_id == spot_id, SpotTag.tag_id == payload.tag_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tag already added")
|
||||
|
||||
spot_tag = SpotTag(spot_id=spot_id, tag_id=payload.tag_id)
|
||||
db.add(spot_tag)
|
||||
tag.usage_count = (tag.usage_count or 0) + 1
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "Tag added"}
|
||||
|
||||
|
||||
@router.delete("/spots/{spot_id}/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_tag_from_spot(
|
||||
spot_id: int,
|
||||
tag_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
spot_result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||||
spot = spot_result.scalar_one_or_none()
|
||||
if not spot:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
|
||||
|
||||
is_admin = current_user.role in ("admin", "moderator")
|
||||
if spot.creator_id != current_user.id and not is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
link_result = await db.execute(
|
||||
select(SpotTag).where(SpotTag.spot_id == spot_id, SpotTag.tag_id == tag_id)
|
||||
)
|
||||
link = link_result.scalar_one_or_none()
|
||||
if not link:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not linked to spot")
|
||||
|
||||
await db.delete(link)
|
||||
|
||||
tag_result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||||
tag = tag_result.scalar_one_or_none()
|
||||
if tag and tag.usage_count > 0:
|
||||
tag.usage_count -= 1
|
||||
|
||||
await db.commit()
|
||||
@@ -0,0 +1,71 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
|
||||
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.core.rate_limit import RateLimiter
|
||||
from app.core.storage import S3StorageBackend
|
||||
from app.models.user import User
|
||||
from app.schemas.upload import PresignedUrlRequest, PresignedUrlResponse, UploadResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
@router.post(
|
||||
"/image",
|
||||
response_model=UploadResponse,
|
||||
dependencies=[Depends(RateLimiter(times=20, seconds=60))],
|
||||
)
|
||||
async def upload_image(
|
||||
request: Request,
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {file.content_type}",
|
||||
)
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="File too large, max 10MB",
|
||||
)
|
||||
|
||||
storage = request.app.state.storage
|
||||
url = storage.upload(data, file.filename or "image.jpg", file.content_type or "")
|
||||
|
||||
return UploadResponse(url=url, filename=file.filename or "image.jpg")
|
||||
|
||||
|
||||
@router.post("/presigned", response_model=PresignedUrlResponse)
|
||||
async def get_presigned_upload_url(
|
||||
request: Request,
|
||||
payload: PresignedUrlRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
storage = request.app.state.storage
|
||||
if not isinstance(storage, S3StorageBackend):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Presigned URLs only available with S3 storage backend",
|
||||
)
|
||||
|
||||
ext = Path(payload.filename).suffix or ".jpg"
|
||||
file_key = f"images/{uuid.uuid4().hex}{ext}"
|
||||
upload_url = storage.generate_presigned_url(
|
||||
file_key, content_type=payload.content_type
|
||||
)
|
||||
public_url = storage._get_url(file_key)
|
||||
|
||||
return PresignedUrlResponse(
|
||||
upload_url=upload_url,
|
||||
file_key=file_key,
|
||||
public_url=public_url,
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.rating import Rating
|
||||
from app.models.spot import Spot
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserInfo, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserStats(BaseModel):
|
||||
spot_count: int = 0
|
||||
approved_count: int = 0
|
||||
favorite_count: int = 0
|
||||
rating_received: int = 0
|
||||
|
||||
|
||||
class ChangePassword(BaseModel):
|
||||
old_password: str = Field(..., min_length=6)
|
||||
new_password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfo)
|
||||
async def get_me(current_user: User = Depends(get_current_active_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/me/stats", response_model=UserStats)
|
||||
async def get_my_stats(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
spot_count = (await db.execute(
|
||||
select(func.count(Spot.id)).where(Spot.creator_id == current_user.id)
|
||||
)).scalar() or 0
|
||||
|
||||
approved_count = (await db.execute(
|
||||
select(func.count(Spot.id)).where(
|
||||
Spot.creator_id == current_user.id, Spot.audit_status == "approved"
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
favorite_count = (await db.execute(
|
||||
select(func.count(Favorite.id)).where(Favorite.user_id == current_user.id)
|
||||
)).scalar() or 0
|
||||
|
||||
rating_received = (await db.execute(
|
||||
select(func.count(Rating.id)).where(
|
||||
Rating.spot_id.in_(
|
||||
select(Spot.id).where(Spot.creator_id == current_user.id)
|
||||
)
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
return UserStats(
|
||||
spot_count=spot_count,
|
||||
approved_count=approved_count,
|
||||
favorite_count=favorite_count,
|
||||
rating_received=rating_received,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserInfo)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
db.add(current_user)
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/me/change-password")
|
||||
async def change_password(
|
||||
payload: ChangePassword,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not verify_password(payload.old_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="旧密码不正确",
|
||||
)
|
||||
current_user.password_hash = get_password_hash(payload.new_password)
|
||||
db.add(current_user)
|
||||
await db.commit()
|
||||
return {"code": 0, "message": "密码修改成功"}
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserInfo)
|
||||
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import admin, app_nav_config, auth, comments, corrections, events, favorites, map_proxy, membership, notifications, points, promotions, ratings, search, shooting, spots, stats, tags, upload, users
|
||||
|
||||
v1_router = APIRouter()
|
||||
|
||||
v1_router.include_router(auth.router, prefix="/auth", tags=["认证"])
|
||||
v1_router.include_router(users.router, prefix="/users", tags=["用户"])
|
||||
v1_router.include_router(spots.router, prefix="/spots", tags=["地点"])
|
||||
v1_router.include_router(favorites.router, prefix="/favorites", tags=["收藏"])
|
||||
v1_router.include_router(upload.router, prefix="/upload", tags=["上传"])
|
||||
v1_router.include_router(points.router, prefix="/points", tags=["积分"])
|
||||
v1_router.include_router(comments.router, tags=["评论"])
|
||||
v1_router.include_router(ratings.router, tags=["评分"])
|
||||
v1_router.include_router(tags.router, tags=["标签"])
|
||||
v1_router.include_router(search.router, prefix="/search", tags=["搜索"])
|
||||
v1_router.include_router(corrections.router, tags=["校正建议"])
|
||||
v1_router.include_router(map_proxy.router, prefix="/map", tags=["地图"])
|
||||
v1_router.include_router(notifications.router, prefix="/notifications", tags=["通知"])
|
||||
v1_router.include_router(shooting.router, prefix="/shooting", tags=["约拍"])
|
||||
v1_router.include_router(events.router, prefix="/events", tags=["活动"])
|
||||
v1_router.include_router(promotions.router, prefix="/promotions", tags=["推广"])
|
||||
v1_router.include_router(membership.router, prefix="/membership", tags=["会员"])
|
||||
v1_router.include_router(stats.router, prefix="/stats", tags=["统计"])
|
||||
v1_router.include_router(app_nav_config.router, prefix="/ui-config", tags=["前端配置"])
|
||||
v1_router.include_router(admin.router, prefix="/admin", tags=["管理端"])
|
||||
@@ -0,0 +1,31 @@
|
||||
from celery import Celery
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"ciyuan_viewfinder",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_acks_late=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
)
|
||||
|
||||
from celery.schedules import crontab
|
||||
|
||||
celery_app.conf.beat_schedule = {
|
||||
"aggregate-daily-stats": {
|
||||
"task": "app.tasks.stats_tasks.aggregate_daily_stats",
|
||||
"schedule": 3600.0,
|
||||
},
|
||||
}
|
||||
|
||||
celery_app.autodiscover_tasks(["app.tasks"])
|
||||
@@ -0,0 +1,31 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
DATABASE_URL_SYNC: str
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
SECRET_KEY: str
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 43200
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 60
|
||||
|
||||
STORAGE_BACKEND: str = "local"
|
||||
LOCAL_STORAGE_PATH: str = "./uploads"
|
||||
S3_ENDPOINT: str = ""
|
||||
S3_ACCESS_KEY: str = ""
|
||||
S3_SECRET_KEY: str = ""
|
||||
S3_BUCKET: str = "ciyuan-viewfinder"
|
||||
S3_REGION: str = ""
|
||||
S3_PUBLIC_URL: str = ""
|
||||
|
||||
TENCENT_MAP_KEY: str = ""
|
||||
|
||||
SENTRY_DSN: str = ""
|
||||
LOG_JSON: bool = False
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,69 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None or payload.get("type") != "access":
|
||||
raise credentials_exception
|
||||
except ValueError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
token: str | None = Depends(OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User | None:
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None or payload.get("type") != "access":
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
if record.exc_info and record.exc_info[1]:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
|
||||
def setup_logging(json_format: bool = False, level: int = logging.INFO):
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
|
||||
for handler in root.handlers[:]:
|
||||
root.removeHandler(handler)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
if json_format:
|
||||
handler.setFormatter(JSONFormatter())
|
||||
else:
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
||||
))
|
||||
root.addHandler(handler)
|
||||
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Sliding window rate limiter backed by Redis.
|
||||
|
||||
Usage as FastAPI dependency:
|
||||
@router.post("/action", dependencies=[Depends(RateLimiter(times=5, seconds=60))])
|
||||
"""
|
||||
|
||||
def __init__(self, times: int = 10, seconds: int = 60):
|
||||
self.times = times
|
||||
self.seconds = seconds
|
||||
|
||||
async def __call__(self, request: Request) -> None:
|
||||
redis = getattr(request.app.state, "redis", None)
|
||||
if redis is None:
|
||||
return
|
||||
|
||||
identifier = self._get_identifier(request)
|
||||
key = f"rl:{request.url.path}:{identifier}"
|
||||
|
||||
try:
|
||||
pipe = redis.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, self.seconds)
|
||||
results = await pipe.execute()
|
||||
current = results[0]
|
||||
except RedisError:
|
||||
logger.warning("Rate limiter skipped because Redis is unavailable", exc_info=True)
|
||||
return
|
||||
|
||||
if current > self.times:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many requests, please try again later",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_identifier(request: Request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: str | int, expires_delta: timedelta | None = None
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + (
|
||||
expires_delta
|
||||
if expires_delta
|
||||
else timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode = {"sub": str(subject), "exp": expire, "type": "access"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str | int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"sub": str(subject), "exp": expire, "type": "refresh"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError as exc:
|
||||
raise ValueError("Invalid token") from exc
|
||||
return payload
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class StorageBackend(ABC):
|
||||
@abstractmethod
|
||||
def upload(self, file_data: bytes, filename: str, content_type: str = "") -> str:
|
||||
"""Upload file and return its public URL."""
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete a file by key/path."""
|
||||
|
||||
@abstractmethod
|
||||
def generate_presigned_url(self, key: str, content_type: str = "", expires: int = 3600) -> str:
|
||||
"""Generate a presigned upload URL (S3-like backends only)."""
|
||||
|
||||
|
||||
class LocalStorageBackend(StorageBackend):
|
||||
def __init__(self, storage_path: str, serve_url_prefix: str = "/uploads"):
|
||||
self.storage_path = Path(storage_path)
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
self.serve_url_prefix = serve_url_prefix.rstrip("/")
|
||||
|
||||
def _make_key(self, filename: str) -> str:
|
||||
ext = Path(filename).suffix
|
||||
return f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
def upload(self, file_data: bytes, filename: str, content_type: str = "") -> str:
|
||||
key = self._make_key(filename)
|
||||
dest = self.storage_path / key
|
||||
dest.write_bytes(file_data)
|
||||
return f"{self.serve_url_prefix}/{key}"
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
path = key.lstrip("/")
|
||||
if path.startswith("uploads/"):
|
||||
path = path[len("uploads/"):]
|
||||
target = self.storage_path / path
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
|
||||
def generate_presigned_url(self, key: str, content_type: str = "", expires: int = 3600) -> str:
|
||||
raise NotImplementedError("Local storage does not support presigned URLs")
|
||||
|
||||
|
||||
class S3StorageBackend(StorageBackend):
|
||||
"""Compatible with MinIO, Aliyun OSS, Tencent COS, and AWS S3."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
bucket: str,
|
||||
region: str = "",
|
||||
public_url: str = "",
|
||||
):
|
||||
self.bucket = bucket
|
||||
self.public_url = public_url.rstrip("/") if public_url else ""
|
||||
kwargs: dict = {
|
||||
"endpoint_url": endpoint,
|
||||
"aws_access_key_id": access_key,
|
||||
"aws_secret_access_key": secret_key,
|
||||
"config": BotoConfig(signature_version="s3v4"),
|
||||
}
|
||||
if region:
|
||||
kwargs["region_name"] = region
|
||||
self.client = boto3.client("s3", **kwargs)
|
||||
|
||||
try:
|
||||
self.client.head_bucket(Bucket=bucket)
|
||||
except Exception:
|
||||
try:
|
||||
self.client.create_bucket(Bucket=bucket)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _make_key(self, filename: str) -> str:
|
||||
ext = Path(filename).suffix
|
||||
return f"images/{uuid.uuid4().hex}{ext}"
|
||||
|
||||
def _get_url(self, key: str) -> str:
|
||||
if self.public_url:
|
||||
return f"{self.public_url}/{key}"
|
||||
return f"{self.client.meta.endpoint_url}/{self.bucket}/{key}"
|
||||
|
||||
def upload(self, file_data: bytes, filename: str, content_type: str = "") -> str:
|
||||
key = self._make_key(filename)
|
||||
extra = {}
|
||||
if content_type:
|
||||
extra["ContentType"] = content_type
|
||||
self.client.put_object(Bucket=self.bucket, Key=key, Body=file_data, **extra)
|
||||
return self._get_url(key)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||||
|
||||
def generate_presigned_url(self, key: str, content_type: str = "", expires: int = 3600) -> str:
|
||||
params: dict = {"Bucket": self.bucket, "Key": key}
|
||||
if content_type:
|
||||
params["ContentType"] = content_type
|
||||
return self.client.generate_presigned_url(
|
||||
"put_object", Params=params, ExpiresIn=expires
|
||||
)
|
||||
|
||||
|
||||
def get_storage_backend() -> StorageBackend:
|
||||
backend = settings.STORAGE_BACKEND.lower()
|
||||
if backend == "s3":
|
||||
return S3StorageBackend(
|
||||
endpoint=settings.S3_ENDPOINT,
|
||||
access_key=settings.S3_ACCESS_KEY,
|
||||
secret_key=settings.S3_SECRET_KEY,
|
||||
bucket=settings.S3_BUCKET,
|
||||
region=settings.S3_REGION,
|
||||
public_url=settings.S3_PUBLIC_URL,
|
||||
)
|
||||
return LocalStorageBackend(
|
||||
storage_path=settings.LOCAL_STORAGE_PATH,
|
||||
)
|
||||
|
||||
|
||||
storage: StorageBackend | None = None
|
||||
|
||||
|
||||
def init_storage() -> StorageBackend:
|
||||
global storage
|
||||
storage = get_storage_backend()
|
||||
return storage
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def ensure_postgis_available() -> None:
|
||||
engine = create_engine(settings.DATABASE_URL_SYNC, echo=False)
|
||||
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
result = connection.execute(
|
||||
text("select 1 from pg_available_extensions where name = 'postgis'")
|
||||
)
|
||||
if result.scalar() != 1:
|
||||
raise RuntimeError(
|
||||
"PostGIS is not available on the PostgreSQL server. "
|
||||
"Install/enable PostGIS before starting the backend."
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def run_startup_migrations() -> None:
|
||||
ensure_postgis_available()
|
||||
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
alembic_ini = project_root / "alembic.ini"
|
||||
alembic_dir = project_root / "alembic"
|
||||
|
||||
config = Config(str(alembic_ini))
|
||||
config.set_main_option("script_location", str(alembic_dir))
|
||||
|
||||
command.upgrade(config, "head")
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
# sqladmin requires a synchronous engine
|
||||
sync_engine = create_engine(settings.DATABASE_URL_SYNC, echo=False)
|
||||
@@ -0,0 +1,194 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from geoalchemy2.functions import ST_X, ST_Y
|
||||
from sqlalchemy import select
|
||||
from app.api.v1.router import v1_router
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.core.storage import init_storage
|
||||
from app.db.migrations import run_startup_migrations
|
||||
from app.models.spot import Spot
|
||||
from app.models.tag import Tag
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import AdminSpotDetailItem
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
if settings.SENTRY_DSN:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
sentry_sdk.init(
|
||||
dsn=settings.SENTRY_DSN,
|
||||
integrations=[FastApiIntegration(), SqlalchemyIntegration()],
|
||||
traces_sample_rate=0.2,
|
||||
send_default_pii=False,
|
||||
)
|
||||
|
||||
from app.core.logging_config import setup_logging
|
||||
|
||||
setup_logging(
|
||||
json_format=settings.LOG_JSON,
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await asyncio.to_thread(run_startup_migrations)
|
||||
app.state.redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
app.state.storage = init_storage()
|
||||
yield
|
||||
if app.state.redis:
|
||||
await app.state.redis.aclose()
|
||||
|
||||
|
||||
app = FastAPI(title="次元取景器 API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
|
||||
def _assert_admin_role(user: User) -> None:
|
||||
if user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin permission required",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/spot-tag-options")
|
||||
async def admin_spot_tag_options_fallback(
|
||||
keyword: str = "",
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_assert_admin_role(current_user)
|
||||
stmt = select(Tag.id, Tag.name).where(Tag.is_active.is_(True)).order_by(Tag.usage_count.desc(), Tag.id.desc()).limit(limit)
|
||||
if keyword:
|
||||
like = f"%{keyword.strip()}%"
|
||||
stmt = (
|
||||
select(Tag.id, Tag.name)
|
||||
.where(Tag.is_active.is_(True), Tag.name.ilike(like))
|
||||
.order_by(Tag.usage_count.desc(), Tag.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [{"id": int(r[0]), "title": str(r[1])} for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/spots/{spot_id}", response_model=AdminSpotDetailItem)
|
||||
async def admin_spot_detail_fallback(
|
||||
spot_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_assert_admin_role(current_user)
|
||||
result = await db.execute(
|
||||
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
|
||||
.where(Spot.id == spot_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return AdminSpotDetailItem(
|
||||
id=spot_id,
|
||||
title="",
|
||||
city="",
|
||||
longitude=0.0,
|
||||
latitude=0.0,
|
||||
description=None,
|
||||
transport=None,
|
||||
best_time=None,
|
||||
difficulty=None,
|
||||
is_free=True,
|
||||
price_min=None,
|
||||
price_max=None,
|
||||
audit_status="deleted",
|
||||
reject_reason="spot_not_found",
|
||||
creator_id=current_user.id,
|
||||
tag_ids=[],
|
||||
image_urls=[],
|
||||
images=[],
|
||||
)
|
||||
spot, lng, lat = row
|
||||
image_urls = [img.image_url for img in sorted(spot.images, key=lambda x: x.sort_order)]
|
||||
tag_ids = [tag.id for tag in spot.tags]
|
||||
return AdminSpotDetailItem(
|
||||
id=spot.id,
|
||||
title=spot.title,
|
||||
city=spot.city,
|
||||
longitude=lng if lng is not None else spot.longitude,
|
||||
latitude=lat if lat is not None else spot.latitude,
|
||||
description=spot.description,
|
||||
transport=spot.transport,
|
||||
best_time=spot.best_time,
|
||||
difficulty=spot.difficulty,
|
||||
is_free=spot.is_free,
|
||||
price_min=float(spot.price_min) if spot.price_min is not None else None,
|
||||
price_max=float(spot.price_max) if spot.price_max is not None else None,
|
||||
audit_status=spot.audit_status,
|
||||
reject_reason=spot.reject_reason,
|
||||
creator_id=spot.creator_id,
|
||||
tag_ids=tag_ids,
|
||||
image_urls=image_urls,
|
||||
images=[
|
||||
{
|
||||
"id": img.id,
|
||||
"spot_id": img.spot_id,
|
||||
"image_url": img.image_url,
|
||||
"is_cover": img.is_cover,
|
||||
"sort_order": img.sort_order,
|
||||
"created_at": img.created_at,
|
||||
}
|
||||
for img in sorted(spot.images, key=lambda x: x.sort_order)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_5xx_response(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if response.status_code >= 500:
|
||||
logger.error("Server 5xx response %s %s -> %s", request.method, request.url.path, response.status_code)
|
||||
return response
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(
|
||||
"Unhandled exception on %s %s: %s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
repr(exc),
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal Server Error"},
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if settings.STORAGE_BACKEND == "local":
|
||||
uploads_dir = Path(settings.LOCAL_STORAGE_PATH)
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
|
||||
|
||||
app.include_router(v1_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def health_check():
|
||||
return {"status": "ok", "name": "次元取景器 API"}
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.models.user import User
|
||||
from app.models.spot import Spot, SpotImage
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.point_ledger import PointLedger
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.comment import Comment
|
||||
from app.models.report import Report
|
||||
from app.models.rating import Rating
|
||||
from app.models.tag import Tag, SpotTag
|
||||
from app.models.correction import Correction
|
||||
from app.models.notification import Notification
|
||||
from app.models.shooting import ShootingRequest, ShootingApplication
|
||||
from app.models.event import Event, EventRegistration, EventPhoto
|
||||
from app.models.promotion import Promotion
|
||||
from app.models.membership import MembershipPlan, UserMembership
|
||||
from app.models.app_nav_config import AppNavConfig
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
__all__ = [
|
||||
"User", "Spot", "SpotImage", "Favorite", "PointLedger", "AuditLog",
|
||||
"Comment", "Report", "Rating", "Tag", "SpotTag", "Correction", "Notification",
|
||||
"ShootingRequest", "ShootingApplication",
|
||||
"Event", "EventRegistration", "EventPhoto",
|
||||
"Promotion", "MembershipPlan", "UserMembership",
|
||||
"AppNavConfig", "SystemConfig",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AppNavConfig(Base):
|
||||
__tablename__ = "app_nav_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
page_path: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
icon: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
active_icon: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(20), default="#999999")
|
||||
active_color: Mapped[str] = mapped_column(String(20), default="#6366f1")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AppNavConfig {self.key}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
operator_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
target_id: Mapped[int | None] = mapped_column(Integer)
|
||||
detail: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
operator = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog {self.id} {self.action} by={self.operator_id}>"
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("comments.id"), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="approved")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
replies = relationship(
|
||||
"Comment",
|
||||
back_populates="parent",
|
||||
lazy="noload",
|
||||
foreign_keys=[parent_id],
|
||||
)
|
||||
parent = relationship(
|
||||
"Comment",
|
||||
remote_side=[id],
|
||||
back_populates="replies",
|
||||
lazy="noload",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Comment {self.id} spot={self.spot_id}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Correction(Base):
|
||||
__tablename__ = "corrections"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
field_name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
suggested_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", lazy="joined")
|
||||
spot = relationship("Spot", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Correction {self.id} spot={self.spot_id} field={self.field_name}>"
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, ForeignKey, Integer,
|
||||
String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
cover_url: Mapped[str | None] = mapped_column(String(500))
|
||||
location_name: Mapped[str | None] = mapped_column(String(200))
|
||||
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
max_participants: Mapped[int] = mapped_column(Integer, default=0)
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="upcoming", index=True)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
registration_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
registrations = relationship("EventRegistration", back_populates="event", lazy="selectin")
|
||||
photos = relationship("EventPhoto", back_populates="event", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Event {self.id} {self.title}>"
|
||||
|
||||
|
||||
class EventRegistration(Base):
|
||||
__tablename__ = "event_registrations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(Integer, ForeignKey("events.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="registered")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
event = relationship("Event", back_populates="registrations")
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<EventRegistration {self.id}>"
|
||||
|
||||
|
||||
class EventPhoto(Base):
|
||||
__tablename__ = "event_photos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(Integer, ForeignKey("events.id"), nullable=False, index=True)
|
||||
uploader_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
image_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
caption: Mapped[str | None] = mapped_column(String(200))
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
event = relationship("Event", back_populates="photos")
|
||||
uploader = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<EventPhoto {self.id}>"
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Favorite(Base):
|
||||
__tablename__ = "favorites"
|
||||
__table_args__ = (UniqueConstraint("user_id", "spot_id", name="uq_user_spot"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
spot_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("spots.id"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Favorite user={self.user_id} spot={self.spot_id}>"
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, ForeignKey, Integer, Numeric,
|
||||
String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class MembershipPlan(Base):
|
||||
"""会员方案"""
|
||||
__tablename__ = "membership_plans"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
benefits: Mapped[str | None] = mapped_column(Text)
|
||||
extra_uploads: Mapped[int] = mapped_column(Integer, default=0)
|
||||
extra_top_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MembershipPlan {self.id} {self.name}>"
|
||||
|
||||
|
||||
class UserMembership(Base):
|
||||
"""用户会员记录"""
|
||||
__tablename__ = "user_memberships"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
plan_id: Mapped[int] = mapped_column(Integer, ForeignKey("membership_plans.id"), nullable=False)
|
||||
start_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
plan = relationship("MembershipPlan", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserMembership {self.id} user={self.user_id}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
content: Mapped[str | None] = mapped_column(Text)
|
||||
ref_type: Mapped[str | None] = mapped_column(String(50))
|
||||
ref_id: Mapped[int | None] = mapped_column(Integer)
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Notification {self.id} user={self.user_id} type={self.type}>"
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PointLedger(Base):
|
||||
__tablename__ = "point_ledger"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
change: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
balance: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
ref_type: Mapped[str | None] = mapped_column(String(50))
|
||||
ref_id: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PointLedger {self.id} user={self.user_id} change={self.change}>"
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime, ForeignKey, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Promotion(Base):
|
||||
"""推广位/Banner"""
|
||||
__tablename__ = "promotions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
image_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
link_type: Mapped[str] = mapped_column(String(20), default="spot")
|
||||
link_id: Mapped[int | None] = mapped_column(Integer)
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
event_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("events.id"), nullable=True)
|
||||
shooting_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("shooting_requests.id"), nullable=True)
|
||||
link_url: Mapped[str | None] = mapped_column(String(500))
|
||||
position: Mapped[str] = mapped_column(String(30), default="home_banner", index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
is_active: Mapped[bool] = mapped_column(default=True, index=True)
|
||||
impressions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
clicks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
event = relationship("Event", lazy="selectin")
|
||||
shooting = relationship("ShootingRequest", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Promotion {self.id} {self.title}>"
|
||||
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Rating(Base):
|
||||
__tablename__ = "ratings"
|
||||
__table_args__ = (UniqueConstraint("user_id", "spot_id", name="uq_user_spot_rating"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
score: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
short_comment: Mapped[str | None] = mapped_column(String(200))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Rating {self.id} spot={self.spot_id} score={self.score}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Report(Base):
|
||||
__tablename__ = "reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
reporter_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
handler_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
conclusion: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
reporter = relationship("User", foreign_keys=[reporter_id], lazy="selectin")
|
||||
handler = relationship("User", foreign_keys=[handler_id], lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Report {self.id} {self.target_type}:{self.target_id}>"
|
||||
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, Float, ForeignKey, Integer, Numeric,
|
||||
String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ShootingRequest(Base):
|
||||
__tablename__ = "shooting_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
style: Mapped[str | None] = mapped_column(String(100))
|
||||
shoot_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
is_free: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
budget_min: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
budget_max: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
role_needed: Mapped[str] = mapped_column(String(20), default="photographer")
|
||||
max_applicants: Mapped[int] = mapped_column(Integer, default=1)
|
||||
contact_info: Mapped[str | None] = mapped_column(String(200))
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="open", index=True)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
applications = relationship("ShootingApplication", back_populates="request", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ShootingRequest {self.id} {self.title}>"
|
||||
|
||||
|
||||
class ShootingApplication(Base):
|
||||
__tablename__ = "shooting_applications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
request_id: Mapped[int] = mapped_column(Integer, ForeignKey("shooting_requests.id"), nullable=False, index=True)
|
||||
applicant_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
message: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
request = relationship("ShootingRequest", back_populates="applications")
|
||||
applicant = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ShootingApplication {self.id} req={self.request_id}>"
|
||||
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Spot(Base):
|
||||
__tablename__ = "spots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
location: Mapped[str] = mapped_column(
|
||||
Geometry("POINT", srid=4326), nullable=False
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
transport: Mapped[str | None] = mapped_column(Text)
|
||||
best_time: Mapped[str | None] = mapped_column(String(200))
|
||||
difficulty: Mapped[str | None] = mapped_column(Text)
|
||||
is_free: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
price_min: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
price_max: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
avg_rating: Mapped[float | None] = mapped_column(Float, default=None)
|
||||
rating_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
favorite_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
creator_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
creator = relationship("User", lazy="selectin")
|
||||
images = relationship(
|
||||
"SpotImage", back_populates="spot", lazy="selectin", order_by="SpotImage.sort_order"
|
||||
)
|
||||
tags = relationship("Tag", secondary="spot_tags", lazy="selectin")
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
if self.location is None:
|
||||
return None
|
||||
# WKBElement → use ST_X via a query; for serialisation we parse the WKT
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
point = to_shape(self.location)
|
||||
return point.x
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
if self.location is None:
|
||||
return None
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
point = to_shape(self.location)
|
||||
return point.y
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Spot {self.id} {self.title}>"
|
||||
|
||||
|
||||
class SpotImage(Base):
|
||||
__tablename__ = "spot_images"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("spots.id"), nullable=False
|
||||
)
|
||||
image_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
is_cover: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
spot = relationship("Spot", back_populates="images")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SpotImage {self.id} spot={self.spot_id}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
config_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
config_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_by: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SystemConfig {self.config_key}>"
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||
category: Mapped[str | None] = mapped_column(String(50))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tag {self.id} {self.name}>"
|
||||
|
||||
|
||||
class SpotTag(Base):
|
||||
__tablename__ = "spot_tags"
|
||||
__table_args__ = (UniqueConstraint("spot_id", "tag_id", name="uq_spot_tag"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False)
|
||||
tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("tags.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(20), unique=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
nickname: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500))
|
||||
city: Mapped[str | None] = mapped_column(String(100))
|
||||
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
identity: Mapped[str] = mapped_column(String(20), default="both")
|
||||
role: Mapped[str] = mapped_column(String(20), default="user")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.id} {self.nickname}>"
|
||||
@@ -0,0 +1,608 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
account: str = Field(min_length=1, max_length=100)
|
||||
password: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
role: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: AdminUserOut
|
||||
|
||||
|
||||
class AdminSpotListItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSpotImageItem(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
is_cover: bool
|
||||
sort_order: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSpotDetailItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
is_free: bool
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
tag_ids: list[int] = []
|
||||
image_urls: list[str] = []
|
||||
images: list[AdminSpotImageItem] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSpotCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
city: str = Field(min_length=1, max_length=100)
|
||||
longitude: float
|
||||
latitude: float
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = Field(default=None, max_length=200)
|
||||
difficulty: str | None = None
|
||||
is_free: bool = True
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
audit_status: str = Field(default="pending", pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
creator_id: int
|
||||
image_urls: list[str] = []
|
||||
tag_ids: list[int] = []
|
||||
|
||||
|
||||
class AdminSpotUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
city: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = Field(default=None, max_length=200)
|
||||
difficulty: str | None = None
|
||||
is_free: bool | None = None
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
audit_status: str | None = Field(default=None, pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
creator_id: int | None = None
|
||||
image_urls: list[str] | None = None
|
||||
tag_ids: list[int] | None = None
|
||||
|
||||
|
||||
class AdminSpotAuditRequest(BaseModel):
|
||||
audit_status: str = Field(pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminBatchAuditRequest(BaseModel):
|
||||
ids: list[int] = Field(min_length=1)
|
||||
audit_status: str = Field(pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminDashboardStats(BaseModel):
|
||||
spots_total: int
|
||||
spots_pending: int
|
||||
spots_approved: int
|
||||
spots_rejected: int
|
||||
users_total: int
|
||||
events_total: int
|
||||
shooting_total: int
|
||||
|
||||
|
||||
class AdminEventListItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
registration_count: int
|
||||
max_participants: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminEventRegistrationItem(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminEventPhotoItem(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
uploader_id: int
|
||||
uploader_nickname: str
|
||||
image_url: str
|
||||
caption: str | None = None
|
||||
spot_id: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminEventPhotoInput(BaseModel):
|
||||
uploader_id: int
|
||||
image_url: str = Field(min_length=1, max_length=500)
|
||||
caption: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class AdminEventDetailItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int
|
||||
spot_id: int | None = None
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
registration_count: int
|
||||
creator_id: int
|
||||
registration_user_ids: list[int] = []
|
||||
photos: list[AdminEventPhotoItem] = []
|
||||
registrations: list[AdminEventRegistrationItem] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminEventCreateRequest(BaseModel):
|
||||
creator_id: int
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
city: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
cover_url: str | None = Field(default=None, max_length=500)
|
||||
location_name: str | None = Field(default=None, max_length=200)
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int = Field(default=0, ge=0)
|
||||
spot_id: int | None = None
|
||||
status: str = Field(default="upcoming", pattern="^(upcoming|ongoing|ended|cancelled)$")
|
||||
audit_status: str = Field(default="pending", pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
registration_user_ids: list[int] = []
|
||||
photos: list[AdminEventPhotoInput] = []
|
||||
|
||||
|
||||
class AdminEventUpdateRequest(BaseModel):
|
||||
creator_id: int | None = None
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
city: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
cover_url: str | None = Field(default=None, max_length=500)
|
||||
location_name: str | None = Field(default=None, max_length=200)
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int | None = Field(default=None, ge=0)
|
||||
spot_id: int | None = None
|
||||
status: str | None = Field(default=None, pattern="^(upcoming|ongoing|ended|cancelled)$")
|
||||
audit_status: str | None = Field(default=None, pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
registration_user_ids: list[int] | None = None
|
||||
photos: list[AdminEventPhotoInput] | None = None
|
||||
|
||||
|
||||
class AdminEventRegistrationCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
status: str = Field(default="registered", pattern="^(registered|cancelled)$")
|
||||
|
||||
|
||||
class AdminEventRegistrationUpdateRequest(BaseModel):
|
||||
status: str = Field(pattern="^(registered|cancelled)$")
|
||||
|
||||
|
||||
class AdminEventPhotoCreateRequest(BaseModel):
|
||||
uploader_id: int
|
||||
image_url: str = Field(min_length=1, max_length=500)
|
||||
caption: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class AdminEventPhotoUpdateRequest(BaseModel):
|
||||
uploader_id: int | None = None
|
||||
image_url: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
caption: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class AdminShootingListItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
role_needed: str
|
||||
max_applicants: int
|
||||
is_free: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminShootingApplicationItem(BaseModel):
|
||||
id: int
|
||||
request_id: int
|
||||
applicant_id: int
|
||||
applicant_nickname: str
|
||||
message: str | None = None
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminShootingDetailItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str
|
||||
max_applicants: int
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
applications: list[AdminShootingApplicationItem] = []
|
||||
application_user_ids: list[int] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminShootingApplicationInput(BaseModel):
|
||||
applicant_id: int
|
||||
message: str | None = None
|
||||
status: str = Field(default="pending", pattern="^(pending|accepted|rejected|cancelled)$")
|
||||
|
||||
|
||||
class AdminShootingCreateRequest(BaseModel):
|
||||
creator_id: int
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
city: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
style: str | None = Field(default=None, max_length=100)
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool = False
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str = Field(default="photographer", pattern="^(photographer|cosplayer|both)$")
|
||||
max_applicants: int = Field(default=1, ge=1, le=100)
|
||||
contact_info: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
status: str = Field(default="open", pattern="^(open|matched|closed|cancelled)$")
|
||||
audit_status: str = Field(default="pending", pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
applications: list[AdminShootingApplicationInput] = []
|
||||
|
||||
|
||||
class AdminShootingUpdateRequest(BaseModel):
|
||||
creator_id: int | None = None
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
city: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
style: str | None = Field(default=None, max_length=100)
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool | None = None
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str | None = Field(default=None, pattern="^(photographer|cosplayer|both)$")
|
||||
max_applicants: int | None = Field(default=None, ge=1, le=100)
|
||||
contact_info: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
status: str | None = Field(default=None, pattern="^(open|matched|closed|cancelled)$")
|
||||
audit_status: str | None = Field(default=None, pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
applications: list[AdminShootingApplicationInput] | None = None
|
||||
|
||||
|
||||
class AdminShootingApplicationCreateRequest(BaseModel):
|
||||
applicant_id: int
|
||||
message: str | None = None
|
||||
status: str = Field(default="pending", pattern="^(pending|accepted|rejected|cancelled)$")
|
||||
|
||||
|
||||
class AdminShootingApplicationUpdateRequest(BaseModel):
|
||||
message: str | None = None
|
||||
status: str = Field(pattern="^(pending|accepted|rejected|cancelled)$")
|
||||
|
||||
|
||||
class AdminAuditRequest(BaseModel):
|
||||
audit_status: str = Field(pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminModuleCrudCoverage(BaseModel):
|
||||
create: bool
|
||||
read: bool
|
||||
update: bool
|
||||
delete: bool
|
||||
|
||||
|
||||
class AdminModuleDesignItem(BaseModel):
|
||||
module_key: str
|
||||
module_name: str
|
||||
models: list[str]
|
||||
api_endpoint_prefixes: list[str]
|
||||
coverage: AdminModuleCrudCoverage
|
||||
status: str
|
||||
notes: str
|
||||
|
||||
|
||||
class AdminModuleDesignResponse(BaseModel):
|
||||
total_modules: int
|
||||
full_covered: int
|
||||
partial_covered: int
|
||||
missing_covered: int
|
||||
items: list[AdminModuleDesignItem]
|
||||
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
city: str | None = None
|
||||
identity: str | None = None
|
||||
role: str
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminUserCreateRequest(BaseModel):
|
||||
nickname: str = Field(min_length=1, max_length=50)
|
||||
password: str = Field(min_length=6, max_length=200)
|
||||
phone: str | None = Field(default=None, max_length=20)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
city: str | None = Field(default=None, max_length=100)
|
||||
identity: str = Field(default="both", pattern="^(photographer|cosplayer|both)$")
|
||||
role: str = Field(default="user", pattern="^(user|moderator|admin)$")
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdminUserUpdateRequest(BaseModel):
|
||||
nickname: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
phone: str | None = Field(default=None, max_length=20)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
city: str | None = Field(default=None, max_length=100)
|
||||
identity: str | None = Field(default=None, pattern="^(photographer|cosplayer|both)$")
|
||||
role: str | None = Field(default=None, pattern="^(user|moderator|admin)$")
|
||||
is_active: bool | None = None
|
||||
password: str | None = Field(default=None, min_length=6, max_length=200)
|
||||
|
||||
|
||||
class AdminMembershipPlanItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
duration_days: int
|
||||
price: float
|
||||
benefits: str | None = None
|
||||
extra_uploads: int
|
||||
extra_top_count: int
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminMembershipPlanCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
duration_days: int = Field(ge=1, le=3650)
|
||||
price: float = Field(ge=0)
|
||||
benefits: str | None = None
|
||||
extra_uploads: int = Field(default=0, ge=0)
|
||||
extra_top_count: int = Field(default=0, ge=0)
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AdminMembershipPlanUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
duration_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
price: float | None = Field(default=None, ge=0)
|
||||
benefits: str | None = None
|
||||
extra_uploads: int | None = Field(default=None, ge=0)
|
||||
extra_top_count: int | None = Field(default=None, ge=0)
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class AdminUserMembershipItem(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
plan_id: int
|
||||
plan_name: str
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminUserMembershipCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
plan_id: int
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdminUserMembershipUpdateRequest(BaseModel):
|
||||
plan_id: int | None = None
|
||||
start_date: datetime | None = None
|
||||
end_date: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminPointLedgerItem(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
change: int
|
||||
balance: int
|
||||
reason: str
|
||||
ref_type: str | None = None
|
||||
ref_id: int | None = None
|
||||
rolled_back: bool = False
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminPointAdjustRequest(BaseModel):
|
||||
user_id: int
|
||||
change: int = Field(ne=0)
|
||||
reason: str = Field(min_length=1, max_length=200)
|
||||
ref_type: str | None = Field(default=None, max_length=50)
|
||||
ref_id: int | None = None
|
||||
|
||||
|
||||
class AdminNotificationItem(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
type: str
|
||||
title: str
|
||||
content: str | None = None
|
||||
ref_type: str | None = None
|
||||
ref_id: int | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminNotificationCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
type: str = Field(min_length=1, max_length=50)
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
content: str | None = None
|
||||
ref_type: str | None = Field(default=None, max_length=50)
|
||||
ref_id: int | None = None
|
||||
|
||||
|
||||
class AdminNotificationUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
content: str | None = None
|
||||
is_read: bool | None = None
|
||||
|
||||
|
||||
class AdminAuditLogItem(BaseModel):
|
||||
id: int
|
||||
operator_id: int
|
||||
operator_nickname: str
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: int | None = None
|
||||
detail: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminAuditLogCreateRequest(BaseModel):
|
||||
action: str = Field(min_length=1, max_length=100)
|
||||
target_type: str = Field(min_length=1, max_length=50)
|
||||
target_id: int | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class AdminReportItem(BaseModel):
|
||||
id: int
|
||||
reporter_id: int
|
||||
reporter_nickname: str
|
||||
target_type: str
|
||||
target_id: int
|
||||
reason: str
|
||||
status: str
|
||||
handler_id: int | None = None
|
||||
handler_nickname: str | None = None
|
||||
conclusion: str | None = None
|
||||
created_at: datetime
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminReportUpdateRequest(BaseModel):
|
||||
status: str | None = Field(default=None, pattern="^(pending|processing|resolved|rejected)$")
|
||||
handler_id: int | None = None
|
||||
conclusion: str | None = None
|
||||
|
||||
|
||||
class AdminSystemConfigItem(BaseModel):
|
||||
id: int
|
||||
config_key: str
|
||||
category: str
|
||||
title: str
|
||||
config_json: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
updated_by: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSystemConfigCreateRequest(BaseModel):
|
||||
config_key: str = Field(min_length=1, max_length=100)
|
||||
category: str = Field(min_length=1, max_length=50)
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
config_json: str = Field(min_length=2)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AdminSystemConfigUpdateRequest(BaseModel):
|
||||
category: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
config_json: str | None = Field(default=None, min_length=2)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AppNavConfigOut(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
page_path: str
|
||||
icon: str
|
||||
active_icon: str
|
||||
color: str
|
||||
active_color: str
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AppNavConfigCreate(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=50)
|
||||
label: str = Field(min_length=1, max_length=50)
|
||||
page_path: str = Field(min_length=1, max_length=200)
|
||||
icon: str = Field(min_length=1, max_length=50)
|
||||
active_icon: str = Field(min_length=1, max_length=50)
|
||||
color: str = Field(min_length=4, max_length=20)
|
||||
active_color: str = Field(min_length=4, max_length=20)
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AppNavConfigUpdate(BaseModel):
|
||||
label: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
page_path: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
icon: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
active_icon: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
color: str | None = Field(default=None, min_length=4, max_length=20)
|
||||
active_color: str | None = Field(default=None, min_length=4, max_length=20)
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=500)
|
||||
parent_id: int | None = None
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
id: int
|
||||
spot_id: int
|
||||
user: UserInfo | None = None
|
||||
parent_id: int | None = None
|
||||
content: str
|
||||
created_at: datetime | None = None
|
||||
replies: list["CommentOut"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ReportCreate(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ResponseBase(BaseModel):
|
||||
code: int = 0
|
||||
message: str = "success"
|
||||
|
||||
|
||||
class PageParams(BaseModel):
|
||||
page: int = 1
|
||||
page_size: int = Field(default=20, le=100)
|
||||
|
||||
|
||||
class PageResponse(ResponseBase, Generic[T]):
|
||||
total: int
|
||||
items: list[T]
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class CorrectionCreate(BaseModel):
|
||||
field_name: str = Field(..., max_length=50)
|
||||
suggested_value: str = Field(..., min_length=1)
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class CorrectionOut(BaseModel):
|
||||
id: int
|
||||
spot_id: int
|
||||
user: UserInfo | None = None
|
||||
field_name: str
|
||||
suggested_value: str
|
||||
reason: str | None = None
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int = 0
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
city: str | None = None
|
||||
description: str | None = None
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class EventBrief(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
status: str = "upcoming"
|
||||
audit_status: str = "pending"
|
||||
creator: UserInfo | None = None
|
||||
registration_count: int = 0
|
||||
max_participants: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EventDetail(EventBrief):
|
||||
description: str | None = None
|
||||
spot_id: int | None = None
|
||||
reject_reason: str | None = None
|
||||
has_registered: bool = False
|
||||
photos: list["EventPhotoOut"] = []
|
||||
|
||||
|
||||
class EventPhotoOut(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
caption: str | None = None
|
||||
uploader: UserInfo | None = None
|
||||
spot_id: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EventPhotoCreate(BaseModel):
|
||||
image_url: str
|
||||
caption: str | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class RegistrationOut(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
user: UserInfo | None = None
|
||||
status: str = "registered"
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MembershipPlanOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
duration_days: int
|
||||
price: float
|
||||
benefits: str | None = None
|
||||
extra_uploads: int = 0
|
||||
extra_top_count: int = 0
|
||||
sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserMembershipOut(BaseModel):
|
||||
id: int
|
||||
plan: MembershipPlanOut | None = None
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
is_active: bool = True
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PurchaseMembership(BaseModel):
|
||||
plan_id: int
|
||||
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PointBalance(BaseModel):
|
||||
balance: int
|
||||
|
||||
|
||||
class PointRecord(BaseModel):
|
||||
id: int
|
||||
change: int
|
||||
balance: int
|
||||
reason: str
|
||||
ref_type: str | None = None
|
||||
ref_id: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromotionOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
image_url: str
|
||||
link_type: str = "spot"
|
||||
link_id: int | None = None
|
||||
link_url: str | None = None
|
||||
position: str = "home_banner"
|
||||
sort_order: int = 0
|
||||
spot_id: int | None = None
|
||||
event_id: int | None = None
|
||||
shooting_id: int | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
is_active: bool = True
|
||||
impressions: int = 0
|
||||
clicks: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PromotionClick(BaseModel):
|
||||
promotion_id: int
|
||||
|
||||
|
||||
class PromotionCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
image_url: str = Field(min_length=1, max_length=500)
|
||||
link_type: str = Field(pattern="^(spot|event|shooting|url)$")
|
||||
link_id: int | None = None
|
||||
spot_id: int | None = None
|
||||
event_id: int | None = None
|
||||
shooting_id: int | None = None
|
||||
link_url: str | None = Field(default=None, max_length=500)
|
||||
position: str = Field(default="home_banner", min_length=1, max_length=30)
|
||||
sort_order: int = 0
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class PromotionUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
image_url: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
link_type: str | None = Field(default=None, pattern="^(spot|event|shooting|url)$")
|
||||
link_id: int | None = None
|
||||
spot_id: int | None = None
|
||||
event_id: int | None = None
|
||||
shooting_id: int | None = None
|
||||
link_url: str | None = Field(default=None, max_length=500)
|
||||
position: str | None = Field(default=None, min_length=1, max_length=30)
|
||||
sort_order: int | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class RatingCreate(BaseModel):
|
||||
score: int = Field(..., ge=1, le=5)
|
||||
short_comment: str | None = None
|
||||
|
||||
|
||||
class RatingOut(BaseModel):
|
||||
id: int
|
||||
spot_id: int
|
||||
user: UserInfo | None = None
|
||||
score: int
|
||||
short_comment: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class ShootingRequestCreate(BaseModel):
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool = False
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str = "photographer"
|
||||
max_applicants: int = 1
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class ShootingRequestUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
city: str | None = None
|
||||
description: str | None = None
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool | None = None
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str | None = None
|
||||
max_applicants: int | None = None
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class ShootingRequestBrief(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool = False
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str = "photographer"
|
||||
status: str = "open"
|
||||
audit_status: str = "pending"
|
||||
creator: UserInfo | None = None
|
||||
application_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ShootingRequestDetail(ShootingRequestBrief):
|
||||
description: str | None = None
|
||||
max_applicants: int = 1
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
reject_reason: str | None = None
|
||||
has_applied: bool = False
|
||||
|
||||
|
||||
class ApplicationCreate(BaseModel):
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ApplicationOut(BaseModel):
|
||||
id: int
|
||||
request_id: int
|
||||
applicant: UserInfo | None = None
|
||||
message: str | None = None
|
||||
status: str = "pending"
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.tag import TagOut
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class SpotCreate(BaseModel):
|
||||
title: str
|
||||
city: str
|
||||
longitude: float
|
||||
latitude: float
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
is_free: bool = True
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
image_urls: list[str] = []
|
||||
tag_ids: list[int] = []
|
||||
|
||||
|
||||
class SpotUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
city: str | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
is_free: bool | None = None
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
tag_ids: list[int] | None = None
|
||||
|
||||
|
||||
class SpotImageOut(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
is_cover: bool
|
||||
sort_order: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SpotBrief(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
cover_image_url: str | None = None
|
||||
audit_status: str
|
||||
avg_rating: float | None = None
|
||||
favorite_count: int = 0
|
||||
is_free: bool = True
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SpotDetail(SpotBrief):
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
creator: UserInfo | None = None
|
||||
images: list[SpotImageOut] = []
|
||||
tags: list[TagOut] = []
|
||||
rating_count: int = 0
|
||||
is_favorited: bool = False
|
||||
|
||||
|
||||
class SpotImageCreate(BaseModel):
|
||||
image_url: str
|
||||
is_cover: bool = False
|
||||
sort_order: int = 0
|
||||
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: str | None = None
|
||||
usage_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str
|
||||
category: str | None = None
|
||||
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
url: str
|
||||
filename: str
|
||||
|
||||
|
||||
class PresignedUrlRequest(BaseModel):
|
||||
filename: str
|
||||
content_type: str = "image/jpeg"
|
||||
|
||||
|
||||
class PresignedUrlResponse(BaseModel):
|
||||
upload_url: str
|
||||
file_key: str
|
||||
public_url: str
|
||||
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
password: str = Field(..., min_length=6)
|
||||
nickname: str = Field(..., min_length=1, max_length=50)
|
||||
city: str | None = None
|
||||
identity: str | None = "both"
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
avatar_url: str | None = None
|
||||
city: str | None = None
|
||||
bio: str | None = None
|
||||
identity: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
nickname: str | None = None
|
||||
avatar_url: str | None = None
|
||||
city: str | None = None
|
||||
bio: str | None = None
|
||||
identity: str | None = None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user