feat(routers): 完善auth、card、file、user相关接口开发

This commit is contained in:
xingc
2025-02-22 11:38:56 +08:00
parent 02ddc81c51
commit 1b2f9c7539
11 changed files with 573 additions and 123 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

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

View File

@@ -12,3 +12,4 @@ class FileOut(BaseModel):
content_type: str
size: int
hash: str
url: str

View File

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

View 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