first init
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.idea
|
||||||
|
|
||||||
|
# 虚拟环境
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
|
||||||
|
tests
|
||||||
|
uploads
|
||||||
|
**/__pycache__
|
||||||
|
|
||||||
|
**/*.zip
|
||||||
|
**/*.log
|
||||||
|
**/*.py[co]
|
||||||
31
README.md
Normal file
31
README.md
Normal file
@@ -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<xingcys@gmail.com>)
|
||||||
119
alembic.ini
Normal file
119
alembic.ini
Normal file
@@ -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
|
||||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
65
alembic/env.py
Normal file
65
alembic/env.py
Normal file
@@ -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())
|
||||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal 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"}
|
||||||
102
alembic/versions/4c950125b0a6_init.py
Normal file
102
alembic/versions/4c950125b0a6_init.py
Normal file
@@ -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 ###
|
||||||
133
config.py
Normal file
133
config.py
Normal file
@@ -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 = '<KEY>'
|
||||||
|
APP_SECRET: str = '<KEY>'
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
6
core/__init__.py
Normal file
6
core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/12 下午4:39
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
51
core/app.py
Normal file
51
core/app.py
Normal file
@@ -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
|
||||||
6
core/base/__init__.py
Normal file
6
core/base/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/14 上午10:50
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
19
core/base/exceptions.py
Normal file
19
core/base/exceptions.py
Normal file
@@ -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
|
||||||
55
core/base/schema.py
Normal file
55
core/base/schema.py
Normal file
@@ -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'
|
||||||
6
core/db/__init__.py
Normal file
6
core/db/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午12:17
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
185
core/db/crud.py
Normal file
185
core/db/crud.py
Normal file
@@ -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)
|
||||||
52
core/db/engine.py
Normal file
52
core/db/engine.py
Normal file
@@ -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)
|
||||||
197
core/db/models.py
Normal file
197
core/db/models.py
Normal file
@@ -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")
|
||||||
13
core/routers/__init__.py
Normal file
13
core/routers/__init__.py
Normal file
@@ -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]
|
||||||
6
core/routers/auth/__init__.py
Normal file
6
core/routers/auth/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午3:48
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
72
core/routers/auth/routes.py
Normal file
72
core/routers/auth/routes.py
Normal file
@@ -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}}
|
||||||
16
core/routers/auth/schema.py
Normal file
16
core/routers/auth/schema.py
Normal file
@@ -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
|
||||||
9
core/routers/auth/services.py
Normal file
9
core/routers/auth/services.py
Normal file
@@ -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")
|
||||||
6
core/routers/card/__init__.py
Normal file
6
core/routers/card/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午3:59
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
220
core/routers/card/routes.py
Normal file
220
core/routers/card/routes.py
Normal file
@@ -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'}
|
||||||
41
core/routers/card/schema.py
Normal file
41
core/routers/card/schema.py
Normal file
@@ -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]
|
||||||
470
core/routers/card/scraper.py
Normal file
470
core/routers/card/scraper.py
Normal file
@@ -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())
|
||||||
18
core/routers/card/services.py
Normal file
18
core/routers/card/services.py
Normal file
@@ -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
|
||||||
6
core/routers/file/__init__.py
Normal file
6
core/routers/file/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午4:01
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc : 文件存储模块
|
||||||
76
core/routers/file/routes.py
Normal file
76
core/routers/file/routes.py
Normal file
@@ -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'
|
||||||
|
)
|
||||||
14
core/routers/file/schema.py
Normal file
14
core/routers/file/schema.py
Normal file
@@ -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
|
||||||
6
core/routers/im/__init__.py
Normal file
6
core/routers/im/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午1:03
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
33
core/routers/im/routes.py
Normal file
33
core/routers/im/routes.py
Normal file
@@ -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
|
||||||
6
core/routers/user/__init__.py
Normal file
6
core/routers/user/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午4:01
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
9
core/routers/user/routes.py
Normal file
9
core/routers/user/routes.py
Normal file
@@ -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=['用户模块'])
|
||||||
6
core/utils/__init__.py
Normal file
6
core/utils/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午5:42
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
34
core/utils/helper.py
Normal file
34
core/utils/helper.py
Normal file
@@ -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
|
||||||
6
core/utils/response.py
Normal file
6
core/utils/response.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/13 下午5:43
|
||||||
|
# @File : response.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
6
core/wechat/__init__.py
Normal file
6
core/wechat/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding = utf-8 -*-
|
||||||
|
# @Time : 2025/2/17 下午10:52
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software : PyCharm
|
||||||
|
# @Author : xingc
|
||||||
|
# @Desc :
|
||||||
68
core/wechat/api.py
Normal file
68
core/wechat/api.py
Normal file
@@ -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()
|
||||||
19
main.py
Normal file
19
main.py
Normal file
@@ -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)
|
||||||
70
requirements.txt
Normal file
70
requirements.txt
Normal file
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user