12 KiB
Architecture
Analysis Date: 2026-05-07
Pattern Overview
Overall: Multi-app Django with REST API + ASGI WebSocket support
Key Characteristics:
- Each Django app is a bounded context with models, views, serializers, and URLs
- HTTP API via Django REST Framework (DRF) ViewSets
- Real-time WebSocket via Django Channels for device interactions
- Custom token-based authentication with Redis backing
- Standardized response middleware for all API responses
- Message-driven device-to-device communication via WebSocket groups
Layers
API Layer (HTTP):
- Purpose: Expose business logic as REST endpoints
- Location: Each app's
views.pycontains ViewSets and APIViews - Contains: DRF ViewSets, APIViews, action decorators
- Depends on: Models, Serializers, Authentication (RedisTokenAuthentication)
- Used by: Mobile app (Unity), Web admin (Next.js), External integrations
WebSocket Layer (ASGI):
- Purpose: Real-time bidirectional communication for device control
- Location:
device_interaction/consumers.py(DeviceConsumer),device_interaction/routing.py - Contains: AsyncWebsocketConsumer, message handlers, channel layer group operations
- Depends on: Models, Channels, Redis channel layer, Token authentication
- Used by: Unity device terminal, Unity mobile app
Serializer Layer:
- Purpose: Validate and transform data between API and models
- Location: Each app's
serializers.py - Contains: DRF ModelSerializer subclasses, custom validation
- Depends on: Models, ValidationError exceptions
- Used by: ViewSets for request/response serialization
Model Layer:
- Purpose: Define data schema and business rules
- Location: Each app's
models.py - Contains: Django Models (ParadiseUser, Device, UserDevice, ChatMessage, etc.)
- Depends on: Django ORM
- Used by: Views, Serializers, Admin interface, WebSocket consumers
Middleware Layer:
- Purpose: Cross-cutting concerns
- Location:
common/middleware.py(StandardResponseMiddleware),device_interaction/auth.py(TokenAuthMiddleware) - Contains: StandardResponseMiddleware wraps all responses; TokenAuthMiddleware authenticates WebSocket
- Depends on: DRF Response, Channels scope
- Used by: All API responses, all WebSocket connections
Authentication Layer:
- Purpose: Validate and identify users
- Location:
userapp/authentication.py(RedisTokenAuthentication),device_interaction/auth.py(TokenAuthMiddleware) - Contains: Token lookup from Redis, user resolution
- Depends on: Redis cache, ParadiseUser model
- Used by: DRF (HTTP), WebSocket, Admin views
Pagination Layer:
- Purpose: Standardize list pagination
- Location:
common/pagination.py(CustomPageNumberPagination) - Contains: Page size configuration, dynamic page_size parameter
- Depends on: DRF pagination
- Used by: All list ViewSet actions
Audio Service Abstraction:
- Purpose: Provide vendor-agnostic audio synthesis/recognition
- Location:
aiapp/audio/AudioService.py - Contains: Multi-provider factory (Aliyun, Volcengine, Tencent)
- Depends on: External vendor SDKs
- Used by:
aiapp/views.pyfor voice chat, speech-to-text
Data Flow
WebSocket Device Info Flow (Example):
-
Device Connection →
device_interaction/consumers.py:DeviceConsumer.connect()- Device sends WebSocket connect with token in URL:
ws://domain/ws/device/token/{token}/ device_interaction/auth.py:TokenAuthMiddlewareintercepts, validates token viauserapp/authentication.py:RedisTokenAuthentication- Token key pattern:
token:{token_value}→ retrievesuser_idfrom Redis (seeuserapp/utils.py:generate_token) - User resolved, scope set; Consumer creates group
device_{user_id}and joins
- Device sends WebSocket connect with token in URL:
-
Device Reports Status → Device sends
type: device_infoJSON message- Example payload:
{ "type": "device_info", "message": { "battery_level": 85, "firmware_version": "1.2.3", "wifi_name": "MyWiFi" } }
- Example payload:
-
Consumer Processes →
device_interaction/consumers.py:DeviceConsumer.receive()- Line 196-198: Parses message type and content
- Line 211-212: Refreshes heartbeat:
cache.set(f"device:last_seen:{mac_address}", time.time(), timeout=300) - Device info not explicitly handled in receive() yet (TODO area)
-
Database Update → Would call
update_device_status()(line 107-131)- Updates
Devicemodel fields:battery_level,firmware_version,wifi_name, status='connected' - Sets Redis heartbeat key
device:last_seen:{mac}with 5-minute TTL
- Updates
-
Broadcast to Group → Via
channel_layer.group_send()(lines 221-227)- Routes message type specific handlers (e.g.,
async def weather()) - Broadcasts to all connections in group
device_{user_id} - Any other user/device connection to same user_id receives forwarded message
- Routes message type specific handlers (e.g.,
-
State in Redis:
- Token lookup:
token:{token}→user_id - Admin token lookup:
admin_token:{token}→user_id(alternative for admin routes) - Heartbeat tracking:
device:last_seen:{mac_address}→ unix timestamp (TTL 5 min)
- Token lookup:
State Management:
- HTTP Tokens: Stored in Redis with 30-day TTL (line 36 in
userapp/utils.py) - WebSocket Groups: In-memory Redis channel layer, groups scoped by
device_{user_id} - Heartbeats: Redis cache, auto-expire after 5 minutes (line 128, 151 in consumers.py)
- Device Status: PostgreSQL —
Device.statusset to 'connected' or 'disconnected' on connect/disconnect
Device Binding & Control Semantics
"Last-Bind-Wins":
UserDevice.Meta.ordering = ['-bound_at'](line 131 indevice_interaction/models.py)- When device MAC used to login, code fetches:
UserDevice.objects.filter(device=device).order_by('-bound_at').first()(line 120 inuserapp/views.py) - That user gets the token; older bindings exist in DB but are superseded
Control Flow Consequence:
- WebSocket group is
device_{user_id}where user_id is from latest binding - Same device, same time, only one user_id in the group
- Old bindings' connections to same MAC get ignored — they can't authenticate to the new owner's user_id group
UserDevicerecords not auto-deleted; explicit deletion required for "unbind" semantics
RTC Integration:
- Endpoint:
/api/device/rtc-token/get_by_mac/?mac_address=...(no auth required) - Resolves MAC → latest
UserDevice.user_id(via.first()with ordering) - Generates
room_id = f"room_{user_id}"(consistent with WebSocket group) - Returns Volcengine RTC token cached as
rtc_room:{user_id}:{task_id}
Key Abstractions
ParadiseUser:
- Purpose: Custom user model extending AbstractUser
- Location:
userapp/models.py(line 8) - Pattern: Single table inheritance; adds fields like
favorability,gender,mbti,interests - Relations: M2M to Group/Permission (custom related_names), 1-M to Device via UserDevice
Device & UserDevice:
- Purpose: Track physical devices and user-device bindings
- Location:
device_interaction/models.py(lines 43-136) - Pattern: Device is the hardware; UserDevice is the ownership/interaction record
- Key:
UserDeviceordering by-bound_atenforces "last-bind-wins"
DeviceConsumer:
- Purpose: Handle WebSocket connections for real-time device messaging
- Location:
device_interaction/consumers.py(line 10) - Pattern: AsyncWebsocketConsumer subclass; manages connect/receive/disconnect lifecycle
- Methods:
connect(): Auth, group add, acceptreceive(): Parse message type, route to group viagroup_send()disconnect(): Mark device offline, group discard
RedisTokenAuthentication:
- Purpose: Validate tokens stored in Redis
- Location:
userapp/authentication.py(line 10) - Pattern: DRF BaseAuthentication subclass; used by HTTP views and WebSocket middleware
- Flow: Extract token from Authorization header, call
get_user_id_from_token()(utils.py:39), resolve user, return (user, None)
StandardResponseMiddleware:
- Purpose: Wrap all API responses in standard format
- Location:
common/middleware.py(line 6) - Pattern: Django middleware; intercepts Response objects, adds
success,code,message,datafields - Behavior: Line 61 sets
success = 200 <= status_code < 300, extracts/transformsresponse.data
AudioService:
- Purpose: Abstract audio provider differences
- Location:
aiapp/audio/AudioService.py - Pattern: Factory pattern with provider configuration
- Providers: Aliyun NLS, Volcengine, Tencent (via
AUDIO_SERVICE_PROVIDERsetting)
Entry Points
HTTP Entry Points:
- Location:
qy_lty/urls.py(line 49-60) - URL pattern:
/api/<app>/<endpoint>/ - Examples:
/api/user/mac-login/→userapp.views.MacAddressLoginView.post()/api/device/types/→device_interaction.views.DeviceTypeViewSet.list()/api/ai/chat/→aiapp.views.ChatView(for multi-turn chat)
- Router: Standard DRF routing via
include('app.urls')
WebSocket Entry Points:
- Location:
qy_lty/asgi.py(lines 14-26),device_interaction/routing.py(lines 4-7) - URL pattern:
ws://domain/ws/device/(header auth) orws://domain/ws/device/token/{token}/(URL auth) - Consumer:
device_interaction.consumers.DeviceConsumer.as_asgi() - Middleware stack: TokenAuthMiddleware → URLRouter → DeviceConsumer
Admin Entry Points:
- Location:
/admin/(Django admin) - Config:
userapp/admin.py, each app'sadmin.py - Customization:
simpleuiintegration for enhanced UI
API Documentation:
- Swagger UI:
/swagger/(via drf-yasg) - ReDoc:
/redoc/(via drf-yasg) - Config: Generated from openapi.Info (line 32-39 in qy_lty/urls.py)
Error Handling
Strategy: Exception-aware middleware + standardized response format
Patterns:
- DRF Exceptions: Caught by
StandardResponseMiddleware.process_exception()(line 120), wrapped in standard format (line 133) - Validation Errors: Nested extraction of field errors (line 19-43), flattened into readable message
- HTTP Status Codes: Mapped to
successboolean (line 61:200 <= code < 300) - Custom Error Codes: HTTP response includes
codefield, e.g.,code=4010for "device not bound" (line 127 in userapp/views.py) - WebSocket Errors: Consumer catches exceptions, logs, and closes connection with specific code (e.g., 4001 for auth failure, line 24)
Cross-Cutting Concerns
Logging:
- Framework: Python
loggingmodule - Configured in:
qy_lty/settings.py(logging config) - Patterns: Each module instantiates logger:
logger = logging.getLogger(__name__)(line 8 in consumers.py) - Aliyun integration:
common/aliyun_logging.py:setup_logging()for production
Validation:
- HTTP: DRF serializer validation via
is_valid(), errors in response - WebSocket: Inline JSON parsing with try/except, error response via
send() - Custom: Model
clean()methods (if defined), field constraints
Authentication:
- HTTP:
RedisTokenAuthenticationon ViewSetauthentication_classes(line 79 in device_interaction/views.py) - WebSocket:
TokenAuthMiddlewarein ASGI app (line 21 in qy_lty/asgi.py) - Token generation: 30-day TTL, Redis-backed, pattern
token:{uuid}oradmin_token:{uuid}
Authorization:
- HTTP:
permission_classes = [IsAuthenticated]on ViewSets (line 80) - WebSocket: Implicit via token — anonymous users get 4001 close code (line 24)
- Admin routes:
/api/v1/admin/(separate URL namespace), likely checked via user.is_staff
Pagination:
- Default: 10 items per page (settings.py line 283)
- Override: Pass
page_sizequery parameter (e.g.,?page_size=50) - Response: Wrapped with
count,next,previous,results(lines 93-95 in common/middleware.py)
Architecture analysis: 2026-05-07