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

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.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 Connectiondevice_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:
      {
        "type": "device_info",
        "message": {
          "battery_level": 85,
          "firmware_version": "1.2.3",
          "wifi_name": "MyWiFi"
        }
      }
      
  3. Consumer Processesdevice_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