From 1b2f9c7539b5bdf54879e23925aa3a6304ee635f Mon Sep 17 00:00:00 2001 From: xingc Date: Sat, 22 Feb 2025 11:38:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(routers):=20=E5=AE=8C=E5=96=84auth?= =?UTF-8?q?=E3=80=81card=E3=80=81file=E3=80=81user=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/routers/__init__.py | 4 +- core/routers/auth/routes.py | 157 ++++++++++++++++++++--------- core/routers/auth/schema.py | 10 ++ core/routers/auth/services.py | 60 +++++++++++ core/routers/card/routes.py | 184 ++++++++++++++++++++++++++-------- core/routers/card/schema.py | 44 ++++++-- core/routers/card/scraper.py | 14 +-- core/routers/file/routes.py | 28 +++--- core/routers/file/schema.py | 1 + core/routers/user/routes.py | 154 +++++++++++++++++++++++++++- core/routers/user/schema.py | 40 ++++++++ 11 files changed, 573 insertions(+), 123 deletions(-) create mode 100644 core/routers/user/schema.py diff --git a/core/routers/__init__.py b/core/routers/__init__.py index 6cf217f..dc52581 100644 --- a/core/routers/__init__.py +++ b/core/routers/__init__.py @@ -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] diff --git a/core/routers/auth/routes.py b/core/routers/auth/routes.py index d75aa84..42cc141 100644 --- a/core/routers/auth/routes.py +++ b/core/routers/auth/routes.py @@ -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 diff --git a/core/routers/auth/schema.py b/core/routers/auth/schema.py index d6ebc92..7e040cd 100644 --- a/core/routers/auth/schema.py +++ b/core/routers/auth/schema.py @@ -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 diff --git a/core/routers/auth/services.py b/core/routers/auth/services.py index efa7b98..ce19338 100644 --- a/core/routers/auth/services.py +++ b/core/routers/auth/services.py @@ -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 diff --git a/core/routers/card/routes.py b/core/routers/card/routes.py index d1460f2..4ebd939 100644 --- a/core/routers/card/routes.py +++ b/core/routers/card/routes.py @@ -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'} diff --git a/core/routers/card/schema.py b/core/routers/card/schema.py index 3c0c76d..f99460c 100644 --- a/core/routers/card/schema.py +++ b/core/routers/card/schema.py @@ -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): diff --git a/core/routers/card/scraper.py b/core/routers/card/scraper.py index 50c2be7..a80fb07 100644 --- a/core/routers/card/scraper.py +++ b/core/routers/card/scraper.py @@ -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'))) diff --git a/core/routers/file/routes.py b/core/routers/file/routes.py index 7ebd3a1..b00ad53 100644 --- a/core/routers/file/routes.py +++ b/core/routers/file/routes.py @@ -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} diff --git a/core/routers/file/schema.py b/core/routers/file/schema.py index 8e9e3e2..3f05af6 100644 --- a/core/routers/file/schema.py +++ b/core/routers/file/schema.py @@ -12,3 +12,4 @@ class FileOut(BaseModel): content_type: str size: int hash: str + url: str diff --git a/core/routers/user/routes.py b/core/routers/user/routes.py index b3461da..42af3b3 100644 --- a/core/routers/user/routes.py +++ b/core/routers/user/routes.py @@ -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 + }} diff --git a/core/routers/user/schema.py b/core/routers/user/schema.py new file mode 100644 index 0000000..98a14ab --- /dev/null +++ b/core/routers/user/schema.py @@ -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