diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 25d3c0f7..26ae8403 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -5,6 +5,8 @@ on: branches: - main # Adjust the branch name as needed pull_request: + paths: + - 'back_end/saolei/**' jobs: check_flag: diff --git a/back_end/saolei/identifier/views.py b/back_end/saolei/identifier/views.py index 5aeacf01..1fe52b4e 100644 --- a/back_end/saolei/identifier/views.py +++ b/back_end/saolei/identifier/views.py @@ -7,9 +7,11 @@ from utils.response import HttpResponseConflict from videomanager.models import VideoModel from videomanager.view_utils import update_state, update_personal_record_stock +from userprofile.decorators import login_required_error # 请求修改自己的标识 @require_POST +@login_required_error def add_identifier(request): user = UserProfile.objects.filter(id=request.user.id).first() if user == None: @@ -41,6 +43,7 @@ def add_identifier(request): # 请求删除自己的标识 @require_POST +@login_required_error def del_identifier(request): user = UserProfile.objects.filter(id=request.user.id).first() if user == None: diff --git a/back_end/saolei/msuser/views.py b/back_end/saolei/msuser/views.py index 02c16799..baa21169 100644 --- a/back_end/saolei/msuser/views.py +++ b/back_end/saolei/msuser/views.py @@ -1,15 +1,13 @@ import logging logger = logging.getLogger('userprofile') -from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required from .forms import UserUpdateRealnameForm, UserUpdateAvatarForm, UserUpdateSignatureForm # from .models import VideoModel, ExpandVideoModel -from django.http import HttpResponse, JsonResponse, HttpResponseNotAllowed, HttpResponseBadRequest, HttpResponseNotFound +from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotFound # from asgiref.sync import sync_to_async import json from utils import ComplexEncoder # from django.core.paginator import Paginator -from msuser.models import UserMS from userprofile.models import UserProfile import base64 import decimal @@ -18,11 +16,9 @@ cache = get_redis_connection("saolei_website") from django.conf import settings import os -from django.utils import timezone -from datetime import datetime, timedelta -from utils import verify_text from django_ratelimit.decorators import ratelimit from django.views.decorators.http import require_GET, require_POST +from userprofile.utils import user_metadata from config.global_settings import * @@ -38,7 +34,7 @@ def default(self, o): # 获取我的地盘里的头像、姓名、个性签名、过审标识 -@ratelimit(key='ip', rate='60/h') +@ratelimit(key='ip', rate='20/m') @require_GET def get_info(request): user_id = request.GET.get('id') @@ -50,28 +46,12 @@ def get_info(request): user.popularity += 1 user.save(update_fields=["popularity"]) - - if user.avatar: - avatar_path = os.path.join(settings.MEDIA_ROOT, urllib.parse.unquote(user.avatar.url)[7:]) - image_data = open(avatar_path, "rb").read() - image_data = base64.b64encode(image_data).decode() - else: - image_data = None - response = {"id": user_id, - "username": user.username, - "realname": user.realname, - "avatar": image_data, - "signature": user.signature, - "popularity": user.popularity, - "identifiers": user.userms.identifiers, - "is_banned": user.is_banned, - "country": user.country - } - return JsonResponse(response) + + return JsonResponse(user_metadata(user)) # 获取我的地盘里的姓名、全部纪录 -@ratelimit(key='ip', rate='60/h') +@ratelimit(key='ip', rate='15/m') @require_GET def get_records(request): user_id = request.GET.get('id') @@ -93,6 +73,7 @@ def get_records(request): # 鼠标移到人名上时,展现头像、姓名、id、记录 +@ratelimit(key='ip', rate='5/s') @require_GET def get_info_abstract(request): # 此处要防攻击 diff --git a/back_end/saolei/requirements.txt b/back_end/saolei/requirements.txt index a4ac2f08..d8c0b921 100644 --- a/back_end/saolei/requirements.txt +++ b/back_end/saolei/requirements.txt @@ -10,4 +10,5 @@ django-ratelimit==4.1.0 requests==2.28.1 ms_toollib==1.4.8 psutil==5.9.1 -lxml==5.1.0 \ No newline at end of file +lxml==5.1.0 +websocket-client==1.8.0 \ No newline at end of file diff --git a/back_end/saolei/userprofile/utils.py b/back_end/saolei/userprofile/utils.py index 79d2d7d7..8932a054 100644 --- a/back_end/saolei/userprofile/utils.py +++ b/back_end/saolei/userprofile/utils.py @@ -1,6 +1,12 @@ from captcha.models import CaptchaStore from django.utils import timezone from .models import EmailVerifyRecord +from .models import UserProfile +from videomanager.models import VideoModel +import os +from django.conf import settings +import urllib.parse +import base64 # 验证验证码 def judge_captcha(captchaStr, captchaHashkey): @@ -25,3 +31,25 @@ def judge_email_verification(email, email_captcha, emailHashkey): EmailVerifyRecord.objects.filter(hashkey=emailHashkey).delete() return False return get_email_captcha.code == email_captcha and get_email_captcha.email == email + +def user_metadata(user: UserProfile): + if user.avatar: + avatar_path = os.path.join(settings.MEDIA_ROOT, urllib.parse.unquote(user.avatar.url)[7:]) + image_data = open(avatar_path, "rb").read() + image_data = base64.b64encode(image_data).decode() + else: + image_data = None + + videos = VideoModel.objects.filter(player=user).values('id', 'upload_time', "level", "mode", "timems", "bv", "state", "software") + return {"id": user.id, + "username": user.username, + "realname": user.realname, + "avatar": image_data, + "signature": user.signature, + "popularity": user.popularity, + "identifiers": user.userms.identifiers, + "is_banned": user.is_banned, + "is_staff": user.is_staff, + "country": user.country, + "videos": list(videos), + } \ No newline at end of file diff --git a/back_end/saolei/userprofile/views.py b/back_end/saolei/userprofile/views.py index b8c92ff4..07c4231f 100644 --- a/back_end/saolei/userprofile/views.py +++ b/back_end/saolei/userprofile/views.py @@ -14,7 +14,7 @@ from .decorators import staff_required from django.utils import timezone from config.flags import EMAIL_SKIP -from .utils import judge_captcha, judge_email_verification +from .utils import judge_captcha, judge_email_verification, user_metadata # Create your views here. @@ -42,11 +42,10 @@ def user_login(request): return JsonResponse({'type': 'error', 'object': 'login', 'category': 'password'}) # 将用户数据保存在 session 中,即实现了登录动作 login(request, user) - userdata = {"id": user.id, "username": user.username, "realname": user.realname, "is_banned": user.is_banned, "is_staff": user.is_staff} if 'user_id' in data and data['user_id'] != str(user.id): # 检测到小号 logger.warning(f'{data["user_id"][:50]} is different from {str(user.id)}.') - return JsonResponse({'type': 'success', 'user': userdata}) + return JsonResponse({'type': 'success', 'user': user_metadata(user)}) @require_GET @@ -83,8 +82,7 @@ def user_retrieve(request): login(request, user) logger.info(f'用户 {user.username}#{user.id} 邮箱找回密码') EmailVerifyRecord.objects.filter(hashkey=emailHashkey).delete() - userdata = {"id": user.id, "username": user.username, "realname": user.realname, "is_banned": user.is_banned, "is_staff": user.is_staff} - return JsonResponse({'type': 'success', 'user': userdata}) + return JsonResponse({'type': 'success', 'user': user_metadata(user)}) # 用户注册 @@ -113,10 +111,7 @@ def user_register(request): logger.info(f'用户 {new_user.username}#{new_user.id} 注册') # 顺手把过期的验证码删了 EmailVerifyRecord.objects.filter(hashkey=emailHashkey).delete() - return JsonResponse({'type': 'success', 'user': { - "id": new_user.id, "username": new_user.username, - "realname": new_user.realname, "is_banned": new_user.is_banned, "is_staff": new_user.is_staff} - }) + return JsonResponse({'type': 'success', 'user': user_metadata(new_user)}) else: return JsonResponse({'type': 'error', 'object': 'emailcode'}) else: @@ -133,11 +128,10 @@ def user_register(request): @require_GET def check_collision(request): user = None - if request.GET.get('username'): - print(request.GET.get('username')) - user = UserProfile.objects.filter(username=request.GET.get('username')).first() - elif request.GET.get('email'): - user = UserProfile.objects.filter(email=request.GET.get('email')).first() + if username := request.GET.get('username'): + user = UserProfile.objects.filter(username=username).first() + elif email := request.GET.get('email'): + user = UserProfile.objects.filter(email=email).first() else: return HttpResponseBadRequest() if not user: @@ -201,7 +195,7 @@ def refresh_captcha(request): return HttpResponse(json.dumps(c), content_type='application/json') # 验证验证码,若通过,发送email -@ratelimit(key='ip', rate='20/h') +@ratelimit(key='ip', rate='1/m') @require_POST def get_email_captcha(request): data = request.POST diff --git a/back_end/saolei/utils/test.py b/back_end/saolei/utils/test.py new file mode 100644 index 00000000..54a38076 --- /dev/null +++ b/back_end/saolei/utils/test.py @@ -0,0 +1,55 @@ +import unittest +from unittest.mock import MagicMock +import time +from wom import WOM +import json + + +class TestWOM(unittest.TestCase): + + def test_insert_video_id(self): + self.count = 0 + # 创建 WOM 实例并传入 mock_callback + wom = WOM(videoInfoFunc=self.callbackFunc, disConnectTime=10, raiseReConnectTime=60,errorFunc=self.errorFunc) + wom.start() # 启动 WOM + + wom.insertVideoId('3071736950') + wom.insertVideoId('3071736950') + time.sleep(5) + # 断言当前是连接状态 + self.assertEqual(wom.isConnected(), True, "Connect") + + # 等待 20 秒 + time.sleep(20) + # 断言当前是断开状态 + self.assertEqual(wom.isConnected(), False, "Disconnect") + wom.insertVideoId('3071736950') + + # 再次等待 5 ,等待异步重连 + time.sleep(5) + # 断言当前是连接状态 + self.assertEqual(wom.isConnected(), True, "Connect") + + time.sleep(20) + # 确保总共调用了6次 callback + self.assertEqual(self.count, 6, "Callback") + wom.stop() + time.sleep(5) + # 断言当前是断开状态 + self.assertEqual(wom.isConnected(), False, "Disconnect") + + def callbackFunc(self, message): + data = json.loads(message) + if data[0][1] == 203: + self.assertEqual(data[1][2][0]['id'], + 3071736950, "Insert video Info 203") + if data[0][1] == 214: + self.assertEqual(data[1][2][0], 3071736950, + "Insert video Info 214") + self.count += 1 + + def errorFunc(self, e:Exception): + print(f"Error: {e}") + +if __name__ == '__main__': + unittest.main() diff --git a/back_end/saolei/utils/wom.py b/back_end/saolei/utils/wom.py new file mode 100644 index 00000000..d454fedb --- /dev/null +++ b/back_end/saolei/utils/wom.py @@ -0,0 +1,184 @@ +from typing import Callable +import websocket +import threading +import queue +import requests +import re +import time + + +class WOM: + def __init__(self, videoInfoFunc: Callable[[str], None], disConnectTime: int = 10, raiseReConnectTime: int = 60, errorFunc: Callable[[Exception], None] = None): + """ + 初始化 WOM 类。 + + args: + - videoInfoFunc (Callable): 当接收到新的视频信息时调用的回调函数. + - disConnectTime (int): 最后一次回调函数后的等待时间.如果超过这个时间,将断开连接. + - raiseReConnectTime (int): 连接出现错误后重试的间隔. + - errorFunc (Callable): 错误回调函数. + Returns: + - None + """ + if videoInfoFunc is None: + if not isinstance(videoInfoFunc,Callable): + raise ValueError("videoInfoFunc is not None and type is callable") + self.__videoIdQueue = queue.Queue() + self.__videoInfoQueue = queue.Queue() + self.__callback = videoInfoFunc + self.__ws = None + self.__isOver = True + self.__lastTime = time.time() + self.__disConnectTime = disConnectTime + self.__lock = threading.Lock() + self.__raiseReConnectTime = raiseReConnectTime + self.__isConnected = False + self.__isConnectedLock = threading.Lock() + self.__wsThread = threading.Thread(target=self.__wsThreadFunc) + self.__callbackThread = threading.Thread( + target=self.__callBackThreadFunc) + self.__stopFlag = threading.Event() + self.__errorCallback = errorFunc + + def registerErrorCallback(self, callback: Callable[[Exception], None]): + """ + 注册错误回调函数,不是必须的,但是推荐使用 + """ + self.__errorCallback = callback + + def __wsThreadFunc(self) -> None: + while not self.__stopFlag.is_set(): + if self.__ws is None and self.__videoIdQueue.empty(): + time.sleep(0.1) + continue + if self.__ws and time.time() - self.__lastTime > self.__disConnectTime and self.__videoIdQueue.empty() and self.__isOver: + self.__ws.close() + self.__ws = None + with self.__isConnectedLock: + self.__isConnected = False + time.sleep(0.1) + continue + try: + if self.__ws: + code, message = self.parseMessage(self.__ws.recv()) + if code == '2': + self.__ws.send('3') + elif code == '42' and message == '["response",[1000,1,[]]]': + self.__isOver = True + elif code == '42': + self.__videoInfoQueue.put(message) + if self.__isOver: + if not self.__videoIdQueue.empty(): + try: + videoId: int|str = self.__videoIdQueue.get() + message = self.formatMessage( + '42', f'["request",["EnterGameController.enterGameWsAction",[{videoId}],1000,683]]') + self.__ws.send(message) + self.__isOver = False + with self.__lock: + self.__lastTime = time.time() + except Exception as e: + self.__ws = None + self.insertVideoId(videoId) + if self.__errorCallback: + self.__errorCallback(e) + time.sleep(self.__raiseReconnectTime) + else: + response = requests.get( + 'https://minesweeper.online/authorize?session=') + data = response.json() + wsUrl = f"wss://main{int(data['userId'])% 10 + 1}.minesweeper.online/mine-websocket/?authKey={data['authKey']}&session={data['session']}&userId={data['userId']}&EIO=4&transport=websocket" + self.__ws = websocket.create_connection(wsUrl) + isAuthenticated = False + while not isAuthenticated: + code, message = self.parseMessage(self.__ws.recv()) + if code == '2': + self.__ws.send('3') + elif code == '0': + self.__ws.send('40') + elif code == '40': + pass + elif code == '42' and message == '["authorized",[]]': + isAuthenticated = True + with self.__isConnectedLock: + self.__isConnected = True + except Exception as e: + self.__ws = None + if self.__errorCallback: + self.__errorCallback(e) + time.sleep(self.__raiseReConnectTime) + + def __callBackThreadFunc(self): + while not self.__stopFlag.is_set() or not self.__videoInfoQueue.empty(): + if not self.__videoInfoQueue.empty(): + with self.__lock: + self.__lastTime = time.time() + self.__callback(self.__videoInfoQueue.get()) + else: + time.sleep(1) + + @staticmethod + def formatMessage(code, message): + return f'{code}{message}' + + @staticmethod + def parseMessage(message) -> tuple[str, str]: + if message == '': + return '', '' + reStr = r'^(\d+)(.*)$' + # 取出第一部分和第二部分 + result = re.findall(reStr, message)[0] + if result: + return result[0], result[1] + else: + return '', '' + + def start(self): + """启动""" + self.__stopFlag.clear() + self.__wsThread.start() + self.__callbackThread.start() + + def insertVideoId(self, videoId: str | int): + """插入一个请求的视频ID""" + if videoId is None: + raise ValueError("videoId is None") + self.__videoIdQueue.put(videoId) + + def insertVideoIds(self, videoIds: list[str | int]): + """插入多个请求的视频ID""" + if videoIds is None: + raise ValueError("videoIds is None") + elif not isinstance(videoIds, list): + raise ValueError("videoIds is not a list") + for videoId in videoIds: + self.__videoIdQueue.put(videoId) + + def stop(self): + """停止,但是会等待已经接收到的数据全部回调完成""" + self.__isOver = True + self.__stopFlag.set() + self.__wsThread.join() + self.__callbackThread.join() + with self.__isConnectedLock: + self.__isConnected = False + + def isConnected(self): + """检查是否已连接,请求视频信息时,无需判断是否已连接""" + with self.__isConnectedLock: + return self.__isConnected + + +if __name__ == '__main__': + def callback(message): + print(message) + wom = WOM(callback) + wom.start() + wom.insertVideoId('1') + wom.insertVideoId('1') + import time + time.sleep(20) + wom.insertVideoId('1') + + print('over') + wom.stop() diff --git a/back_end/saolei/videomanager/views.py b/back_end/saolei/videomanager/views.py index 9be73e25..ce17f0c5 100644 --- a/back_end/saolei/videomanager/views.py +++ b/back_end/saolei/videomanager/views.py @@ -152,7 +152,7 @@ def video_query_by_id(request): user = UserProfile.objects.filter(id=id).first() if not user: return HttpResponseNotFound() - videos = VideoModel.objects.filter(player=user).values('id', 'upload_time', "level", "mode", "timems", "bv", "bvs", "state", "video__identifier", "software") + videos = VideoModel.objects.filter(player=user).values('id', 'upload_time', "level", "mode", "timems", "bv", "bvs", "state", "video__identifier", "software", "video__flag", "video__cell0", "video__cell1", "video__cell2", "video__cell3", "video__cell4", "video__cell5", "video__cell6", "video__cell7", "video__cell8", "video__left", "video__right", "video__double", "video__op", "video__isl", "video__path") # print(list(videos)) return JsonResponse(list(videos), safe=False) diff --git a/front_end/src/App.vue b/front_end/src/App.vue index d2b2dc57..bcdfc32d 100644 --- a/front_end/src/App.vue +++ b/front_end/src/App.vue @@ -16,7 +16,7 @@ - + diff --git a/front_end/src/components/AccountLinkManager.vue b/front_end/src/components/AccountLinkManager.vue index 4b4619b3..4504167b 100644 --- a/front_end/src/components/AccountLinkManager.vue +++ b/front_end/src/components/AccountLinkManager.vue @@ -22,7 +22,7 @@ scope.row.identifier }} - +