From 48a02974fc0410801a51b216d9d03069142bd305 Mon Sep 17 00:00:00 2001 From: xingc Date: Tue, 18 Feb 2025 14:26:47 +0800 Subject: [PATCH] first init --- .gitignore | 13 + README.md | 31 ++ alembic.ini | 119 +++++++ alembic/README | 1 + alembic/env.py | 65 ++++ alembic/script.py.mako | 26 ++ alembic/versions/4c950125b0a6_init.py | 102 ++++++ config.py | 133 ++++++++ core/__init__.py | 6 + core/app.py | 51 +++ core/base/__init__.py | 6 + core/base/exceptions.py | 19 ++ core/base/schema.py | 55 +++ core/db/__init__.py | 6 + core/db/crud.py | 185 ++++++++++ core/db/engine.py | 52 +++ core/db/models.py | 197 +++++++++++ core/routers/__init__.py | 13 + core/routers/auth/__init__.py | 6 + core/routers/auth/routes.py | 72 ++++ core/routers/auth/schema.py | 16 + core/routers/auth/services.py | 9 + core/routers/card/__init__.py | 6 + core/routers/card/routes.py | 220 ++++++++++++ core/routers/card/schema.py | 41 +++ core/routers/card/scraper.py | 470 ++++++++++++++++++++++++++ core/routers/card/services.py | 18 + core/routers/file/__init__.py | 6 + core/routers/file/routes.py | 76 +++++ core/routers/file/schema.py | 14 + core/routers/im/__init__.py | 6 + core/routers/im/routes.py | 33 ++ core/routers/user/__init__.py | 6 + core/routers/user/routes.py | 9 + core/utils/__init__.py | 6 + core/utils/helper.py | 34 ++ core/utils/response.py | 6 + core/wechat/__init__.py | 6 + core/wechat/api.py | 68 ++++ main.py | 19 ++ requirements.txt | 70 ++++ 41 files changed, 2297 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/4c950125b0a6_init.py create mode 100644 config.py create mode 100644 core/__init__.py create mode 100644 core/app.py create mode 100644 core/base/__init__.py create mode 100644 core/base/exceptions.py create mode 100644 core/base/schema.py create mode 100644 core/db/__init__.py create mode 100644 core/db/crud.py create mode 100644 core/db/engine.py create mode 100644 core/db/models.py create mode 100644 core/routers/__init__.py create mode 100644 core/routers/auth/__init__.py create mode 100644 core/routers/auth/routes.py create mode 100644 core/routers/auth/schema.py create mode 100644 core/routers/auth/services.py create mode 100644 core/routers/card/__init__.py create mode 100644 core/routers/card/routes.py create mode 100644 core/routers/card/schema.py create mode 100644 core/routers/card/scraper.py create mode 100644 core/routers/card/services.py create mode 100644 core/routers/file/__init__.py create mode 100644 core/routers/file/routes.py create mode 100644 core/routers/file/schema.py create mode 100644 core/routers/im/__init__.py create mode 100644 core/routers/im/routes.py create mode 100644 core/routers/user/__init__.py create mode 100644 core/routers/user/routes.py create mode 100644 core/utils/__init__.py create mode 100644 core/utils/helper.py create mode 100644 core/utils/response.py create mode 100644 core/wechat/__init__.py create mode 100644 core/wechat/api.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1d3a14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea + +# 虚拟环境 +.venv +venv + +tests +uploads +**/__pycache__ + +**/*.zip +**/*.log +**/*.py[co] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1169a03 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Card Book + +## 部署 + +1.环境准备和要求 + +- **python** 3.11.0 +- **mysql** 5.7 (建议) +- **redis** >5.0 (建议) + +2.安装依赖 + +```shell +pip install -r requirements.txt +``` + +3.修改配置 + +- 数据库和redis连接信息 +- 图片资源上传目录 +- 境外代理 + +4.启动服务 + +```shell +uvicorn main:app +``` + +## 关于 + +作者:[xingc](mailto:xingc) \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..3700390 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..c3423b1 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,65 @@ +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine +from logging.config import fileConfig + +# 导入你的配置 +from config import settings + +# 这是 Alembic 配置对象,提供对 .ini 文件中的值的访问。 +config = context.config + +# 动态设置 sqlalchemy.url +config.set_main_option("sqlalchemy.url", ( + settings.DB_URL or + '{scheme}://{user}:{password}@{host}:{port}/{db}'.format( + scheme=settings.DB_SCHEMA, + user=settings.DB_USER, + password=settings.DB_PASSWORD, + host=settings.DB_HOST, + port=settings.DB_PORT, + db=settings.DB_NAME, + ) +)) + +# 设置 target_metadata +from core.db.models import BaseModel # 假设你的 SQLAlchemy 模型在 models.py 中 + +target_metadata = BaseModel.metadata + + +def do_run_migrations(connection): + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + include_schemas=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): + # 创建异步引擎 + connectable = create_async_engine(config.get_main_option("sqlalchemy.url")) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + +if context.is_offline_mode(): + # 离线模式(同步) + run_migrations_offline() +else: + import asyncio + + # 在线模式(异步) + # 检查是否已经有事件循环 + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # 运行迁移 + loop.run_until_complete(run_migrations_online()) \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/4c950125b0a6_init.py b/alembic/versions/4c950125b0a6_init.py new file mode 100644 index 0000000..1a4baec --- /dev/null +++ b/alembic/versions/4c950125b0a6_init.py @@ -0,0 +1,102 @@ +"""Init + +Revision ID: 4c950125b0a6 +Revises: +Create Date: 2025-02-17 03:17:02.044360 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4c950125b0a6' +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: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('system_cache_cards', + sa.Column('cert_number', sa.String(length=50), nullable=False, comment='评级卡号'), + sa.Column('card_number', sa.String(length=50), nullable=False, comment='卡片编号'), + sa.Column('data', sa.JSON(), nullable=False, comment='标准数据'), + sa.Column('raw_data', sa.JSON(), nullable=False, comment='原始数据'), + sa.Column('source', sa.Enum('PSA', 'BGS', 'CGC', 'GBTC', 'CCG', 'BCTC', name='supportplatformtype', inherit_schema=True), nullable=False, comment='数据来源:PSA、BGS、CGC、GBTC、CCG、BCTC'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='自增id'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), + sa.PrimaryKeyConstraint('id'), + comment='搜索卡片信息缓存表' + ) + op.create_index('cert_and_card_number_source', 'system_cache_cards', ['cert_number', 'card_number', 'source'], unique=False) + op.create_table('system_cards', + sa.Column('cert_number', sa.String(length=50), nullable=False, comment='评级卡号'), + sa.Column('card_year', sa.Integer(), nullable=False, comment='卡片年份'), + sa.Column('card_brand', sa.Integer(), nullable=False, comment='卡片品牌'), + sa.Column('card_score', sa.DECIMAL(precision=10, scale=1), nullable=False, comment='卡片评分'), + sa.Column('auto_score', sa.DECIMAL(precision=10, scale=1), nullable=False, comment='签字评分'), + sa.Column('card_name', sa.String(length=225), nullable=False, comment='卡片名称'), + sa.Column('card_number', sa.String(length=225), nullable=False, comment='卡片编号'), + sa.Column('card_type', sa.String(length=20), nullable=False, comment='卡片类型'), + sa.Column('card_picture', sa.JSON(), nullable=False, comment='卡片正、反图片(本地)'), + sa.Column('card_spare_picture', sa.JSON(), nullable=False, comment='卡片正、反图片(远程)'), + sa.Column('create_user_id', sa.Integer(), nullable=False, comment='创建者'), + sa.Column('category', sa.Enum('SPORTS', 'ANIME', 'OTHER', name='cardcategorytype', inherit_schema=True), nullable=False, comment='卡片分类'), + sa.Column('source', sa.Enum('PSA', 'BGS', 'CGC', 'GBTC', 'CCG', 'BCTC', 'OTHER', name='platformsourcetype', inherit_schema=True), nullable=False, comment='来源,PSA、BGS、CGC、GBTC、CCG、BCTC :采集 、OTHER:自定义'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='自增id'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), + sa.PrimaryKeyConstraint('id'), + comment='卡片表' + ) + op.create_index('cert_number_category_source', 'system_cards', ['cert_number', 'category', 'source'], unique=False) + op.create_table('system_users', + sa.Column('nickname', sa.String(length=20), nullable=False, comment='昵称'), + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户id'), + sa.Column('head_avatar', sa.String(length=500), nullable=False, comment='头像链接'), + sa.Column('wechat_openid', sa.String(length=100), nullable=True, comment='微信openid'), + sa.Column('wechat_union_id', sa.String(length=100), nullable=True, comment='微信unionid'), + sa.Column('phone', sa.Integer(), nullable=False, comment='手机号码'), + sa.Column('vip_expire_date', sa.Date(), nullable=False, comment='会员到期时间'), + sa.Column('integral', sa.Integer(), nullable=False, comment='会员积分'), + sa.Column('card_total', sa.Integer(), nullable=True, comment='卡牌总数'), + sa.Column('card_show_total', sa.Integer(), nullable=True, comment='卡片展示总数'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='自增id'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', name='uniq_user_id'), + comment='用户表' + ) + op.create_table('system_user_collections', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('card_id', sa.Integer(), nullable=False), + sa.Column('purchase_price', sa.DECIMAL(precision=10, scale=2), nullable=False), + sa.Column('display_price', sa.DECIMAL(precision=10, scale=2), nullable=False), + sa.Column('displayed', sa.Boolean(), nullable=False), + sa.Column('updated_at', sa.Boolean(), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='自增id'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), + sa.CheckConstraint('display_value >= 0', name='check_display_price'), + sa.CheckConstraint('purchase_price >= 0', name='check_purchase_price'), + sa.ForeignKeyConstraint(['card_id'], ['system_cards.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['system_users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'card_id', name='uniq_user_card') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('system_user_collections') + op.drop_table('system_users') + op.drop_index('cert_number_category_source', table_name='system_cards') + op.drop_table('system_cards') + op.drop_index('cert_and_card_number_source', table_name='system_cache_cards') + op.drop_table('system_cache_cards') + # ### end Alembic commands ### diff --git a/config.py b/config.py new file mode 100644 index 0000000..ffb7d3c --- /dev/null +++ b/config.py @@ -0,0 +1,133 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/12 下午4:55 +# @File : config.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import logging +import os + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + + # 基础设置 + NAME: str = 'card book' + PREFIX: str = NAME.lower().replace(' ', '_') + ENVIRONMENT: str = 'development' + BASE_DIR: str = os.path.dirname(os.path.abspath(__file__)) + DEBUG: bool = True + + # 应用配置 + ALLOWED_ORIGINS: str = 'http://127.0.0.1:3000,http://localhost:3000' + + # 日志配置 + LOG_LEVEL: int = logging.DEBUG + + # 数据库配置 + DB_USER: str + DB_PASSWORD: str + DB_HOST: str + DB_PORT: int = 3306 + DB_NAME: str + DB_SCHEMA: str = "mysql+aiomysql" + DB_POOL_SIZE: int = 30 + # 指定单个数据库url + DB_URL: str | None = None + + # redis配置 + REDIS_PASSWORD: str + REDIS_HOST: str + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + REDIS_CACHE_EXPIRATION_SECONDS: int = 60 * 30 * 1 + + # 身份认证 + ACCESS_SECRET_KEY: str = 'a2f718e8313bf8efcb45ef7364d040c45800f34d10f65776eef0f6584677ecd0' + REFRESH_SECRET_KEY: str = 'bb0cbf245de98c09c42c928326f6e07a44d91bc5a81787b37b4eb33428915fa7' + ALGORITHM: str = 'HS256' + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 + + # 文件上传配置 + UPLOADED_FILES_DIR: str = os.path.join(BASE_DIR, 'uploads') + UPLOADED_ALLOWED_MIME_TYPES: set = { + "image/jpeg", + "image/png" + } + UPLOADED_MAX_FILE_SIZE: int = 1024 * 1024 * 10 # 10MB + + # 代理 + OVERSEAS_PROXY: str = 'http://127.0.0.1:7890' + + # 微信 + APPID: str = '' + APP_SECRET: str = '' + + +class ProductionSettings(Settings): + # 基础设置 + ENVIRONMENT: str = 'production' + DEBUG: bool = False + + # 身份认证 + ACCESS_SECRET_KEY: str = '1f790219840ccd10b5d8b33d7f595b7fe0eed262403689f6fa1cab80149b00f2' + REFRESH_SECRET_KEY: str = 'fb57c85dc4e0256024ae809bc3200adc7b7de222b348da6df35d58e27338e3b8' + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 1 # 1days + + +class DevelopmentSettings(Settings): + # 应用配置 + ALLOWED_ORIGINS: str = 'http://127.0.0.1:3000,http://localhost:3000' + + # 日志配置 + LOG_LEVEL: int = logging.INFO + + # 数据库配置 + DB_USER: str = 'card_book' + DB_PASSWORD: str = 'DFCPjk4rdkca6SPE' + DB_HOST: str = '192.168.3.4' + DB_NAME: str = 'card_book' + + # redis配置 + REDIS_PASSWORD: str = 'xingc' + REDIS_HOST: str = '192.168.3.4' + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + + +def get_settings(): + env = os.environ.get('ENVIRONMENT', 'development') + if env == 'development': + return DevelopmentSettings() + elif env == 'production': + return ProductionSettings() + + return Settings() + + +settings = get_settings() +LOGGING_CONFIG: dict = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, + }, + "handlers": { + "default": { + "level": settings.LOG_LEVEL, + "formatter": "standard", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "": {"handlers": ["default"], "level": settings.LOG_LEVEL, "propagate": False}, + "uvicorn": { + "handlers": ["default"], + "level": logging.ERROR, + "propagate": False, + }, + }, +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..1761c5e --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/12 下午4:39 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/app.py b/core/app.py new file mode 100644 index 0000000..1e0b50c --- /dev/null +++ b/core/app.py @@ -0,0 +1,51 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午12:27 +# @File : app.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import logging + +from fastapi import FastAPI, Request, Response + +from config import settings +from core.routers import routers + +logger = logging.getLogger(__name__) + + +async def exception_log(request: Request, exc): + logger.error('\n'.join([ + '', + f'request method: {request.method}', + f'request path: {request.path_params}', + f'request host: {request.client.host}', + f'query parameters: {dict(request.query_params)}', + f'abnormal description: {exc}', + ])) + logger.exception(exc) + + +def create_app() -> FastAPI: + app = FastAPI( + debug=settings.DEBUG, + openapi_url='/openapi.json' if settings.DEBUG else None, + ) + + for router in routers: + app.include_router(router) + + @app.on_event('startup') + async def startup_event(): + logger.info(f'Application <<{settings.NAME}>> is started') + + @app.on_event('shutdown') + async def shutdown_event(): + logger.info(f'Application <<{settings.NAME}>> is closed') + + # @app.exception_handler(Exception) + # async def http_exception_handler(request: Request, exc: Exception): + # await exception_log(request, exc) + # return base_response.error(status_code=500, message='Server error') + + return app diff --git a/core/base/__init__.py b/core/base/__init__.py new file mode 100644 index 0000000..7f1337e --- /dev/null +++ b/core/base/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/14 上午10:50 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/base/exceptions.py b/core/base/exceptions.py new file mode 100644 index 0000000..9cb4f46 --- /dev/null +++ b/core/base/exceptions.py @@ -0,0 +1,19 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/16 下午10:05 +# @File : exceptions.py +# @Software : PyCharm +# @Author : xingc +# @Desc : + +class BaseException(Exception): + pass + + +class SearchCardNotFound(Exception): + """搜索卡片不存在""" + pass + + +class SearchCardRetry(Exception): + """搜索重试""" + pass diff --git a/core/base/schema.py b/core/base/schema.py new file mode 100644 index 0000000..17ee6de --- /dev/null +++ b/core/base/schema.py @@ -0,0 +1,55 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午5:01 +# @File : schema.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import enum +from typing import Optional, Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar('T') + + +class BaseResponse(BaseModel, Generic[T]): + code: int = 200 + message: str = 'success' + data: Optional[T] = None + + +class Paginated(BaseModel, Generic[T]): + list: Optional[T] = None + total: int + page: int + page_size: int + + +class SupportPlatformType(enum.Enum): + PSA = 'psa' + BGS = 'bgs' + CGC = 'cgc' + GBTC = 'gbtc' + CCG = 'ccg' + BCTC = 'bctc' + + +class PlatformSourceType(enum.Enum): + PSA = 'psa' + BGS = 'bgs' + CGC = 'cgc' + GBTC = 'gbtc' + CCG = 'ccg' + BCTC = 'bctc' + OTHER = 'other' + + +class MessageType(enum.Enum): + TEXT = 'text' + IMAGE = 'image' + + +class CardCategoryType(enum.Enum): + SPORTS = 'sports' + ANIME = 'anime' + OTHER = 'other' diff --git a/core/db/__init__.py b/core/db/__init__.py new file mode 100644 index 0000000..468ffe8 --- /dev/null +++ b/core/db/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午12:17 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/db/crud.py b/core/db/crud.py new file mode 100644 index 0000000..9de6cea --- /dev/null +++ b/core/db/crud.py @@ -0,0 +1,185 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午12:18 +# @File : crud.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from datetime import datetime +from typing import List, Optional, Union, NewType, Dict, Any, Tuple + +from sqlalchemy import func, desc +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from .models import User, Card, SearchCardCache + +Model = NewType('Model', Union[User, Card, SearchCardCache]) + + +class CRUD: + def __init__(self, session: AsyncSession, model: Model): + self.session = session + self.model = model + + async def get(self, filters: dict) -> Optional[Model]: + async with self.session.begin(): + result = await self.session.execute(select(self.model).filter( + *self.build_filter_conditions(filters)) + ) + record = result.scalar_one_or_none() + return record + + async def get_paginated(self, filters: dict, page: int = 1, page_size: int = 10) -> Tuple[List[Model], int]: + async with self.session.begin(): + filter_conditions = self.build_filter_conditions(filters) if filters else [] + query = select(self.model).filter(*filter_conditions).order_by(desc(self.model.insert_time)) + + total_query = select(func.count()).select_from(self.model).filter(*filter_conditions) + total_result = await self.session.execute(total_query) + total = total_result.scalar() + + query = query.limit(page_size).offset((page - 1) * page_size) + result = await self.session.execute(query) + records = result.scalars().all() + + return records, total + + async def get_grouped_count(self, group_by_field: str, filters: Dict[str, Any] = None) -> Dict[Any, int]: + # 构建过滤条件 + filter_conditions = self.build_filter_conditions(filters) if filters else [] + + # 确定分组字段 + group_by = getattr(self.model, group_by_field) + + # 构建查询 + query = select(group_by, func.count()).filter(*filter_conditions).group_by(group_by) + + # 执行查询并获取结果 + async with self.session.begin(): + result = await self.session.execute(query) + records = result.all() + + return {record[0]: record[1] for record in records} + + async def create(self, data: dict, filters: dict) -> Union[Model, bool]: + async with self.session.begin(): + result = await self.session.execute(select(self.model).filter( + *self.build_filter_conditions(filters)) + ) + if result.scalars().first() is not None: + return False + + record = self.model(**data) + self.session.add(record) + await self.session.commit() + return record + + async def get_all(self, filters: dict = None) -> List[Model]: + async with self.session.begin(): + result = await self.session.execute( + select(self.model) if filters is None else select(self.model).filter( + *self.build_filter_conditions(filters)).order_by(desc(self.model.insert_time)) + ) + records = result.scalars().all() + return records + + async def update(self, data: dict, filters: dict) -> Optional[Model]: + async with self.session.begin(): + result = await self.session.execute(select(self.model).filter( + *self.build_filter_conditions(filters)) + ) + record = result.scalars().first() + + if not record: + return None + + count = 0 + for key, value in data.items(): + if any([ + value is None, + value == getattr(record, key), + key in ['insert_time', 'update_time'] + ]): + continue + setattr(record, key, value) + count += 1 + if count > 0: + await self.session.commit() + return record + + async def updates(self, data: dict, filters: dict) -> Optional[Model]: + async with self.session.begin(): + result = await self.session.execute(select(self.model).filter( + *self.build_filter_conditions(filters)) + ) + records = result.scalars().all() + + if len(records) == 0: + return None + count = 0 + for record in records: + for key, value in data.items(): + if any([ + value is None, + value == getattr(record, key), + key in ['insert_time', 'update_time'] + ]): + continue + setattr(record, key, value) + count += 1 + if count > 0: + await self.session.commit() + return records + + async def delete(self, filters: dict) -> bool: + async with self.session.begin(): + result = await self.session.execute(select(self.model).filter( + *self.build_filter_conditions(filters)) + ) + record = result.scalars().first() + + if not record: + return False + + await self.session.delete(record) + await self.session.commit() + return True + + def build_filter_conditions(self, filters: Dict[str, Any]) -> tuple: + conditions = [] + + for key, value in filters.items(): + if any([ + value is None, + value == '', + ]): + continue + if isinstance(value, datetime) and value.tzinfo is not None: + value = value.astimezone() + + # 解析字段名和操作符 + field_name, operator = key, None + if '__' in key: + field_name, operator = key.split('__', 1) + else: + field_name = key + + field = getattr(self.model, field_name) + + if operator == 'like': # 模糊匹配 + conditions.append(field.like(f'%{value}%')) + elif operator == 'gt': # 大于 + conditions.append(field > value) + elif operator == 'lt': # 小于 + conditions.append(field < value) + elif operator == 'eq': # 等于 + conditions.append(field == value) + elif operator == 'ne': # 不等于 + conditions.append(field != value) + elif operator == 'in': # 在集合中 + conditions.append(field.in_(value)) + else: + # 如果没有匹配的操作符,默认为等于 + conditions.append(field == value) + + return tuple(conditions) diff --git a/core/db/engine.py b/core/db/engine.py new file mode 100644 index 0000000..cdfab5a --- /dev/null +++ b/core/db/engine.py @@ -0,0 +1,52 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午12:19 +# @File : engine.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from typing import AsyncGenerator + +import redis.asyncio as aioredis +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings + +engine = create_async_engine( + ( + settings.DB_URL or + '{scheme}://{user}:{password}@{host}:{port}/{db}'.format( + scheme=settings.DB_SCHEMA, + user=settings.DB_USER, + password=settings.DB_PASSWORD, + host=settings.DB_HOST, + port=settings.DB_PORT, + db=settings.DB_NAME, + ) + ), + pool_size=settings.DB_POOL_SIZE, + pool_recycle=1800, + pool_timeout=30, + connect_args={'charset': 'utf8mb4', "connect_timeout": 5}, + echo=settings.DEBUG +) +session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + async with session_maker() as session: + yield session + + +def create_redis_pool(): + return aioredis.ConnectionPool( + host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD, db=settings.REDIS_DB, + decode_responses=True + ) + + +redis_pool = create_redis_pool() + + +async def get_cache() -> aioredis.Redis: + return aioredis.Redis(connection_pool=redis_pool) diff --git a/core/db/models.py b/core/db/models.py new file mode 100644 index 0000000..c8e1bf7 --- /dev/null +++ b/core/db/models.py @@ -0,0 +1,197 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午12:17 +# @File : models.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import enum +import uuid +from datetime import datetime + +import ujson +from sqlalchemy import ( + Column, Integer, String, Text, Date, + ForeignKey, DECIMAL, CheckConstraint, + UniqueConstraint, Index, Sequence, JSON, UUID, Enum, Boolean +) +from sqlalchemy.orm import ( + DeclarativeBase, Mapped, mapped_column, relationship +) +from sqlalchemy.sql import func +from sqlalchemy.ext.asyncio import AsyncAttrs + +from core.base import schema as base_schema + + +class BaseModel(DeclarativeBase, AsyncAttrs): + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True, comment='自增id' + ) + + def dict(self, exclude: set = None): + data = {} + for column in self.__table__.columns: + if exclude is not None and column.name in exclude: + continue + + value = getattr(self, column.name) + if isinstance(value, datetime): + data[column.name] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, enum.Enum): + data[column.name] = value.value + else: + data[column.name] = value + return data + + def json(self, exclude: set = None): + return ujson.dumps(self.dict(exclude=exclude)) + + +class DateModel: + created_at: Mapped[datetime] = mapped_column(server_default=func.current_timestamp(), comment='创建时间') + updated_at: Mapped[datetime] = mapped_column(server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), comment='更新时间') + + +class User(BaseModel, DateModel): + __tablename__ = 'system_users' + __table_args__ = ( + UniqueConstraint('user_id', name='uniq_user_id'), + {'comment': '用户表'} + ) + + nickname: Mapped[str] = mapped_column(String(20), nullable=False, comment='昵称') + user_id: Mapped[int] = mapped_column( + Integer, Sequence('non_primary_id_seq', start=100000, increment=2), comment='用户id' + ) + head_avatar: Mapped[str] = mapped_column(String(500), comment='头像链接') + wechat_openid = mapped_column(String(100), comment='微信openid') + wechat_union_id = mapped_column(String(100), comment='微信unionid') + phone: Mapped[int] = mapped_column(Integer, comment='手机号码') + vip_expire_date: Mapped[datetime] = mapped_column(Date, comment='会员到期时间') + integral: Mapped[int] = mapped_column(Integer, default=0, comment='会员积分') + card_total: Mapped[int] = Column(Integer, default=0, comment='卡牌总数') + card_show_total: Mapped[int] = Column(Integer, default=0, comment='卡片展示总数') + + collections: Mapped[list["UserCollection"]] = relationship( + back_populates="user", + cascade="all, delete-orphan" + ) + + +class Card(BaseModel, DateModel): + __tablename__ = 'system_cards' + __table_args__ = ( + Index('cert_number_category_source', 'cert_number', 'category', 'source'), + {'comment': '卡片表'} + ) + + cert_number: Mapped[str] = mapped_column(String(50), comment='评级卡号') + card_year: Mapped[int] = mapped_column(Integer, comment='卡片年份') + card_brand: Mapped[str] = mapped_column(Integer, comment='卡片品牌') + card_score: Mapped[float] = mapped_column(DECIMAL(10, 1), comment='卡片评分') + auto_score: Mapped[float] = mapped_column(DECIMAL(10, 1), comment='签字评分') + card_name: Mapped[str] = mapped_column(String(225), comment='卡片名称') + card_number: Mapped[str] = mapped_column(String(225), comment='卡片编号') + card_type: Mapped[str] = mapped_column(String(20), comment='卡片类型') + card_picture: Mapped[dict] = mapped_column(JSON, default=dict, comment='卡片正、反图片(本地)') + card_spare_picture: Mapped[dict] = mapped_column(JSON, default=dict, comment='卡片正、反图片(远程)') + create_user_id: Mapped[int] = mapped_column(Integer, comment='创建者') + + category: Mapped[str] = mapped_column( + Enum(base_schema.CardCategoryType, inherit_schema=True), + default=base_schema.CardCategoryType.OTHER, comment='卡片分类' + ) + source: Mapped[str] = mapped_column( + Enum(base_schema.PlatformSourceType, inherit_schema=True), + comment='来源,PSA、BGS、CGC、GBTC、CCG、BCTC :采集 、OTHER:自定义' + ) + + collections: Mapped[list["UserCollection"]] = relationship( + back_populates="card", + cascade="all, delete-orphan" + ) + + +class UserCollection(BaseModel, DateModel): + __tablename__ = "system_user_collections" + __table_args__ = ( + UniqueConstraint('user_id', 'card_id', name='uniq_user_card'), + CheckConstraint('purchase_price >= 0', name='check_purchase_price'), + CheckConstraint('display_value >= 0', name='check_display_price') + ) + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("system_users.id", ondelete="CASCADE"), + nullable=False + ) + card_id: Mapped[int] = mapped_column( + Integer, ForeignKey("system_cards.id", ondelete="CASCADE"), + nullable=False + ) + purchase_price: Mapped[float] = mapped_column( + DECIMAL(10, 2), nullable=False + ) + display_price: Mapped[float] = mapped_column( + DECIMAL(10, 2), nullable=False + ) + displayed: Mapped[bool] = mapped_column( + Boolean, default=True + ) + updated_at: Mapped[bool] = None + + user: Mapped["User"] = relationship(back_populates="collections") + card: Mapped["Card"] = relationship(back_populates="collections") + + +class SearchCardCache(BaseModel, DateModel): + __tablename__ = 'system_cache_cards' + __table_args__ = ( + Index('cert_and_card_number_source', 'cert_number', 'card_number', 'source'), + {'comment': '搜索卡片信息缓存表'} + ) + + cert_number: Mapped[str] = mapped_column(String(50), comment='评级卡号') + card_number: Mapped[str] = mapped_column(String(50), comment='卡片编号') + data: Mapped[dict] = mapped_column(JSON, comment='标准数据') + raw_data: Mapped[dict] = mapped_column(JSON, comment='原始数据') + source: Mapped[str] = mapped_column( + Enum(base_schema.SupportPlatformType, inherit_schema=True), + comment='数据来源:PSA、BGS、CGC、GBTC、CCG、BCTC' + ) + +# class Product(BaseModel, DateModel): +# __tablename__ = 'system_products' +# __table_args__ = {'comment': '产品信息表'} +# +# name: Mapped[str] = mapped_column(String(50), comment='产品名') +# price: Mapped[float] = mapped_column(DECIMAL(10, 2), comment='价格') +# action: Mapped[str] = mapped_column(String(10), comment='付款后的动作,会员续期:vip_renewal 积分:recharge_points') +# number: Mapped[int] = mapped_column(Integer, comment='执行动作的增量值') +# description: Mapped[str] = mapped_column(Text, comment='商品描述') +# +# +# class Order(BaseModel, DateModel): +# __tablename__ = 'system_orders' +# __table_args__ = {'comment': '产品信息表'} +# +# order_id: Mapped[int] = mapped_column(String(20), comment='订单id') +# user_id: Mapped[int] = mapped_column(Integer, comment='用户id') +# product_id: Mapped[int] = mapped_column(Integer, comment='产品id') +# price: Mapped[float] = mapped_column(DECIMAL(10, 2), comment='价格') +# pay_url: Mapped[str] = mapped_column(String(255), comment='支付地址') +# status: Mapped[int] = mapped_column(Integer, comment='订单状态,-1:初始化 0:失败 1:完成 2:待支付') + +# +# class Message(BaseModel, DateModel): +# __tablename__ = 'system_messages' +# __table_args__ = {'comment': '聊天消息表'} +# +# guid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4) +# message_type: Mapped[str] = mapped_column( +# Enum(base_schema.MessageType, inherit_schema=True), default=base_schema.MessageType.TEXT +# ) +# content: Mapped[str] = mapped_column(String(5000), comment='文本消息') +# send_timestamp: Mapped[int] = mapped_column(Integer, comment='发送时间') +# +# user: Mapped["User"] = relationship(back_populates="system_messages") diff --git a/core/routers/__init__.py b/core/routers/__init__.py new file mode 100644 index 0000000..6cf217f --- /dev/null +++ b/core/routers/__init__.py @@ -0,0 +1,13 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午3:48 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from .auth.routes import auth_router +from .card.routes import card_router +from .im.routes import im_router +from .file.routes import file_router +from .user.routes import user_router + +routers = [auth_router, card_router, im_router, file_router, user_router] diff --git a/core/routers/auth/__init__.py b/core/routers/auth/__init__.py new file mode 100644 index 0000000..b304228 --- /dev/null +++ b/core/routers/auth/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午3:48 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/routers/auth/routes.py b/core/routers/auth/routes.py new file mode 100644 index 0000000..d75aa84 --- /dev/null +++ b/core/routers/auth/routes.py @@ -0,0 +1,72 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午5:02 +# @File : routes.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import logging + +import httpx +from fastapi import APIRouter, HTTPException, Depends +import redis.asyncio as aioredis +from sqlalchemy.ext.asyncio import AsyncSession + +from . import schema +from config import settings +from core.db.engine import get_async_session, get_cache +from core.db.models import User +from core.db.crud import CRUD + +logger = logging.getLogger(__name__) +auth_router = APIRouter(prefix='/auth', tags=['认证模块']) + + +async def get_wechat_session(code: str): + url = "https://api.weixin.qq.com/sns/jscode2session" + params = { + "appid": settings.APPID, + "secret": settings.APPSECRET, + "js_code": code, + "grant_type": "authorization_code", + } + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params) + if response.status_code != 200: + raise HTTPException(status_code=400, detail="Failed to fetch session from WeChat") + data = response.json() + if "errcode" in data: + logger.warning(f"WeChat session error: {data['errcode']}") + raise HTTPException(status_code=400, detail=data["errmsg"]) + + return data + + +@auth_router.post('/wechat_login') +async def wechat_login( + payload: schema.WechatLoginIn, + db_session: AsyncSession = Depends(get_async_session), + redis_conn: aioredis.Redis = Depends(get_cache) +): + # 获取微信 session + wechat_session = await get_wechat_session(payload.code) + logger.info(f"WeChat session: {wechat_session}") + openid = wechat_session.get("openid") + + if openid: + raise HTTPException(status_code=400, detail="Invalid WeChat session") + + db_crud = CRUD(db_session, User) + user = await db_crud.get(filters={'openid': openid}) + if user is None: + # 新用户注册 + if not payload.encrypted_data or not payload.iv: + raise HTTPException(400, detail="需要用户授权信息") + # user = await db_crud.create( + # data={ + # 'wechat_openid': openid, + # 'wechat_union_id': union_id, + # }, + # filters={'union_id': union_id} + # ) + + return {'data': {'openid': openid}} diff --git a/core/routers/auth/schema.py b/core/routers/auth/schema.py new file mode 100644 index 0000000..d6ebc92 --- /dev/null +++ b/core/routers/auth/schema.py @@ -0,0 +1,16 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/17 下午2:30 +# @File : schema.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from typing import Optional + +from pydantic import BaseModel, constr + + +class WechatLoginIn(BaseModel): + code: str + phone: Optional[constr(pattern='^1[0-9]{10}$')] = None + encrypted_data: Optional[str] = None + iv: Optional[str] = None diff --git a/core/routers/auth/services.py b/core/routers/auth/services.py new file mode 100644 index 0000000..efa7b98 --- /dev/null +++ b/core/routers/auth/services.py @@ -0,0 +1,9 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/17 下午8:14 +# @File : services.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from fastapi.security import OAuth2PasswordBearer + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") diff --git a/core/routers/card/__init__.py b/core/routers/card/__init__.py new file mode 100644 index 0000000..8494588 --- /dev/null +++ b/core/routers/card/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午3:59 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/routers/card/routes.py b/core/routers/card/routes.py new file mode 100644 index 0000000..d1460f2 --- /dev/null +++ b/core/routers/card/routes.py @@ -0,0 +1,220 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午4:51 +# @File : routes.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import logging +from typing import Literal, List + +import ujson +from fastapi import APIRouter, Depends, Query, HTTPException, status +import redis.asyncio as aioredis +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from . import schema +from .scraper import card_scraper +from config import settings +from core.base import schema as base_schema +from core.db.crud import CRUD +from core.db.engine import get_async_session, get_cache +from core.db.models import Card, SearchCardCache, UserCollection + +logger = logging.getLogger(__name__) +card_router = APIRouter(prefix='/card', tags=['卡册模块']) + + +@card_router.get('/search', response_model=base_schema.BaseResponse[schema.CardOut]) +async def get_card_search( + cert_number: str = Query(pattern='[a-zA-Z0-9]+'), + platform: base_schema.SupportPlatformType = Query(...), + db_session: AsyncSession = Depends(get_async_session), + redis_conn: aioredis.Redis = Depends(get_cache) +): + logger.info(f'Get card search request: {cert_number}') + logger.info(platform) + result = None + # 缓存 + cache_keyname = f'card_book:card_search:{platform.value}:{cert_number}' + result_str = await redis_conn.get(cache_keyname) + if result_str is not None: + result = ujson.decode(result_str) + return {'data': result} + + db_crud = CRUD(db_session, SearchCardCache) + # 缓存库 + if result is None: + card = await db_crud.get(filters={'cert_number': cert_number, 'source': platform.value}) + if card is not None: + result = card.data + + # 采集 + if result is None: + scraper_result = await card_scraper.choice(platform.value, cert_number) + if scraper_result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='相关卡片信息未找到' + ) + result = scraper_result['data'] + await db_crud.create( + data={ + **scraper_result, + 'source': result.get('source') or platform.value, + }, + filters={'cert_number': cert_number, 'source': platform.value} + ) + await redis_conn.set(cache_keyname, ujson.encode(result), ex=settings.REDIS_CACHE_EXPIRATION_SECONDS) + + return {'data': result} + + +@card_router.get( + '/me/all', + response_model=base_schema.BaseResponse[ + base_schema.Paginated[ + List[schema.CardOut] + ] + ], +) +async def get_me_all_card( + page: int = Query(default=1, gt=0), + page_size: int = Query(default=15, gt=0, le=200), + category: Literal['all', 'anime', 'sports', 'other', 'displayed'] = Query(default='all'), + db_session: AsyncSession = Depends(get_async_session) +): + user_id = None + conditions = [ + UserCollection.user_id == user_id, + ] + if category in ['anime', 'sports', 'other']: + conditions.append(Card.category == category) + + if category == 'displayed': + conditions.append(UserCollection.displayed is True) + + total_stmt = ( + select(func.count()).select_from(UserCollection).filter( + *conditions + ) + ) + total = await db_session.execute(total_stmt) + stmt = ( + select(UserCollection, Card) + .join(Card, UserCollection.card_id == Card.id) + .where(*conditions) + .order_by(UserCollection.created_at.desc()).limit(page_size).offset((page - 1) * page_size) + ) + records = await db_session.execute(stmt) + + return { + 'data': { + 'list': [record.dict() for record in records.scalars().all()], + 'total': total.scalar(), + 'page': page, + 'page_size': page_size, + }, + } + + +@card_router.get( + '/visit/{user_id}/all', + response_model=base_schema.BaseResponse[ + base_schema.Paginated[ + List[schema.CardOut] + ] + ], +) +async def get_visit_user_all_card( + user_id: str, + page: int = Query(default=1, gt=0), + page_size: int = Query(default=15, gt=0, le=200), + # category: Literal['displayed'] = Query(default='displayed'), + db_session: AsyncSession = Depends(get_async_session) +): + conditions = [ + UserCollection.user_id == user_id, UserCollection.displayed is True + ] + total_stmt = ( + select(func.count()).select_from(UserCollection).filter(*conditions) + ) + total = await db_session.execute(total_stmt) + stmt = ( + select(UserCollection, Card) + .join(Card, UserCollection.card_id == Card.id) + .where(*conditions) + .order_by(UserCollection.created_at.desc()).limit(page_size).offset((page - 1) * page_size) + ) + records = await db_session.execute(stmt) + + return { + 'data': { + 'list': [record.dict() for record in records.scalars().all()], + 'total': total.scalar(), + 'page': page, + 'page_size': page_size, + }, + } + + +@card_router.post('/create', response_model=base_schema.BaseResponse[schema.CardOut]) +async def created_card( + payload: schema.Card, + db_session: AsyncSession = Depends(get_async_session) +): + db_crud = CRUD(db_session, Card) + card: Card = await db_crud.create( + data={ + **payload.dict(exclude_none=True), + 'create_user_id': None, + 'source': payload.source.value, + }, + filters={'cert_number': payload.cert_number, 'source': payload.source.value} + ) + return {'data': card} + + +@card_router.post('/{card_id}/update/') +async def update_card_price_info( + card_id: int, + payload: schema.UpdateCard, + db_session: AsyncSession = Depends(get_async_session), +): + db_crud = CRUD(db_session, UserCollection) + result = await db_crud.update( + data=payload.dict(exclude_none=True), + filters={'card_id': card_id, 'user_id': None} + ) + return {'message': 'success' if result is not None else 'fail'} + + +@card_router.post('/{card_id}/display/') +async def display_card_status( + card_id: int, + status: bool, + db_session: AsyncSession = Depends(get_async_session) +): + db_crud = CRUD(db_session, UserCollection) + result = await db_crud.update( + data={ + 'displayed': status + }, + filters={'card_id': card_id, 'user_id': None} + ) + return {'message': 'success' if result is not None else 'fail'} + + +@card_router.delete('/delete/', response_model=base_schema.BaseResponse) +async def delete_card( + payload: schema.DeleteCardIn, + db_session: AsyncSession = Depends(get_async_session) +): + db_crud = CRUD(db_session, Card) + state = await db_crud.delete(filters={'card_id_in': payload.card_ids}) + + if state is False: + db_crud = CRUD(db_session, UserCollection) + state = await db_crud.delete(filters={'card_id_in': payload.card_ids, 'user_id': None}) + + return {'message': 'success' if state else 'fail'} diff --git a/core/routers/card/schema.py b/core/routers/card/schema.py new file mode 100644 index 0000000..3c0c76d --- /dev/null +++ b/core/routers/card/schema.py @@ -0,0 +1,41 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午5:01 +# @File : schema.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from typing import Optional, Literal + +from pydantic import BaseModel, conint + +from core.base.schema import SupportPlatformType, PlatformSourceType + + +class Card(BaseModel): + cert_number: str + card_year: conint(gt=0, le=2099) + card_brand: Optional[str] = None + card_score: Optional[float] = None + auto_score: Optional[float] = None + card_name: str + card_number: Optional[str] = None + card_type: Optional[str] = None + card_picture: Optional[dict] = {} + card_spare_picture: Optional[dict] = {} + source: SupportPlatformType + + +class UpdateCard(BaseModel): + purchase_price: float + display_price: float + + +class CardOut(Card, UpdateCard): + card_year: int + source: PlatformSourceType + purchase_price: Optional[float] = None + display_price: Optional[float] = None + + +class DeleteCardIn(BaseModel): + card_ids: list[int] diff --git a/core/routers/card/scraper.py b/core/routers/card/scraper.py new file mode 100644 index 0000000..50c2be7 --- /dev/null +++ b/core/routers/card/scraper.py @@ -0,0 +1,470 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/14 上午10:34 +# @File : scraper.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import logging + +from curl_cffi import requests +from fake_useragent import UserAgent +from parsel import Selector +from simple_spider_tool import jsonpath, regx_match, format_json + +from config import settings +from core.base.exceptions import SearchCardNotFound, SearchCardRetry +from core.utils.helper import async_retry + +logger = logging.getLogger(__name__) + + +class SiteApi: + def __init__(self): + self.user_agent = UserAgent( + browsers=['Google', 'Chrome', 'Firefox', 'Edge', 'Safari'], + os=['Windows', 'Mac OS X'], + platforms=['desktop'], + min_version=127.0 + ) + self.session = requests.AsyncSession() + self.headers = { + 'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", + 'Accept-Encoding': "gzip, deflate, br, zstd", + 'Sec-Fetch-Site': "same-origin", + 'Sec-Fetch-Mode': "cors", + 'Sec-Fetch-Dest': "empty", + 'Accept-Language': "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", + } + + async def request( + self, + method, + url, + headers=None, + params=None, + data=None, + json=None, + use_abroad_proxy: bool = False, + **kwargs + ): + headers = { + **self.headers, + 'User-Agent': self.user_agent.random, + **(headers or {}), + } + # 境外代理 + if use_abroad_proxy: + kwargs['proxies'] = { + 'http': settings.OVERSEAS_PROXY, + 'https': settings.OVERSEAS_PROXY + } + r = await self.session.request( + method, url, headers=headers, params=params, data=data, json=json, **kwargs + ) + logger.debug(f'耗时:{url} {r.elapsed}') + return r + + async def get(self, url, headers=None, params=None, **kwargs): + return await self.request('GET', url, headers=headers, params=params, **kwargs) + + async def post(self, url, headers=None, params=None, data=None, json=None, **kwargs): + return await self.request( + 'POST', url, headers=headers, params=params, data=data, json=json, **kwargs + ) + + @async_retry(exceptions=(SearchCardRetry,)) + async def psa(self, cert_number: str | int): + """PSA""" + url = f'https://www.psacard.com/cert/{cert_number}' + headers = { + # 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://www.psacard.com', + 'Referer': f'https://www.psacard.com/cert/{cert_number}', + } + r = await self.get(url, headers=headers, use_abroad_proxy=True) + chck_string = 'Just a moment...' + if chck_string in r.text: + raise SearchCardRetry(chck_string) + return r + + async def psa_images(self, cert_number: str | int, images_id: str | int): + """PSA图片""" + url = 'https://www.psacard.com/GetPSACertImages' + headers = { + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://www.psacard.com', + 'Referer': f'https://www.psacard.com/cert/{cert_number}', + } + payload = { + 'certID': images_id + } + return await self.post(url, headers=headers, params=payload, use_abroad_proxy=True) + + async def bgs(self, cert_number: str | int): + """BGS""" + url = 'https://www.beckett.com/api/grading/lookup' + params = { + 'category': 'BGS', + 'serialNumber': cert_number + } + r = await self.get(url, headers=self.headers, params=params, use_abroad_proxy=True) + return r + + async def cgc(self, cert_number: str | int): + """CGC""" + url = f'https://www.cgccards.com/certlookup/{cert_number}/' + headers = { + 'referer': 'https://www.cgccards.com/' + } + return await self.get(url, headers=headers) + + async def gbtc(self, cert_number: str | int): + """北京公博""" + url = 'https://wapi.gongbocoins.com/gbca/orderCoin/getWebsiteRatingInfo' + headers = { + 'Content-Type': "application/json;charset=UTF-8", + 'origin': 'https://www.gongbocoins.com', + 'referer': "https://www.gongbocoins.com" + } + payload = { + 'ratingCode': cert_number + } + return await self.post(url, headers=headers, json=payload) + + async def bctc(self, cert_number: str | int): + """南京保粹""" + url = f'https://web-api.baocuicoin.com/Search/index?keyword={cert_number}&code' + headers = { + 'origin': 'https://www.baocuicoin.com/', + 'referer': "https://www.baocuicoin.com/" + } + r = await self.post(url, headers=headers) + return r + + async def ccg(self, cert_number: str | int): + """重庆藏卡""" + url = 'https://webapi.cangcard.com/webapi/card/num_list' + payload = { + 'num': cert_number + } + headers = { + 'Content-Type': 'application/json', + 'Referer': "https://servicewechat.com/wx0b020dffc7cf7e74/75/page-frame.html", + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090c1f)XWEB/11581' + } + r = await self.post(url, headers=headers, json=payload) + return r + + async def close(self): + await self.session.close() + + +class CardScraper: + def __init__(self): + self.api = SiteApi() + self._handlers = None + + @property + def handlers(self): + if self._handlers is None: + self._handlers = { + 'psa': self.psa, + 'bgs': self.bgs, + 'cgc': self.cgc, + 'gbtc': self.gbtc, + 'ccg': self.ccg, + 'bctc': self.bctc, + } + return self._handlers + + async def psa(self, cert_number: str | int) -> dict | None: + """PSA""" + r = await self.api.psa(cert_number) + html = Selector(r.text) + if html.xpath('//div[contains(@class, "alert-danger")]/p[contains(text(), "not found")]').get() is not None: + return + + info_elements = html.xpath('//main[@id="mainContent"]/div/table//tr') + info = dict() + for info_element in info_elements: + name = info_element.xpath('./th[1]').xpath('string()').get() + name = name.lower().replace(' ', '_').replace('/', '_') + value = info_element.xpath('./td[1]').xpath('string()').get() + info[name.strip()] = value.strip() + picture_elements = info_elements.xpath('.//img') + info['pictures'] = [ + { + 'name': picture_element.xpath('./@alt').get(), + 'value': picture_element.xpath('./@src').get(), + } for picture_element in picture_elements + ] + + item = dict(cert_number=None) + # 评级卡号 + item['cert_number'] = info['certification_number'] + # 年份 + item['card_year'] = info.get('year') + # 卡片品牌 + item['card_brand'] = info.get('brand') + # 卡片评分 + card_grade = info.get('card_grade') or '' + item['card_score'] = regx_match(r'(\d+)', card_grade, first=True) + # 签字评分 + auto_score = info.get('autograph_grade') or '' + item['auto_score'] = regx_match(r'(\d+)', auto_score, first=True) + # 卡片名称 + item['card_name'] = info.get('player') + # 卡片编号 + item['card_number'] = info.get('card_number') + # 卡片类型 + item['card_type'] = info.get('sport') + images_id = html.xpath( + '//script[contains(text(), "getPSACertImages")]/text()' + ).re_first(r'getPSACertImages\(\'(\d+)\'\);') + + if images_id: + r = await self.api.psa_images(cert_number, images_id) + result = r.json() + # 卡片正、反图片(远程) + item['card_spare_picture'] = { + 'front_picture': jsonpath(result, '$.PSACertImages[0].OriginalImageUrl', first=True), + 'reverse_picture': jsonpath(result, '$.PSACertImages[1].OriginalImageUrl', first=True) + } + # 来源 + item['source'] = 'psa' + return { + 'cert_number': item['cert_number'], + 'card_number': cert_number, + 'data': item, + 'raw_data': info, + } + + async def bgs(self, cert_number: str | int) -> dict | None: + r = await self.api.bgs(cert_number) + if r.status_code != 200: + return + result = r.json() + + item = dict(cert_number=None) + # 评级卡号 + item['cert_number'] = result.get('item_id') + # 年份 + set_name = result.get('set_name') or '' + item['card_year'] = regx_match(r'(\d{,4}).*', set_name, first=True) + # 卡片品牌 + item['card_brand'] = result.get('sport_name') + # 卡片评分 + item['card_score'] = result.get('final_grade') + # 签字评分 + auto_score = result.get('autograph_grade') + item['auto_score'] = auto_score if auto_score != '0.0' else None + # 卡片名称 + item['card_name'] = result.get('player_name') + # 卡片编号 无 + # 卡片类型 无 + # 卡片正、反图片(远程) 无 + # 来源 + item['source'] = 'bgs' + return { + 'cert_number': item['cert_number'], + 'card_number': cert_number, + 'data': item, + 'raw_data': result, + } + + async def cgc(self, cert_number: str | int): + """CGC""" + r = await self.api.cgc(cert_number) + html = Selector(r.text) + if html.xpath('//div[@class="error" and @ng-show]').get() is None: + return + + info_elements = html.xpath('//div[@class="content-wrapper"]//dl') + info = dict() + for info_element in info_elements: + name = info_element.xpath('./dt[1]').xpath('string()').get() + name = name.lower().replace('#', '').strip().replace(' ', '_') + value = info_element.xpath('./dd[1]').xpath('string()').get() + info[name.strip()] = value.strip() + + item = dict(cert_number=None) + # 评级卡号 + item['cert_number'] = info.get('cert') + # 年份 + item['card_year'] = info.get('year') + # 卡片品牌 + item['card_brand'] = info.get('game') + # 卡片评分 + item['card_score'] = regx_match(r'(\d+)', info.get('grade') or '', first=True) + # 签字评分 无 + # 卡片名称 + item['card_name'] = info.get('card_name') + # 卡片编号 + item['card_number'] = info.get('card_number') + # 卡片类型 无 + # 卡片正、反图片(远程) + picture_query = '//div[@class="certlookup-images"]/div[@class="certlookup-images-item"][{index}]//img/@src' + item['card_spare_picture'] = { + 'front_picture': html.xpath(picture_query.format(index=1)).get(), + 'reverse_picture': html.xpath(picture_query.format(index=2)).get() + } + # 来源 + item['source'] = 'cgc' + return { + 'cert_number': item['cert_number'], + 'card_number': cert_number, + 'data': item, + 'raw_data': info, + } + + async def gbtc(self, cert_number: str | int) -> dict | None: + """北京公博""" + r = await self.api.gbtc(cert_number) + result = r.json() + if result.get('errorCode') != 0: + return + + attrs = jsonpath(result, '$.data.attr', first=True) + attrs_string = ';'.join(attrs) + + item = dict(cert_number=None) + # 评级卡号 + item['cert_number'] = jsonpath(result, '$.data.ratingCode', first=True) + # 年份 + item['card_year'] = regx_match(r'年份:(\d{,4})', attrs_string, first=True) + # 卡片品牌 + item['card_brand'] = regx_match(r'发行商:(.*?);', attrs_string, first=True) + # 卡片评分 + item['card_score'] = jsonpath(result, '$.data.goodsScore', first=True) + # 签字评分 + item['auto_score'] = regx_match(r'签字分数:(\d+)', attrs_string, first=True) + # 卡片名称 + item['card_name'] = jsonpath(result, '$.data.goodsName', first=True) + # 卡片编号 + item['card_number'] = regx_match(r'卡片编码:(.*?);', attrs_string, first=True) + # 卡片类型 无 + # 卡片正、反图片(远程) + item['card_spare_picture'] = { + 'front_picture': jsonpath(result, '$.data.imgList[0].self', first=True), + 'reverse_picture': jsonpath(result, '$.data.imgList[1].self', first=True) + } + # 来源 + item['source'] = 'gbtc' + return { + 'cert_number': item['cert_number'], + 'card_number': cert_number, + 'data': item, + 'raw_data': result.get('data'), + } + + async def ccg(self, cert_number: str | int) -> dict | None: + """重庆藏卡""" + r = await self.api.ccg(cert_number) + result = r.json() + if result.get('code') != '200': + return + + item = dict(cert_number=None) + # 评级卡号 + item['cert_number'] = jsonpath(result, '$.data.num', first=True) + # 年份 + item['card_year'] = jsonpath(result, '$.data.year', first=True) + # 卡片品牌 + content = jsonpath(result, '$.data.content', first=True) + item['card_brand'] = regx_match(r'\d{4}(.*?)\.', content, first=True) + # 卡片评分 + item['card_score'] = jsonpath(result, '$.data.zm_num', first=True) + # 签字评分 无 + # 卡片名称 + item['card_name'] = jsonpath(result, '$.data.name', first=True) + # 卡片类型 无 + # 卡片编号 + item['card_number'] = content + # 卡片正、反图片(远程) + item['card_spare_picture'] = { + 'front_picture': jsonpath(result, '$.data.img_z', first=True), + 'reverse_picture': jsonpath(result, '$.data.img_f', first=True) + } + # 来源 + item['source'] = 'ccg' + return { + 'cert_number': item['cert_number'], + 'card_number': cert_number, + 'data': item, + 'raw_data': result.get('data'), + } + + async def bctc(self, cert_number: str | int) -> dict | None: + """南京保粹""" + r = await self.api.bctc(cert_number) + result = r.json() + if result.get('code') != '200': + return + + details = jsonpath(result, '$.info.details', first=True) + + item = dict(cert_number=None) + # 评级卡号 + item['cert_number'] = jsonpath(details, '$.grade[?(@.title =="证书编号")].val', first=True) + # 年份 + year_string = jsonpath(details, '$.grade[?(@.title =="年份")].val', first=True) + item['card_year'] = year = regx_match(r'(\d{,4})', year_string, first=True) + # 卡片品牌 + item['card_brand'] = year_string.replace(f'{year}', '').strip() if year_string else None + # 卡片评分 + item['card_score'] = jsonpath(details, '$.grade[?(@.title =="分值")].val', first=True) + # 签字评分 + item['auto_score'] = jsonpath(details, '$.grade[?(@.title =="签字分值")].val', first=True) + # 卡片名称 + item['card_name'] = jsonpath(details, '$.grade[?(@.title =="名称")].val', first=True) + # 卡片编号 + item['card_number'] = jsonpath(details, '$.grade[?(@.title =="编号")].val', first=True) + # 卡片类型 无 + # 卡片正、反图片(远程) + item['card_spare_picture'] = { + 'front_picture': jsonpath(details, '$.info.picurl', first=True), + 'reverse_picture': jsonpath(details, '$.info.dt_pics', first=True) + } + # 来源 + item['source'] = 'bctc' + return { + 'cert_number': item['cert_number'], + 'card_number': cert_number, + 'data': item, + 'raw_data': result.get('info'), + } + + async def choice(self, platform: str, cert_number: str | int) -> dict | None: + handler = self.handlers.get(platform.lower()) + if handler is None: + raise Exception('平台不支持') + return await handler(cert_number) + + async def close(self): + await self.api.close() + + +card_scraper = CardScraper() + + +async def main(): + import time + started = time.time() + print(format_json(await card_scraper.choice('psa', '83610272'))) + print(time.time() - started) + # print(format_json(await card_scraper.choice('psa', '83610272'))) + # print(format_json(await card_scraper.psa('83610272'))) + # print(format_json(await card_scraper.cgc('6011456021'))) + # print(format_json(await card_scraper.bgs('16770585'))) + # print(format_json(await card_scraper.gbtc('8110071983'))) + # print(format_json(await card_scraper.bctc('E000033353'))) + # print(format_json(await card_scraper.ccg('230810139152'))) + + +if __name__ == '__main__': + import asyncio + import sys + + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + asyncio.run(main()) diff --git a/core/routers/card/services.py b/core/routers/card/services.py new file mode 100644 index 0000000..05610b0 --- /dev/null +++ b/core/routers/card/services.py @@ -0,0 +1,18 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/17 上午1:00 +# @File : services.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db.models import SearchCardCache + + +async def get_cache_card_by_number(db_session: AsyncSession, *, cert_number: str) -> SearchCardCache | None: + query = select(SearchCardCache).where(SearchCardCache.cert_number == cert_number) + result = await db_session.execute(query) + card: SearchCardCache | None = result.scalar_one_or_none() + + return card diff --git a/core/routers/file/__init__.py b/core/routers/file/__init__.py new file mode 100644 index 0000000..b81037e --- /dev/null +++ b/core/routers/file/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午4:01 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : 文件存储模块 diff --git a/core/routers/file/routes.py b/core/routers/file/routes.py new file mode 100644 index 0000000..7ebd3a1 --- /dev/null +++ b/core/routers/file/routes.py @@ -0,0 +1,76 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午4:50 +# @File : routes.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import hashlib +import logging +import os +from io import BytesIO +from typing import Literal + +from fastapi import APIRouter, UploadFile, File, status, HTTPException, Depends +from PIL import Image + +from config import settings +from . import schema +from core.base.schema import BaseResponse + +logger = logging.getLogger(__name__) +file_router = APIRouter(prefix='/file', tags=['文件存储模块']) + + +@file_router.post('/{upload_type}/upload', response_model=BaseResponse[schema.FileOut]) +async def upload_file( + upload_type: Literal['card', 'chat', 'avatar'], + file: UploadFile = File(...) +): + # 验证文件类型 + if file.content_type not in settings.UPLOADED_ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=f'Unsupported media type: {file.content_type}' + ) + + # 验证文件大小 + if file.size > settings.UPLOADED_MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f'File exceeds maximum size' + ) + + try: + file_chunks = await file.read() + file_hash = hashlib.sha256(file_chunks).hexdigest() + file_name = f'{file_hash}.jpg' + dirname_args = [settings.UPLOADED_FILES_DIR, upload_type.lower()] + if upload_type.lower() != 'card': + dirname_args.append('test') + + save_path = os.path.join(*dirname_args) + save_file_path = os.path.join(*dirname_args, file_name) + if os.path.exists(save_file_path) is False: + # 创建目录 + os.makedirs(save_path, exist_ok=True) + + img = Image.open(BytesIO(file_chunks)) + img = img.convert("RGB") + img.save(save_file_path, format="JPEG") + + file_info = { + 'filename': file_name, + 'content_type': file.content_type, + 'size': os.path.getsize(save_path), + 'hash': file_hash, + 'url': save_file_path + } + return {'data': file_info} + + except Exception as e: + logger.error(f'Upload failed: {str(e)}') + logger.exception(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f'File upload failed' + ) diff --git a/core/routers/file/schema.py b/core/routers/file/schema.py new file mode 100644 index 0000000..8e9e3e2 --- /dev/null +++ b/core/routers/file/schema.py @@ -0,0 +1,14 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午5:01 +# @File : schema.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from pydantic import BaseModel + + +class FileOut(BaseModel): + filename: str + content_type: str + size: int + hash: str diff --git a/core/routers/im/__init__.py b/core/routers/im/__init__.py new file mode 100644 index 0000000..ac2bc6c --- /dev/null +++ b/core/routers/im/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午1:03 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/routers/im/routes.py b/core/routers/im/routes.py new file mode 100644 index 0000000..9023cda --- /dev/null +++ b/core/routers/im/routes.py @@ -0,0 +1,33 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午4:53 +# @File : routes.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from typing import Annotated + +from fastapi import APIRouter, Query + +im_router = APIRouter(prefix='/im', tags=['聊天模块']) + + +# @im_router.get('/chat/{user_id}/messages/') +# def get_single_chat_messages( +# user_id: int, +# limit: Annotated[int, Query(ge=1, le=200)] = 20, +# cursor: int = -1, +# ): +# pass +# +# +# @im_router.get('/chats/messages/') +# def get_chats_messages( +# limit: Annotated[int, Query(ge=1, le=200)] = 100, +# cursor: int = -1, +# ): +# pass +# +# +# @im_router.websocket('/chat') +# def get_chat(): +# pass diff --git a/core/routers/user/__init__.py b/core/routers/user/__init__.py new file mode 100644 index 0000000..6540a52 --- /dev/null +++ b/core/routers/user/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午4:01 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/routers/user/routes.py b/core/routers/user/routes.py new file mode 100644 index 0000000..b3461da --- /dev/null +++ b/core/routers/user/routes.py @@ -0,0 +1,9 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午4:53 +# @File : routes.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +from fastapi import APIRouter + +user_router = APIRouter(prefix='/user', tags=['用户模块']) diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 0000000..ef38f36 --- /dev/null +++ b/core/utils/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午5:42 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/utils/helper.py b/core/utils/helper.py new file mode 100644 index 0000000..8864a7b --- /dev/null +++ b/core/utils/helper.py @@ -0,0 +1,34 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午7:40 +# @File : helper.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import asyncio +import functools +from typing import Callable + + +def async_retry(max_retries: int = 3, exceptions: tuple = (Exception,)): + """ + 异步重试装饰器。 + + :param max_retries: 最大重试次数。 + :param exceptions: 触发重试的异常类型。 + """ + + def decorator(func: Callable): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + retries = 0 + while retries < max_retries: + try: + return await func(*args, **kwargs) + except exceptions as e: + retries += 1 + if retries >= max_retries: + raise # 如果达到最大重试次数,抛出异常 + + return wrapper + + return decorator diff --git a/core/utils/response.py b/core/utils/response.py new file mode 100644 index 0000000..fe27950 --- /dev/null +++ b/core/utils/response.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/13 下午5:43 +# @File : response.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/wechat/__init__.py b/core/wechat/__init__.py new file mode 100644 index 0000000..cceba7b --- /dev/null +++ b/core/wechat/__init__.py @@ -0,0 +1,6 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/17 下午10:52 +# @File : __init__.py.py +# @Software : PyCharm +# @Author : xingc +# @Desc : diff --git a/core/wechat/api.py b/core/wechat/api.py new file mode 100644 index 0000000..616b186 --- /dev/null +++ b/core/wechat/api.py @@ -0,0 +1,68 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/17 下午4:49 +# @File : api.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import time + +import httpx +import ujson + +from core.db.engine import get_cache + + +class WechatApi: + def __init__(self, appid, secret): + self.appid = appid + self.secret = secret + self._access_token = None + self._access_token_expire_time = None + self.session = httpx.AsyncClient() + + @property + async def access_token(self): + if any([ + self._access_token is None, + self._access_token_expire_time < time.time(), + ]): + await self.get_access_token() + return self._access_token + + async def get_access_token(self): + async with get_cache() as redis_conn: + result_str = await redis_conn.get('access_token') + + if result_str: + access_token, expire_time = result_str.split('|') + self._access_token = access_token + self._access_token_expire_time = int(expire_time) + else: + url = 'https://api.weixin.qq.com/cgi-bin/token' + params = { + 'appid': self.appid, + 'secret': self.secret, + 'grant_type': 'client_credential' + } + r = await self.session.get(url, params=params) + result = r.json() + if 'errcode' not in result_str: + expires_in = result['expires_in'] + self._access_token_expire_time = time.time() + expires_in + self._access_token = result['access_token'] + await redis_conn.set( + 'access_token', f'{self._access_token}|{self._access_token_expire_time}', ex=expires_in + ) + + return self._access_token + + async def code_to_session(self, code: str): + url = 'https://api.weixin.qq.com/sns/jscode2session' + params = { + 'appid': self.appid, + 'secret': self.secret, + 'js_code': code, + 'grant_type': 'authorization_code' + } + r = await self.session.get(url, params=params) + return r.json() diff --git a/main.py b/main.py new file mode 100644 index 0000000..238c4ba --- /dev/null +++ b/main.py @@ -0,0 +1,19 @@ +# -*- coding = utf-8 -*- +# @Time : 2025/2/12 下午4:55 +# @File : main.py +# @Software : PyCharm +# @Author : xingc +# @Desc : +import logging + +from config import LOGGING_CONFIG +from core.app import create_app + +# logging.config.dictConfig(LOGGING_CONFIG) + +app = create_app() + +if __name__ == '__main__': + import uvicorn + + uvicorn.run(app, host='0.0.0.0', port=8001) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..125406d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,70 @@ +alembic +alembic-async +Brotli +curl_cffi +httpx +httpx[http2] +parsel +simple-spider-tool +fake_useragent +fastapi==0.115.6 +pydantic==2.10.4 +pydantic_settings +pyjwt +passlib[bcrypt] +python-multipart +pillow +redis +SQLAlchemy==2.0.23 +SQLAlchemy[asyncio] +SQLAlchemy[aiomysql] +ujson +uvicorn[standard] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +