Initial project commit

This commit is contained in:
2026-05-09 16:40:29 +08:00
commit 02b0259a9e
267 changed files with 54891 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.idea
.venv
__pycache__
*.pyc
.env
uploads
+22
View File
@@ -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=
+50
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.10
+16
View File
@@ -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"]
+6
View File
@@ -0,0 +1,6 @@
# 后端默认管理员账号
- 管理后台登录账号(手机号):`13900000001`
- 管理后台登录密码:`demo123456`
> 说明:以上为 `seed_demo_data.py` 初始化的演示管理员账号(role=`admin`)。
+43
View File
@@ -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
+57
View File
@@ -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()
+26
View File
@@ -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"}
+95
View File
@@ -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")
+21
View File
@@ -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")
View File
View File
+54
View File
@@ -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
+76
View File
@@ -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
+53
View File
@@ -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="移除图片">&times;</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()
View File
View File
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()
+135
View File
@@ -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),
)
+145
View File
@@ -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])
+388
View File
@@ -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"}
+112
View File
@@ -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],
)
+80
View File
@@ -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
+81
View File
@@ -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"}
+49
View File
@@ -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)
+51
View File
@@ -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"}
+80
View File
@@ -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])
+74
View File
@@ -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])
+402
View File
@@ -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"}
+454
View File
@@ -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
+63
View File
@@ -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,
}
+93
View File
@@ -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()
+71
View File
@@ -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,
)
+110
View File
@@ -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
+26
View File
@@ -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=["管理端"])
+31
View File
@@ -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"])
View File
+31
View File
@@ -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()
+69
View File
@@ -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()
+40
View File
@@ -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)
+49
View File
@@ -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"
+46
View File
@@ -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
+139
View File
@@ -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
View File
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+36
View File
@@ -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")
+12
View File
@@ -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)
+194
View File
@@ -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"}
+26
View File
@@ -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",
]
+26
View File
@@ -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}>"
+27
View File
@@ -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}>"
+35
View File
@@ -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}>"
+27
View File
@@ -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}>"
+74
View File
@@ -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}>"
+28
View File
@@ -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}>"
+48
View File
@@ -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}>"
+25
View File
@@ -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}>"
+28
View File
@@ -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}>"
+38
View File
@@ -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}>"
+23
View File
@@ -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}>"
+27
View File
@@ -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}>"
+57
View File
@@ -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}>"
+88
View File
@@ -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}>"
+25
View File
@@ -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}>"
+30
View File
@@ -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())
+31
View File
@@ -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}>"
View File
+608
View File
@@ -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
+42
View File
@@ -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
+26
View File
@@ -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)
+20
View File
@@ -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]
+24
View File
@@ -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}
+82
View File
@@ -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}
+31
View File
@@ -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
+19
View File
@@ -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}
+60
View File
@@ -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
+21
View File
@@ -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}
+78
View File
@@ -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}
+82
View File
@@ -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
+15
View File
@@ -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
+17
View File
@@ -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
+47
View File
@@ -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
View File

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