first init

This commit is contained in:
xingc
2025-02-18 14:26:47 +08:00
commit 48a02974fc
41 changed files with 2297 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.idea
# 虚拟环境
.venv
venv
tests
uploads
**/__pycache__
**/*.zip
**/*.log
**/*.py[co]

31
README.md Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
Generic single-database configuration.

65
alembic/env.py Normal file
View 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
View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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]

View File

@@ -0,0 +1,6 @@
# -*- coding = utf-8 -*-
# @Time : 2025/2/13 下午3:48
# @File : __init__.py.py
# @Software : PyCharm
# @Author : xingc
# @Desc :

View 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}}

View 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

View 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")

View 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
View 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'}

View 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]

View 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())

View 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

View File

@@ -0,0 +1,6 @@
# -*- coding = utf-8 -*-
# @Time : 2025/2/13 下午4:01
# @File : __init__.py.py
# @Software : PyCharm
# @Author : xingc
# @Desc : 文件存储模块

View 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'
)

View 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

View 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
View 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

View File

@@ -0,0 +1,6 @@
# -*- coding = utf-8 -*-
# @Time : 2025/2/13 下午4:01
# @File : __init__.py.py
# @Software : PyCharm
# @Author : xingc
# @Desc :

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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]