feat(routers): 完善auth、card、file、user相关接口开发
This commit is contained in:
@@ -4,10 +4,10 @@
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from .auth.routes import auth_router
|
||||
from .auth.routes import auth_router, test_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]
|
||||
routers = [auth_router, test_auth_router, card_router, im_router, file_router, user_router]
|
||||
|
||||
@@ -5,68 +5,133 @@
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import ujson
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, status
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from simple_spider_tool import md5, jsonpath
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from . import schema
|
||||
from config import settings
|
||||
from core.base import schema as base_schema
|
||||
from core.base.exceptions import WechatApiError
|
||||
from core.db.crud import CRUD
|
||||
from core.db.engine import get_async_session, get_cache
|
||||
from core.db.models import User
|
||||
from core.db.crud import CRUD
|
||||
from core.utils.helper import generate_keyname
|
||||
from core.wechat.api import wechat_api, decrypt_sensitive_data
|
||||
|
||||
from . import schema
|
||||
from .services import create_access_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_router = APIRouter(prefix='/auth', tags=['认证模块'])
|
||||
test_auth_router = APIRouter(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.get(
|
||||
'/phone',
|
||||
summary='获取绑定的手机号',
|
||||
response_model=base_schema.BaseResponse
|
||||
)
|
||||
async def get_wechat_phone(
|
||||
code: str = Query(pattern=r'^[a-zA-Z0-9]+'),
|
||||
):
|
||||
try:
|
||||
result = await wechat_api.get_phone_number(code)
|
||||
except WechatApiError:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Invalid code')
|
||||
return {'data': {'phone': jsonpath(result, '$.phone_info.phoneNumber')}}
|
||||
|
||||
|
||||
@auth_router.post('/wechat_login')
|
||||
@auth_router.post(
|
||||
'/wechat_login',
|
||||
summary='微信小程序授权登录',
|
||||
response_model=base_schema.BaseResponse[schema.Token]
|
||||
)
|
||||
async def wechat_login(
|
||||
payload: schema.WechatLoginIn,
|
||||
db_session: AsyncSession = Depends(get_async_session),
|
||||
redis_conn: aioredis.Redis = Depends(get_cache)
|
||||
db_session: Annotated[AsyncSession, Depends(get_async_session)],
|
||||
redis_conn: Annotated[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}
|
||||
# )
|
||||
keyname = generate_keyname(f'wechat_session:{md5(payload.code)}')
|
||||
# 验证code的有效性
|
||||
wechat_session_str = await redis_conn.get(keyname)
|
||||
if wechat_session_str is None:
|
||||
try:
|
||||
wechat_session = await wechat_api.code_to_session(payload.code)
|
||||
except WechatApiError:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Invalid code')
|
||||
logger.debug(f"WeChat session: {wechat_session}")
|
||||
else:
|
||||
wechat_session = ujson.decode(wechat_session_str)
|
||||
|
||||
return {'data': {'openid': openid}}
|
||||
openid = wechat_session.get('openid')
|
||||
if openid is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid code')
|
||||
|
||||
# 验证用户的存在/是否位首次登录
|
||||
user: User | None = await db_crud.get(filters={'wechat_openid': openid})
|
||||
if user is None:
|
||||
if not payload.encrypted_data or not payload.iv:
|
||||
await redis_conn.set(keyname, ujson.encode(wechat_session), ex=60 * 5)
|
||||
raise HTTPException(4001, detail='首次登录需要用户授权信息')
|
||||
|
||||
if all([
|
||||
# payload.phone,
|
||||
payload.encrypted_data,
|
||||
payload.iv,
|
||||
]) is False:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='请重试登录操作')
|
||||
|
||||
# 注册用户
|
||||
session_key = wechat_session.get('session_key')
|
||||
union_id = wechat_session.get('union_id')
|
||||
try:
|
||||
profile = await decrypt_sensitive_data(session_key, payload.encrypted_data, payload.iv)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='请重试登录操作')
|
||||
|
||||
logger.info(f"decrypt plaintext: {wechat_session}")
|
||||
user: User | bool = await db_crud.create(
|
||||
data={
|
||||
'nickname': profile.get('nickName'),
|
||||
'gender': profile.get('gender'),
|
||||
'wechat_openid': openid,
|
||||
'wechat_union_id': union_id,
|
||||
'phone': payload.phone,
|
||||
'avatar_url': profile.get('avatarUrl')
|
||||
},
|
||||
filters={'wechat_openid': openid}
|
||||
)
|
||||
await redis_conn.delete(keyname)
|
||||
|
||||
result = create_access_token({
|
||||
'sub': str(user.id)
|
||||
})
|
||||
return {'data': result}
|
||||
|
||||
|
||||
if settings.ENVIRONMENT == 'dev':
|
||||
@test_auth_router.post(
|
||||
'/token',
|
||||
summary='开发',
|
||||
description='点击“Authorize”在username输入“user_id”, password任意输入字符,点再点击“Authorize”即可'
|
||||
)
|
||||
async def login_for_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db_session: Annotated[AsyncSession, Depends(get_async_session)]
|
||||
) -> schema.Token:
|
||||
logger.info(form_data.username, form_data.password)
|
||||
db_crud = CRUD(db_session, User)
|
||||
user = await db_crud.get(filters={'user_id': form_data.username})
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail='Incorrect user_id')
|
||||
|
||||
result = create_access_token({
|
||||
'sub': str(user.user_id)
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -14,3 +14,13 @@ class WechatLoginIn(BaseModel):
|
||||
phone: Optional[constr(pattern='^1[0-9]{10}$')] = None
|
||||
encrypted_data: Optional[str] = None
|
||||
iv: Optional[str] = None
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class LoginUser(BaseModel):
|
||||
nickname: str
|
||||
user_id: int
|
||||
|
||||
@@ -4,6 +4,66 @@
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
import ujson
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from core.db.crud import CRUD
|
||||
from core.db.engine import get_async_session, get_cache
|
||||
from core.db.models import User
|
||||
from core.utils.helper import generate_keyname
|
||||
|
||||
from . import schema
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
|
||||
def create_access_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.ACCESS_SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return {'token_type': 'bearer', 'access_token': encoded_jwt}
|
||||
|
||||
|
||||
def verify_access_token(token: str):
|
||||
try:
|
||||
# payload = jwt.decode(token, settings.ACCESS_SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
payload = jwt.decode(token, settings.ACCESS_SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
db_session: Annotated[AsyncSession, Depends(get_async_session)],
|
||||
redis_conn: aioredis = Depends(get_cache)
|
||||
) -> schema.LoginUser:
|
||||
"""验证登录状态、获取的用户基础信息"""
|
||||
result = verify_access_token(token)
|
||||
user_id = result.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
keyname = generate_keyname(f'login_cache', use_today=True)
|
||||
user_str = await redis_conn.hget(keyname, user_id)
|
||||
if user_str is None:
|
||||
user_model = await CRUD(db_session, User).get(filters={'user_id': user_id})
|
||||
user = schema.LoginUser(**user_model.dict())
|
||||
await redis_conn.hset(keyname, user_id, user.json())
|
||||
else:
|
||||
user = schema.LoginUser(**ujson.decode(user_str))
|
||||
|
||||
return user
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
import re
|
||||
import logging
|
||||
from typing import Literal, List
|
||||
from typing import List, Literal
|
||||
|
||||
import ujson
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
@@ -13,19 +14,23 @@ 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
|
||||
from core.routers.auth.schema import LoginUser
|
||||
from core.routers.auth.services import get_current_user
|
||||
from core.utils.helper import generate_keyname, check_picture_for_empty
|
||||
|
||||
from . import schema
|
||||
from .scraper import card_scraper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
card_router = APIRouter(prefix='/card', tags=['卡册模块'])
|
||||
|
||||
|
||||
@card_router.get('/search', response_model=base_schema.BaseResponse[schema.CardOut])
|
||||
@card_router.get('/search', summary='搜索卡片', response_model=base_schema.BaseResponse[schema.Card])
|
||||
async def get_card_search(
|
||||
cert_number: str = Query(pattern='[a-zA-Z0-9]+'),
|
||||
platform: base_schema.SupportPlatformType = Query(...),
|
||||
@@ -36,7 +41,7 @@ async def get_card_search(
|
||||
logger.info(platform)
|
||||
result = None
|
||||
# 缓存
|
||||
cache_keyname = f'card_book:card_search:{platform.value}:{cert_number}'
|
||||
cache_keyname = generate_keyname(f'card_search:{platform.value}:{cert_number}', use_today=True)
|
||||
result_str = await redis_conn.get(cache_keyname)
|
||||
if result_str is not None:
|
||||
result = ujson.decode(result_str)
|
||||
@@ -63,8 +68,9 @@ async def get_card_search(
|
||||
**scraper_result,
|
||||
'source': result.get('source') or platform.value,
|
||||
},
|
||||
filters={'cert_number': cert_number, 'source': platform.value}
|
||||
filters={'cert_number': scraper_result['cert_number'], 'source': platform.value}
|
||||
)
|
||||
|
||||
await redis_conn.set(cache_keyname, ujson.encode(result), ex=settings.REDIS_CACHE_EXPIRATION_SECONDS)
|
||||
|
||||
return {'data': result}
|
||||
@@ -72,6 +78,7 @@ async def get_card_search(
|
||||
|
||||
@card_router.get(
|
||||
'/me/all',
|
||||
summary='卡片列表(所有者)',
|
||||
response_model=base_schema.BaseResponse[
|
||||
base_schema.Paginated[
|
||||
List[schema.CardOut]
|
||||
@@ -79,14 +86,15 @@ async def get_card_search(
|
||||
],
|
||||
)
|
||||
async def get_me_all_card(
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
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,
|
||||
UserCollection.user_id == login_user.user_id,
|
||||
]
|
||||
if category in ['anime', 'sports', 'other']:
|
||||
conditions.append(Card.category == category)
|
||||
@@ -94,14 +102,16 @@ async def get_me_all_card(
|
||||
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)
|
||||
select(Card, UserCollection)
|
||||
.join(Card, UserCollection.card_id == Card.id)
|
||||
.where(*conditions)
|
||||
.order_by(UserCollection.created_at.desc()).limit(page_size).offset((page - 1) * page_size)
|
||||
@@ -110,7 +120,12 @@ async def get_me_all_card(
|
||||
|
||||
return {
|
||||
'data': {
|
||||
'list': [record.dict() for record in records.scalars().all()],
|
||||
'list': [
|
||||
{
|
||||
**card.dict(),
|
||||
**user_collection.dict()
|
||||
} for card, user_collection in records
|
||||
],
|
||||
'total': total.scalar(),
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
@@ -120,28 +135,34 @@ async def get_me_all_card(
|
||||
|
||||
@card_router.get(
|
||||
'/visit/{user_id}/all',
|
||||
summary='卡片列表(访问者)',
|
||||
response_model=base_schema.BaseResponse[
|
||||
base_schema.Paginated[
|
||||
List[schema.CardOut]
|
||||
List[schema.CardVisitOut]
|
||||
]
|
||||
],
|
||||
)
|
||||
async def get_visit_user_all_card(
|
||||
user_id: str,
|
||||
user_id: int,
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
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
|
||||
UserCollection.user_id == user_id, UserCollection.displayed == True
|
||||
]
|
||||
|
||||
# 记录总数查询
|
||||
total_stmt = (
|
||||
select(func.count()).select_from(UserCollection).filter(*conditions)
|
||||
)
|
||||
total = await db_session.execute(total_stmt)
|
||||
|
||||
# 分页记录查询
|
||||
stmt = (
|
||||
select(UserCollection, Card)
|
||||
select(Card, UserCollection)
|
||||
.join(Card, UserCollection.card_id == Card.id)
|
||||
.where(*conditions)
|
||||
.order_by(UserCollection.created_at.desc()).limit(page_size).offset((page - 1) * page_size)
|
||||
@@ -150,7 +171,12 @@ async def get_visit_user_all_card(
|
||||
|
||||
return {
|
||||
'data': {
|
||||
'list': [record.dict() for record in records.scalars().all()],
|
||||
'list': [
|
||||
{
|
||||
**card.dict(),
|
||||
**user_collection.dict()
|
||||
} for card, user_collection in records
|
||||
],
|
||||
'total': total.scalar(),
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
@@ -158,63 +184,139 @@ async def get_visit_user_all_card(
|
||||
}
|
||||
|
||||
|
||||
@card_router.post('/create', response_model=base_schema.BaseResponse[schema.CardOut])
|
||||
@card_router.post('/create', summary='新增卡片', response_model=base_schema.BaseResponse[schema.CardOut])
|
||||
async def created_card(
|
||||
payload: schema.Card,
|
||||
payload: schema.CardIn,
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
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}
|
||||
card_crud = CRUD(db_session, Card)
|
||||
if payload.source.value != 'other':
|
||||
# 卡片库查询
|
||||
card: Card = await card_crud.get(filters={'cert_number': payload.cert_number})
|
||||
if card is None:
|
||||
# 卡片缓存库转正至卡片库, 重新查询保障数据准确性
|
||||
cache_card_crud = CRUD(db_session, SearchCardCache)
|
||||
cache_card = await cache_card_crud.get(
|
||||
filters={'cert_number': payload.cert_number, 'source': payload.source.value}
|
||||
)
|
||||
card = await card_crud.create(
|
||||
data={
|
||||
**cache_card.data,
|
||||
'category': payload.category,
|
||||
},
|
||||
filters={
|
||||
'cert_number': payload.cert_number,
|
||||
'source': payload.source.value
|
||||
}
|
||||
)
|
||||
|
||||
# 校验是否需要更新图片
|
||||
card_picture_empty = check_picture_for_empty(card.card_picture)
|
||||
if all([
|
||||
check_picture_for_empty(payload.card_picture.dict()) is False, # 创建时用户有提交上传图片链接
|
||||
check_picture_for_empty(card.card_spare_picture), # 采集原始来源无图片
|
||||
check_picture_for_empty(card.card_picture, or_=True), # 无本地化图片
|
||||
]):
|
||||
# 补充有效值
|
||||
card_picture = {} if card_picture_empty else card.card_picture.copy()
|
||||
for key, value in payload.card_picture.dict(exclude_none=True).items():
|
||||
if card_picture.get(key) is None:
|
||||
card_picture[key] = value
|
||||
|
||||
await card_crud.update(
|
||||
data={
|
||||
'card_picture': card_picture,
|
||||
'perfect_user_id': login_user.user_id,
|
||||
},
|
||||
filters={'id': card.id}
|
||||
)
|
||||
|
||||
else:
|
||||
card: Card = await card_crud.create(
|
||||
data={
|
||||
**payload.dict(exclude_none=True),
|
||||
'create_user_id': login_user.user_id,
|
||||
'source': payload.source.value,
|
||||
},
|
||||
filters={'cert_number': payload.cert_number, 'source': payload.source.value}
|
||||
)
|
||||
|
||||
collection_crud = CRUD(db_session, UserCollection)
|
||||
record = {
|
||||
'user_id': login_user.user_id,
|
||||
'card_id': card.id
|
||||
}
|
||||
result = await collection_crud.create(
|
||||
data=record,
|
||||
filters=record
|
||||
)
|
||||
# if result is False:
|
||||
# raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='卡片已存入')
|
||||
|
||||
return {'data': card}
|
||||
|
||||
|
||||
@card_router.post('/{card_id}/update/')
|
||||
@card_router.post(
|
||||
'/{card_id}/update/',
|
||||
summary='卡片更新(会员功能,入手价值、展示价值)',
|
||||
response_model=base_schema.BaseResponse
|
||||
)
|
||||
async def update_card_price_info(
|
||||
card_id: int,
|
||||
payload: schema.UpdateCard,
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
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}
|
||||
filters={'card_id': card_id, 'user_id': login_user.user_id}
|
||||
)
|
||||
return {'message': 'success' if result is not None else 'fail'}
|
||||
return {'message': 'ok' if result is not None else 'fail'}
|
||||
|
||||
|
||||
@card_router.post('/{card_id}/display/')
|
||||
@card_router.post('/{card_id}/display/', summary='卡片展示/隐藏', response_model=base_schema.BaseResponse)
|
||||
async def display_card_status(
|
||||
card_id: int,
|
||||
status: bool,
|
||||
action: Literal['displayed', 'undisplayed'],
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
db_crud = CRUD(db_session, UserCollection)
|
||||
result = await db_crud.update(
|
||||
data={
|
||||
'displayed': status
|
||||
'displayed': (action == 'displayed'),
|
||||
},
|
||||
filters={'card_id': card_id, 'user_id': None}
|
||||
filters={'card_id': card_id, 'user_id': login_user.user_id}
|
||||
)
|
||||
return {'message': 'success' if result is not None else 'fail'}
|
||||
return {'message': 'ok' if result is not None else 'fail'}
|
||||
|
||||
|
||||
@card_router.delete('/delete/', response_model=base_schema.BaseResponse)
|
||||
@card_router.delete('/delete/', summary='卡片删除(批量)', response_model=base_schema.BaseResponse)
|
||||
async def delete_card(
|
||||
payload: schema.DeleteCardIn,
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
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})
|
||||
# 删除关联关系
|
||||
db_crud = CRUD(db_session, UserCollection)
|
||||
state = await db_crud.delete(
|
||||
filters={
|
||||
'card_id__in': payload.card_ids,
|
||||
'user_id': login_user.user_id
|
||||
}
|
||||
)
|
||||
|
||||
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})
|
||||
if state is True:
|
||||
# 删除原始卡片(自定义)
|
||||
db_crud = CRUD(db_session, Card)
|
||||
await db_crud.delete(
|
||||
filters={
|
||||
'card_id_in': payload.card_ids,
|
||||
'user_id': login_user.user_id,
|
||||
'source': 'OTHER'
|
||||
}
|
||||
)
|
||||
|
||||
return {'message': 'success' if state else 'fail'}
|
||||
return {'message': 'ok' if state else 'fail'}
|
||||
|
||||
@@ -4,37 +4,59 @@
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from typing import Optional, Literal
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
|
||||
from pydantic import BaseModel, conint
|
||||
from pydantic import BaseModel, constr, condecimal, field_validator
|
||||
|
||||
from core.base.schema import SupportPlatformType, PlatformSourceType
|
||||
from core.base.schema import SupportPlatformType, PlatformSourceType, CardCategoryType
|
||||
|
||||
|
||||
class CardPrice(BaseModel):
|
||||
front_picture: Optional[str] = None
|
||||
reverse_picture: Optional[str] = None
|
||||
|
||||
# 自定义校验规则
|
||||
@field_validator('front_picture', 'reverse_picture')
|
||||
def check_length(cls, value):
|
||||
if value is not None:
|
||||
if not re.match(r'^http.*\.jpg$', value):
|
||||
raise ValueError('链接不合法')
|
||||
return value
|
||||
|
||||
|
||||
class Card(BaseModel):
|
||||
cert_number: str
|
||||
card_year: conint(gt=0, le=2099)
|
||||
card_year: Optional[constr(pattern=r'^\d{4}([-~]\d{1,4})?$')]
|
||||
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_picture: Optional[CardPrice] = {}
|
||||
card_spare_picture: Optional[dict] = {}
|
||||
source: SupportPlatformType
|
||||
|
||||
|
||||
class CardIn(Card):
|
||||
category: CardCategoryType
|
||||
reward: Optional[bool] = False
|
||||
|
||||
|
||||
class UpdateCard(BaseModel):
|
||||
purchase_price: float
|
||||
display_price: float
|
||||
purchase_price: condecimal(max_digits=10, decimal_places=2)
|
||||
display_price: condecimal(max_digits=10, decimal_places=2)
|
||||
|
||||
|
||||
class CardOut(Card, UpdateCard):
|
||||
card_year: int
|
||||
class CardVisitOut(Card, UpdateCard):
|
||||
source: PlatformSourceType
|
||||
purchase_price: Optional[float] = None
|
||||
display_price: Optional[float] = None
|
||||
|
||||
|
||||
class CardOut(CardVisitOut):
|
||||
id: Optional[int] = None
|
||||
purchase_price: Optional[condecimal(max_digits=10, decimal_places=2)] = None
|
||||
display_price: Optional[condecimal(max_digits=10, decimal_places=2)] = None
|
||||
|
||||
|
||||
class DeleteCardIn(BaseModel):
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
import logging
|
||||
import re
|
||||
|
||||
from curl_cffi import requests
|
||||
from fake_useragent import UserAgent
|
||||
@@ -181,13 +182,14 @@ class CardScraper:
|
||||
"""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:
|
||||
if html.xpath('//div[contains(@class, "alert-danger")]/p[contains(string(), "not found")]').get() is not None:
|
||||
return
|
||||
|
||||
info_elements = html.xpath('//main[@id="mainContent"]/div/table//tr')
|
||||
info_elements = html.xpath('//main[@id="mainContent"]/div/table[contains(@class, "text-medium")]//tr')
|
||||
info = dict()
|
||||
for info_element in info_elements:
|
||||
name = info_element.xpath('./th[1]').xpath('string()').get()
|
||||
name = re.sub(r'\(|\)', '', name)
|
||||
name = name.lower().replace(' ', '_').replace('/', '_')
|
||||
value = info_element.xpath('./td[1]').xpath('string()').get()
|
||||
info[name.strip()] = value.strip()
|
||||
@@ -234,7 +236,6 @@ class CardScraper:
|
||||
item['source'] = 'psa'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': info,
|
||||
}
|
||||
@@ -267,7 +268,6 @@ class CardScraper:
|
||||
item['source'] = 'bgs'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result,
|
||||
}
|
||||
@@ -312,7 +312,6 @@ class CardScraper:
|
||||
item['source'] = 'cgc'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': info,
|
||||
}
|
||||
@@ -352,7 +351,6 @@ class CardScraper:
|
||||
item['source'] = 'gbtc'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result.get('data'),
|
||||
}
|
||||
@@ -389,7 +387,6 @@ class CardScraper:
|
||||
item['source'] = 'ccg'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result.get('data'),
|
||||
}
|
||||
@@ -429,7 +426,6 @@ class CardScraper:
|
||||
item['source'] = 'bctc'
|
||||
return {
|
||||
'cert_number': item['cert_number'],
|
||||
'card_number': cert_number,
|
||||
'data': item,
|
||||
'raw_data': result.get('info'),
|
||||
}
|
||||
@@ -450,7 +446,7 @@ card_scraper = CardScraper()
|
||||
async def main():
|
||||
import time
|
||||
started = time.time()
|
||||
print(format_json(await card_scraper.choice('psa', '83610272')))
|
||||
print(format_json(await card_scraper.choice('psa', '1')))
|
||||
print(time.time() - started)
|
||||
# print(format_json(await card_scraper.choice('psa', '83610272')))
|
||||
# print(format_json(await card_scraper.psa('83610272')))
|
||||
|
||||
@@ -6,25 +6,30 @@
|
||||
# @Desc :
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urljoin
|
||||
|
||||
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
|
||||
from core.routers.auth.schema import LoginUser
|
||||
from core.routers.auth.services import get_current_user
|
||||
|
||||
from . import schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
file_router = APIRouter(prefix='/file', tags=['文件存储模块'])
|
||||
|
||||
|
||||
@file_router.post('/{upload_type}/upload', response_model=BaseResponse[schema.FileOut])
|
||||
@file_router.post('/{upload_type}/upload', summary='图片上传', response_model=BaseResponse[schema.FileOut])
|
||||
async def upload_file(
|
||||
upload_type: Literal['card', 'chat', 'avatar'],
|
||||
file: UploadFile = File(...)
|
||||
file: UploadFile = File(...),
|
||||
login_user: LoginUser = Depends(get_current_user)
|
||||
):
|
||||
# 验证文件类型
|
||||
if file.content_type not in settings.UPLOADED_ALLOWED_MIME_TYPES:
|
||||
@@ -44,15 +49,12 @@ async def upload_file(
|
||||
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')
|
||||
dirname_args = [upload_type.lower(), str(login_user.user_id), file_name]
|
||||
|
||||
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:
|
||||
save_file_path: Path = settings.UPLOADED_FILES_DIR.joinpath(*dirname_args)
|
||||
if save_file_path.exists() is False:
|
||||
# 创建目录
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
save_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
img = Image.open(BytesIO(file_chunks))
|
||||
img = img.convert("RGB")
|
||||
@@ -61,9 +63,9 @@ async def upload_file(
|
||||
file_info = {
|
||||
'filename': file_name,
|
||||
'content_type': file.content_type,
|
||||
'size': os.path.getsize(save_path),
|
||||
'size': file.size,
|
||||
'hash': file_hash,
|
||||
'url': save_file_path
|
||||
'url': urljoin(settings.ASSETS_BASE_URL, '/'.join([settings.UPLOADED_FILES_DIR.name, *dirname_args])),
|
||||
}
|
||||
return {'data': file_info}
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ class FileOut(BaseModel):
|
||||
content_type: str
|
||||
size: int
|
||||
hash: str
|
||||
url: str
|
||||
|
||||
@@ -4,6 +4,158 @@
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from fastapi import APIRouter
|
||||
from typing import List, Literal
|
||||
|
||||
from fastapi import APIRouter, Query, Depends, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.base import schema as base_schema
|
||||
from core.db.crud import CRUD
|
||||
from core.db.engine import get_async_session
|
||||
from core.db.models import User, UserFollows
|
||||
from core.routers.auth.schema import LoginUser
|
||||
from core.routers.auth.services import get_current_user
|
||||
|
||||
from . import schema
|
||||
|
||||
user_router = APIRouter(prefix='/user', tags=['用户模块'])
|
||||
|
||||
|
||||
@user_router.get('/me/info', summary='用户信息(我的)', response_model=base_schema.BaseResponse[schema.User])
|
||||
async def get_me_info(
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
db_crud = CRUD(db_session, User)
|
||||
user = await db_crud.get(filters={'user_id': login_user.user_id})
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='用户不存在')
|
||||
|
||||
return {'data': user}
|
||||
|
||||
|
||||
@user_router.get(
|
||||
'/visit/{user_id}/info',
|
||||
summary='用户信息(访问者)',
|
||||
response_model=base_schema.BaseResponse[schema.LiteUser]
|
||||
)
|
||||
async def get_visit_user_info(
|
||||
user_id: int,
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
if user_id == login_user.user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='无法操作')
|
||||
|
||||
db_crud = CRUD(db_session, User)
|
||||
user = await db_crud.get(filters={'user_id': user_id})
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='访问用户不存在')
|
||||
return {'data': user}
|
||||
|
||||
|
||||
@user_router.get(
|
||||
'/followers/all',
|
||||
summary='获取所有关注列表',
|
||||
response_model=base_schema.BaseResponse[
|
||||
base_schema.Paginated[
|
||||
List[schema.LiteUser]
|
||||
]
|
||||
]
|
||||
)
|
||||
async def get_all_followers(
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
page: int = Query(default=1, gt=0),
|
||||
page_size: int = Query(default=15, gt=0, le=200),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
# 组装查询条件
|
||||
conditions = [
|
||||
UserFollows.follower_id == login_user.user_id
|
||||
]
|
||||
|
||||
# 记录总数查询
|
||||
total_stmt = (
|
||||
select(func.count()).select_from(UserFollows).filter(*conditions)
|
||||
)
|
||||
total = await db_session.execute(total_stmt)
|
||||
|
||||
# 分页记录查询
|
||||
stmt = (
|
||||
select(User, UserFollows)
|
||||
.join(User, UserFollows.followed_id == User.user_id)
|
||||
.where(*conditions)
|
||||
.order_by(UserFollows.created_at.desc()).limit(page_size).offset((page - 1) * page_size)
|
||||
)
|
||||
records = await db_session.execute(stmt)
|
||||
|
||||
return {
|
||||
'data': {
|
||||
'list': records.scalars().all(),
|
||||
'total': total.scalar(),
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@user_router.post('/follow/{user_id}/', summary='新增/取消关注用户', response_model=base_schema.BaseResponse)
|
||||
async def follow_user(
|
||||
user_id: int,
|
||||
action: Literal['follower', 'unfollower'],
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
if user_id == login_user.user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='无法操作')
|
||||
|
||||
db_crud = CRUD(db_session, User)
|
||||
follower_user: User | None = await db_crud.get(filters={'user_id': user_id})
|
||||
if follower_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='被关注者不存在')
|
||||
|
||||
user: User | None = await db_crud.get(filters={'user_id': login_user.user_id})
|
||||
|
||||
db_crud = CRUD(db_session, UserFollows)
|
||||
record = {
|
||||
'follower_id': login_user.user_id,
|
||||
'followed_id': user.user_id,
|
||||
}
|
||||
if action == 'follower':
|
||||
await db_crud.create(data=record, filters=record)
|
||||
|
||||
if action == 'unfollower':
|
||||
await db_crud.delete(filters=record)
|
||||
|
||||
return {'message': 'ok'}
|
||||
|
||||
|
||||
@user_router.post(
|
||||
'/search/',
|
||||
summary='搜索用户',
|
||||
response_model=base_schema.BaseResponse[
|
||||
base_schema.Paginated[
|
||||
List[schema.LiteUser]
|
||||
]
|
||||
]
|
||||
)
|
||||
async def search_user(
|
||||
nickname: str = Query(...),
|
||||
page_size: int = Query(default=30, gt=0, le=30),
|
||||
login_user: LoginUser = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
db_crud = CRUD(db_session, User)
|
||||
users, total = await db_crud.get_paginated(
|
||||
page_size=page_size,
|
||||
filters={
|
||||
'nickname__like': nickname,
|
||||
'user_id__ne': login_user.user_id
|
||||
}
|
||||
)
|
||||
return {'data': {
|
||||
'list': users,
|
||||
'total': total,
|
||||
'page_size': page_size
|
||||
}}
|
||||
|
||||
40
core/routers/user/schema.py
Normal file
40
core/routers/user/schema.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- coding = utf-8 -*-
|
||||
# @Time : 2025/2/20 下午10:13
|
||||
# @File : schema.py
|
||||
# @Software : PyCharm
|
||||
# @Author : xingc
|
||||
# @Desc :
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.openapi.models import Schema
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
nickname: str
|
||||
user_id: int
|
||||
gender: Optional[int] = None
|
||||
avatar_url: Optional[str] = None
|
||||
wechat_openid: Optional[str] = None
|
||||
wechat_union_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
vip_expire_date: Optional[date] = None
|
||||
points: Optional[int] = 0
|
||||
displayed_card_total: Optional[int] = 0
|
||||
|
||||
|
||||
class LiteUser(BaseModel):
|
||||
nickname: str
|
||||
user_id: int
|
||||
gender: Optional[int] = None
|
||||
avatar_url: Optional[str] = None
|
||||
vip_expire_date: Optional[date] = None
|
||||
displayed_card_total: Optional[int] = 0
|
||||
|
||||
|
||||
class UserFollows(Schema):
|
||||
nickname: str
|
||||
user_id: int
|
||||
gender: Optional[int] = None
|
||||
avatar_url: Optional[int] = None
|
||||
Reference in New Issue
Block a user