# 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.py` contains 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.py` for voice chat, speech-to-text ## Data Flow **WebSocket Device Info Flow (Example):** 1. **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:TokenAuthMiddleware` intercepts, validates token via `userapp/authentication.py:RedisTokenAuthentication` - Token key pattern: `token:{token_value}` → retrieves `user_id` from Redis (see `userapp/utils.py:generate_token`) - User resolved, scope set; Consumer creates group `device_{user_id}` and joins 2. **Device Reports Status** → Device sends `type: device_info` JSON message - Example payload: ```json { "type": "device_info", "message": { "battery_level": 85, "firmware_version": "1.2.3", "wifi_name": "MyWiFi" } } ``` 3. **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) 4. **Database Update** → Would call `update_device_status()` (line 107-131) - Updates `Device` model fields: `battery_level`, `firmware_version`, `wifi_name`, status='connected' - Sets Redis heartbeat key `device:last_seen:{mac}` with 5-minute TTL 5. **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 6. **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) **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.status` set to 'connected' or 'disconnected' on connect/disconnect ## Device Binding & Control Semantics **"Last-Bind-Wins":** - `UserDevice.Meta.ordering = ['-bound_at']` (line 131 in `device_interaction/models.py`) - When device MAC used to login, code fetches: `UserDevice.objects.filter(device=device).order_by('-bound_at').first()` (line 120 in `userapp/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 - `UserDevice` records 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: `UserDevice` ordering by `-bound_at` enforces "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, accept - `receive()`: Parse message type, route to group via `group_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`, `data` fields - Behavior: Line 61 sets `success = 200 <= status_code < 300`, extracts/transforms `response.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_PROVIDER` setting) ## Entry Points **HTTP Entry Points:** - Location: `qy_lty/urls.py` (line 49-60) - URL pattern: `/api///` - 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) or `ws://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's `admin.py` - Customization: `simpleui` integration 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 `success` boolean (line 61: `200 <= code < 300`) - **Custom Error Codes**: HTTP response includes `code` field, e.g., `code=4010` for "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 `logging` module - 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: `RedisTokenAuthentication` on ViewSet `authentication_classes` (line 79 in device_interaction/views.py) - WebSocket: `TokenAuthMiddleware` in ASGI app (line 21 in qy_lty/asgi.py) - Token generation: 30-day TTL, Redis-backed, pattern `token:{uuid}` or `admin_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_size` query 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*