lty/qy_lty/.planning/codebase/ARCHITECTURE.md
2026-05-07 10:37:16 +08:00

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*