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

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