252 lines
12 KiB
Markdown
252 lines
12 KiB
Markdown
# 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/<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) 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*
|