feat: add user websocket channel (consumers+routing), drop USE_SQLITE branch

This commit is contained in:
chunyu
2026-09-04 12:47:19 +08:00
parent a1048de0fe
commit f53d6cd5b9
4 changed files with 96 additions and 25 deletions
+3 -2
View File
@@ -7,11 +7,12 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chunyu_project.settings')
django_asgi_app = get_asgi_application()
from chat.routing import websocket_urlpatterns
from chat.routing import websocket_urlpatterns as chat_ws
from user.routing import websocket_urlpatterns as user_ws
application = ProtocolTypeRouter({
'http': django_asgi_app,
'websocket': AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
URLRouter(chat_ws + user_ws)
),
})
+15 -23
View File
@@ -168,30 +168,22 @@ INSTALLED_APPS = [
]
# Database
if os.environ.get('USE_SQLITE') == '1':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'chunyu_project',
'USER': 'root',
'PASSWORD': 'mysql_NpyMCS',
'HOST': '192.168.5.7',
'PORT': '3306',
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
'CONN_MAX_AGE': 3600,
'CONN_HEALTH_CHECKS': True,
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'chunyu_project',
'USER': 'root',
'PASSWORD': 'mysql_NpyMCS',
'HOST': '192.168.5.7',
'PORT': '3306',
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
'CONN_MAX_AGE': 3600,
'CONN_HEALTH_CHECKS': True,
}
}
# Redis
REDIS_HOST = '192.168.5.7'
+72
View File
@@ -0,0 +1,72 @@
import json
import asyncio
from channels.generic.websocket import AsyncWebsocketConsumer
from django.core.cache import cache
class QRStatusConsumer(AsyncWebsocketConsumer):
"""
扫码登录 WebSocket Consumer.
前端连接 ws://host/ws/qr-status/{token}/,
后端在扫码/确认/取消/过期时实时推送状态。
"""
async def connect(self):
self.qr_token = self.scope['url_route']['kwargs']['token']
self.group_name = f"qr_{self.qr_token}"
# 加入该 token 对应的 group
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
# 检查 token 是否已过期/不存在
data = cache.get(f"qr_token:{self.qr_token}")
if not data:
await self.send(text_data=json.dumps({"status": "expired"}))
await self.close()
return
# 启动过期倒计时任务
self._expire_task = asyncio.create_task(self._expire_watcher(data.get("expires_at")))
async def disconnect(self, close_code):
if hasattr(self, 'group_name'):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
if hasattr(self, '_expire_task'):
self._expire_task.cancel()
async def receive(self, text_data):
"""客户端不需要发送消息,忽略即可"""
pass
# ── group 消息处理器:由 views 通过 group_send 触发 ──
async def qr_status_update(self, event):
"""接收扫码/确认/取消状态推送"""
message = {"status": event["status"]}
if event.get("username"):
message["username"] = event["username"]
if event.get("auth"):
message["auth"] = event["auth"]
await self.send(text_data=json.dumps(message))
# confirmed 或 cancelled 后关闭连接
if event["status"] in ("confirmed", "cancelled", "expired"):
await self.close()
# ── 内部方法 ──
async def _expire_watcher(self, expires_at):
"""Token 过期时主动推送 expired 并关闭"""
if not expires_at:
return
import time
wait = max(expires_at - time.time(), 0) + 1 # 多等 1 秒确保 Redis 已清除
try:
await asyncio.sleep(wait)
except asyncio.CancelledError:
return
# token 已过期
await self.send(text_data=json.dumps({"status": "expired"}))
await self.close()
+6
View File
@@ -0,0 +1,6 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/qr-status/(?P<token>[^/]+)/$', consumers.QRStatusConsumer.as_asgi()),
]