Erweiterungen
This commit is contained in:
421
STOMP_README.md
421
STOMP_README.md
@@ -1,302 +1,177 @@
|
||||
# STOMP Messaging Integration
|
||||
# VOTIANLT Realtime & Photo Upload API
|
||||
|
||||
Die Anwendung unterstützt jetzt STOMP (Simple Text Oriented Messaging Protocol) für die Kommunikation mit externen Apps über WebSocket-Verbindungen.
|
||||
This document describes how mobile/Flutter apps can interact with the backend via STOMP (WebSocket) and how to upload photos via HTTP POST (new flow).
|
||||
|
||||
## Übersicht
|
||||
## 1) STOMP Overview
|
||||
|
||||
Das System bietet folgende STOMP-Funktionalitäten:
|
||||
- Connect to one of the WebSocket endpoints:
|
||||
- `/ws` (SockJS fallback supported)
|
||||
- `/websocket` (native WebSocket)
|
||||
- `/stomp` (native WebSocket, alternate path)
|
||||
- Application destination prefix: `/app`
|
||||
- Broker destinations:
|
||||
- Broadcast: `/topic/...`
|
||||
- User specific: `/user/queue/...`
|
||||
|
||||
### WebSocket-Endpunkte
|
||||
Examples:
|
||||
- Send a generic message: send to `/app/message`, receive on `/topic/messages`
|
||||
- Job updates: send to `/app/job/status`, receive on `/topic/job-updates`
|
||||
- Device locations: send to `/app/device/location`, receive on `/topic/device-locations`
|
||||
- Task updates (broadcast): `/topic/task-updates`
|
||||
- Task specific topic: `/topic/tasks/{taskId}`
|
||||
|
||||
- **`/ws`** - STOMP-Endpunkt mit SockJS-Fallback-Unterstützung
|
||||
- **`/websocket`** - Reiner WebSocket-Endpunkt ohne SockJS
|
||||
## 2) Photo Task – New HTTP Upload Flow
|
||||
|
||||
### Nachrichtendestinationen
|
||||
In the future, photos for a PHOTO task must NOT be embedded in the STOMP message anymore. Instead, upload the photos via HTTP POST and only send the task-completion event via STOMP without large payloads.
|
||||
|
||||
#### Eingehende Nachrichten (Client → Server)
|
||||
- **`/app/message`** - Allgemeine Nachrichten
|
||||
- **`/app/job/status`** - Job-Status-Updates
|
||||
- **`/app/device/location`** - Gerätestandort-Updates
|
||||
- **`/app/auth/login`** - Anmeldung eines App-Users (Payload: { email, password })
|
||||
### Endpoint
|
||||
|
||||
#### Ausgehende Nachrichten (Server → Client)
|
||||
- **`/topic/messages`** - Broadcast aller allgemeinen Nachrichten
|
||||
- **`/topic/job-updates`** - Job-Status-Updates für alle Abonnenten
|
||||
- **`/topic/device-locations`** - Gerätestandort-Updates
|
||||
- **`/topic/broadcasts`** - System-weite Broadcast-Nachrichten
|
||||
- **`/queue/notifications`** - Benutzerspezifische Benachrichtigungen
|
||||
- URL: `POST /api/tasks/{taskId}/photos`
|
||||
- Purpose: Upload one or many photos for a task of type `PHOTO`.
|
||||
- Path params:
|
||||
- `taskId` (required): The MongoDB ObjectId of the task.
|
||||
- Auth: (same as your app uses today; if you have a session/cookie or token, include it accordingly)
|
||||
- CORS: Enabled for `*` by default on this controller.
|
||||
|
||||
## Verwendung für Apps
|
||||
### Content Types (choose one)
|
||||
|
||||
### 1. Verbindung aufbauen
|
||||
1) Multipart form data (recommended for binary images)
|
||||
|
||||
```javascript
|
||||
// Mit SockJS
|
||||
const socket = new SockJS('http://192.168.180.196:8080/ws');
|
||||
const stompClient = Stomp.over(socket);
|
||||
- `Content-Type: multipart/form-data`
|
||||
- Fields:
|
||||
- `files`: one or multiple image files (repeat the field for multiple images)
|
||||
- `completedBy` (optional): username/id of the completing user
|
||||
- `note` (optional): any note (currently stored on the task when you mark it as completed via STOMP)
|
||||
|
||||
// Oder mit nativem WebSocket (WICHTIG: ws:// verwenden, nicht http://)
|
||||
const socket = new WebSocket('ws://192.168.180.196:8080/websocket');
|
||||
const stompClient = Stomp.over(socket);
|
||||
Example (curl):
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/tasks/66f5c2f1a3b6e27a1a234567/photos" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "files=@/path/to/photo1.jpg" \
|
||||
-F "files=@/path/to/photo2.jpg" \
|
||||
-F "completedBy=driver01" \
|
||||
-F "note=Anlieferung dokumentiert"
|
||||
```
|
||||
|
||||
### Flutter/Dart STOMP Client
|
||||
2) JSON with base64 strings (kept for compatibility)
|
||||
|
||||
**Für Flutter-Apps verwenden Sie diese Konfiguration:**
|
||||
|
||||
```dart
|
||||
import 'package:stomp_dart_client/stomp.dart';
|
||||
import 'package:stomp_dart_client/stomp_config.dart';
|
||||
import 'package:stomp_dart_client/stomp_frame.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
// WICHTIG: Verwenden Sie ws:// für WebSocket-Verbindungen, NICHT http://
|
||||
final stompClient = StompClient(
|
||||
config: StompConfig(
|
||||
url: 'ws://192.168.180.196:8080/websocket', // Beachten Sie ws:// statt http://
|
||||
onConnect: onConnectCallback,
|
||||
onWebSocketError: (dynamic error) => print('WebSocket Error: $error'),
|
||||
onStompError: (StompFrame frame) => print('Stomp Error: ${frame.body}'),
|
||||
onDisconnect: (StompFrame frame) => print('Disconnected'),
|
||||
// Heartbeat-Konfiguration
|
||||
heartbeatIncoming: Duration(seconds: 20),
|
||||
heartbeatOutgoing: Duration(seconds: 20),
|
||||
),
|
||||
);
|
||||
|
||||
void onConnectCallback(StompFrame frame) {
|
||||
print('Connected to STOMP server');
|
||||
|
||||
// Nachrichten abonnieren
|
||||
stompClient.subscribe(
|
||||
destination: '/topic/messages',
|
||||
callback: (StompFrame frame) {
|
||||
print('Received message: ${frame.body}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Verbindung herstellen
|
||||
stompClient.activate();
|
||||
|
||||
// Nachricht senden
|
||||
void sendMessage(String content) {
|
||||
stompClient.send(
|
||||
destination: '/app/message',
|
||||
body: jsonEncode({
|
||||
'content': content,
|
||||
'sender': 'FlutterApp',
|
||||
}),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative Endpunkte für Flutter:**
|
||||
Falls Probleme mit `/websocket` auftreten, versuchen Sie:
|
||||
- `ws://192.168.180.196:8080/stomp` (zusätzlicher Endpunkt)
|
||||
- `ws://192.168.180.196:8080/ws` (SockJS-Endpunkt ohne SockJS-Protokoll)
|
||||
|
||||
### 2. Verbindung herstellen
|
||||
|
||||
```javascript
|
||||
stompClient.connect({}, function(frame) {
|
||||
console.log('Verbunden: ' + frame);
|
||||
|
||||
// Nachrichten abonnieren
|
||||
stompClient.subscribe('/topic/messages', function(message) {
|
||||
console.log('Nachricht erhalten:', JSON.parse(message.body));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Nachrichten senden
|
||||
|
||||
```javascript
|
||||
// Allgemeine Nachricht senden
|
||||
stompClient.send('/app/message', {}, JSON.stringify({
|
||||
content: 'Hallo vom App',
|
||||
sender: 'MobileApp'
|
||||
}));
|
||||
|
||||
// Job-Status-Update senden
|
||||
stompClient.send('/app/job/status', {}, JSON.stringify({
|
||||
jobId: '12345',
|
||||
status: 'IN_PROGRESS',
|
||||
progress: 75
|
||||
}));
|
||||
|
||||
// Gerätestandort senden
|
||||
stompClient.send('/app/device/location', {}, JSON.stringify({
|
||||
deviceId: 'device-001',
|
||||
latitude: 52.5200,
|
||||
longitude: 13.4050,
|
||||
accuracy: 10
|
||||
}));
|
||||
|
||||
// Anmeldung eines App-Users
|
||||
// Zuerst die Antwort-Warteschlange abonnieren (user-spezifisch)
|
||||
const authSubscription = stompClient.subscribe('/user/queue/auth', function(message) {
|
||||
const resp = JSON.parse(message.body);
|
||||
console.log('Login-Antwort:', resp);
|
||||
});
|
||||
|
||||
// Login-Request senden
|
||||
stompClient.send('/app/auth/login', {}, JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
password: 'geheimesPasswort'
|
||||
}));
|
||||
```
|
||||
|
||||
## Backend-Integration
|
||||
|
||||
### Programmatische Nachrichten senden
|
||||
|
||||
```java
|
||||
@Autowired
|
||||
private MessageController messageController;
|
||||
|
||||
// Benachrichtigung an spezifischen Benutzer
|
||||
messageController.sendNotificationToUser("username", "Neue Aufgabe verfügbar");
|
||||
|
||||
// Broadcast-Nachricht an alle
|
||||
messageController.sendBroadcastMessage("Systemwartung in 10 Minuten");
|
||||
```
|
||||
|
||||
## Zeroconf (mDNS) Veröffentlichung
|
||||
|
||||
Die Anwendung veröffentlicht die STOMP-Schnittstelle via Zeroconf (DNS-SD/mDNS), sofern verfügbar. Es wird der Service-Typ `_stomp._tcp.local.` mit folgenden TXT-Records publiziert:
|
||||
- path = Pfad für SockJS-Endpoint (Standard: /ws)
|
||||
- websocket = Pfad für nativen WebSocket (Standard: /websocket)
|
||||
- protocol = "stomp"
|
||||
|
||||
Clients können per Bonjour/mDNS nach `_stomp._tcp` suchen und erhalten Port und Metadaten.
|
||||
|
||||
Hinweise:
|
||||
- Die Implementierung nutzt JmDNS, falls die Bibliothek auf dem Klassenpfad vorhanden ist. In Umgebungen ohne JmDNS bleibt Zeroconf stillschweigend deaktiviert (es wird ein Hinweis im Log ausgegeben).
|
||||
- Konfigurierbare Properties:
|
||||
- app.zeroconf.enabled (default: true)
|
||||
- app.zeroconf.serviceName (default: votianlt-stomp)
|
||||
- app.stomp.wsPath (default: /ws)
|
||||
- app.stomp.websocketPath (default: /websocket)
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die STOMP-Konfiguration befindet sich in:
|
||||
- **`WebSocketConfig.java`** - WebSocket und STOMP-Konfiguration
|
||||
- **`MessageController.java`** - Nachrichtenbehandlung
|
||||
- **`application.properties`** - Zusätzliche WebSocket-Einstellungen
|
||||
|
||||
### Wichtige Konfigurationsparameter
|
||||
|
||||
```properties
|
||||
# Nachrichtenpuffergröße
|
||||
spring.websocket.servlet.max-text-message-buffer-size=8192
|
||||
spring.websocket.servlet.max-binary-message-buffer-size=8192
|
||||
|
||||
# STOMP aktivieren
|
||||
spring.websocket.stomp.enabled=true
|
||||
|
||||
# Heartbeat-Einstellungen
|
||||
spring.websocket.stomp.heartbeat.outgoing=10000
|
||||
spring.websocket.stomp.heartbeat.incoming=10000
|
||||
```
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- WebSocket-Verbindungen verwenden die gleiche Authentifizierung wie die Web-Anwendung
|
||||
- Nachrichten werden automatisch mit Zeitstempel versehen
|
||||
- Alle Nachrichten werden in JSON-Format verarbeitet
|
||||
|
||||
## Testing
|
||||
|
||||
Zum Testen der STOMP-Funktionalität können Sie:
|
||||
1. Eine WebSocket-Client-Bibliothek verwenden
|
||||
2. Browser-Entwicklertools für WebSocket-Verbindungen nutzen
|
||||
3. Spezialisierte STOMP-Testing-Tools einsetzen
|
||||
|
||||
Die Implementierung ist vollständig und bereit für die Integration mit externen Apps.
|
||||
|
||||
## Neue STOMP-Schnittstelle: Task-Erledigung melden
|
||||
|
||||
Mit dieser Schnittstelle kann ein Client die Erledigung eines Tasks melden.
|
||||
|
||||
- Senden (Client → Server): `/app/task/completed`
|
||||
- Broadcasts (Server → Client):
|
||||
- Global: `/topic/task-updates`
|
||||
- Task-spezifisch: `/topic/tasks/{taskId}`
|
||||
|
||||
Payload (JSON):
|
||||
- `Content-Type: application/json`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "<MongoId als String>",
|
||||
"completedBy": "<optional: Name/ID des Ausführenden>",
|
||||
"note": "<optional: Kommentar>"
|
||||
}
|
||||
```
|
||||
|
||||
Antwort (Beispiel):
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-09-05T09:25:00",
|
||||
"type": "taskCompletedAck",
|
||||
"success": true,
|
||||
"taskId": "...",
|
||||
"jobId": "...",
|
||||
"completed": true,
|
||||
"completedAt": "2025-09-05T09:25:00",
|
||||
"completedBy": "driver01",
|
||||
"note": "Übergabe erfolgreich",
|
||||
"event": "taskCompleted"
|
||||
"note": "Anlieferung dokumentiert",
|
||||
"photos": [
|
||||
"<base64-of-image-1>",
|
||||
"<base64-of-image-2>"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
JavaScript Beispiel:
|
||||
Example (curl):
|
||||
|
||||
```javascript
|
||||
// Abonnieren der globalen Updates
|
||||
stompClient.subscribe('/topic/task-updates', (frame) => {
|
||||
console.log('Task update:', JSON.parse(frame.body));
|
||||
});
|
||||
|
||||
// Abonnieren eines spezifischen Tasks
|
||||
stompClient.subscribe('/topic/tasks/' + taskId, (frame) => {
|
||||
console.log('Task-specific update:', JSON.parse(frame.body));
|
||||
});
|
||||
|
||||
// Task als erledigt melden
|
||||
stompClient.send('/app/task/completed', {}, JSON.stringify({
|
||||
taskId: taskId,
|
||||
completedBy: 'driver01',
|
||||
note: 'Übergabe erfolgreich'
|
||||
}));
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/tasks/66f5c2f1a3b6e27a1a234567/photos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"completedBy":"driver01",
|
||||
"note":"Anlieferung dokumentiert",
|
||||
"photos":["iVBORw0KGgo...","iVBORw0KGgo..."]
|
||||
}'
|
||||
```
|
||||
|
||||
Flutter/Dart Beispiel:
|
||||
### Simple JSON endpoint (single photo per call)
|
||||
|
||||
```dart
|
||||
stompClient.subscribe(
|
||||
destination: '/topic/task-updates',
|
||||
callback: (frame) => print('Task update: ${frame.body}'),
|
||||
);
|
||||
- URL: `POST /api/photos`
|
||||
- Content-Type: `application/json`
|
||||
- Body:
|
||||
|
||||
stompClient.subscribe(
|
||||
destination: '/topic/tasks/$taskId',
|
||||
callback: (frame) => print('Task-specific update: ${frame.body}'),
|
||||
);
|
||||
|
||||
stompClient.send(
|
||||
destination: '/app/task/completed',
|
||||
body: jsonEncode({
|
||||
'taskId': taskId,
|
||||
'completedBy': 'driver01',
|
||||
'note': 'Übergabe erfolgreich',
|
||||
}),
|
||||
);
|
||||
```json
|
||||
{
|
||||
"taskId": "66f5c2f1a3b6e27a1a234567",
|
||||
"photo": "<base64-of-image>",
|
||||
"completedBy": "driver01",
|
||||
"note": "optional"
|
||||
}
|
||||
```
|
||||
|
||||
Hinweise:
|
||||
- `taskId` ist Pflicht. Bei ungültiger oder unbekannter `taskId` wird `success=false` zurückgegeben.
|
||||
- Der Server setzt `completed=true` und `completedAt` automatisch.
|
||||
- Zusätzlich zum globalen Broadcast wird ein task-spezifisches Event auf `/topic/tasks/{taskId}` versendet.
|
||||
- Multiple photos: call this endpoint multiple times (one image per request).
|
||||
|
||||
Example (curl):
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/photos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"taskId":"66f5c2f1a3b6e27a1a234567",
|
||||
"photo":"iVBORw0KGgo...",
|
||||
"completedBy":"driver01",
|
||||
"note":"Anlieferung dokumentiert"
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
Both content types return the same JSON structure on success:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-09-13T21:27:00",
|
||||
"type": "photoUploadAck",
|
||||
"success": true,
|
||||
"photoId": "66f5c400b1a1b05b1c123abc",
|
||||
"photosCount": 2,
|
||||
"taskId": "66f5c2f1a3b6e27a1a234567",
|
||||
"jobId": "66f5c2f1a3b6e27a1a200000"
|
||||
}
|
||||
```
|
||||
|
||||
Error responses use HTTP status codes with:
|
||||
|
||||
```json
|
||||
{ "timestamp": "...", "type": "photoUploadAck", "success": false, "message": "..." }
|
||||
```
|
||||
|
||||
### Server-side side-effect (notification)
|
||||
|
||||
After a successful upload, a small event is broadcast to the specific task topic:
|
||||
|
||||
- Destination: `/topic/tasks/{taskId}`
|
||||
- Payload:
|
||||
|
||||
```json
|
||||
{ "event": "photoUploaded", "taskId": "...", "jobId": "...", "photosCount": 2, "timestamp": "..." }
|
||||
```
|
||||
|
||||
Use this to update UI (e.g., show that photos have arrived) without transferring large images via STOMP.
|
||||
|
||||
### Limits
|
||||
|
||||
- Multipart limits (configurable in `application.properties`):
|
||||
- `spring.servlet.multipart.max-file-size=32MB`
|
||||
- `spring.servlet.multipart.max-request-size=64MB`
|
||||
- Tomcat limits adjusted accordingly.
|
||||
|
||||
If you need higher limits, we can raise these values.
|
||||
|
||||
## 3) Marking Task as Completed (STOMP)
|
||||
|
||||
Continue to mark a task as completed via STOMP without embedding images:
|
||||
|
||||
- Send to: `/app/task/photo/completed`
|
||||
- Payload (no photos inside):
|
||||
|
||||
```json
|
||||
{ "taskId": "66f5c2f1a3b6e27a1a234567", "completedBy": "driver01", "note": "Anlieferung dokumentiert" }
|
||||
```
|
||||
|
||||
- Broadcasts an update to `/topic/task-updates` and `/topic/tasks/{taskId}`.
|
||||
|
||||
## 4) Retrieval of Photos (optional)
|
||||
|
||||
Currently, the backend stores uploaded photos as base64 in the `photos` collection referencing the `taskId` and `jobId`. If you need a download/list endpoint, let us know—we can expose `/api/tasks/{taskId}/photos` (GET) to return metadata and/or images.
|
||||
|
||||
---
|
||||
If anything is unclear or you need different formats (e.g., presigned URLs, chunked uploads), please reach out.
|
||||
@@ -6,6 +6,12 @@ import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBr
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
|
||||
import org.springframework.messaging.simp.config.ChannelRegistration;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
|
||||
import org.springframework.messaging.converter.MessageConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* WebSocket configuration for STOMP messaging.
|
||||
@@ -13,43 +19,74 @@ import org.springframework.web.socket.server.support.HttpSessionHandshakeInterce
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
@Slf4j
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// Enable a simple memory-based message broker to carry messages back to client
|
||||
// on destinations prefixed with "/topic" and "/queue"
|
||||
config.enableSimpleBroker("/topic", "/queue");
|
||||
|
||||
|
||||
// Designate the "/app" prefix for messages that are bound to methods
|
||||
// annotated with @MessageMapping
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
|
||||
|
||||
// Set user destination prefix for user-specific messages
|
||||
config.setUserDestinationPrefix("/user");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureClientInboundChannel(ChannelRegistration registration) {
|
||||
// Increase message size limits for large payloads (like base64 photos)
|
||||
registration.taskExecutor().corePoolSize(4);
|
||||
registration.taskExecutor().maxPoolSize(8);
|
||||
registration.taskExecutor().keepAliveSeconds(60);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureClientOutboundChannel(ChannelRegistration registration) {
|
||||
// Configure outbound channel for better performance with large messages
|
||||
registration.taskExecutor().corePoolSize(4);
|
||||
registration.taskExecutor().maxPoolSize(8);
|
||||
registration.taskExecutor().keepAliveSeconds(60);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
|
||||
// Use framework defaults (no custom large-message settings)
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean configureMessageConverters(List<MessageConverter> messageConverters) {
|
||||
// Use default message converters (no custom large-payload converter)
|
||||
return false; // keep default converters
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
log.info("=== REGISTERING WEBSOCKET ENDPOINTS ===");
|
||||
|
||||
// Register the "/ws" endpoint for WebSocket connections with SockJS fallback
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.addInterceptors(new HttpSessionHandshakeInterceptor())
|
||||
.withSockJS()
|
||||
.setHeartbeatTime(25000) // Set heartbeat interval
|
||||
.setDisconnectDelay(5000) // Set disconnect delay
|
||||
.setStreamBytesLimit(128 * 1024) // Set stream bytes limit
|
||||
.setHttpMessageCacheSize(1000) // Set HTTP message cache size
|
||||
.setSessionCookieNeeded(false); // Disable session cookie requirement
|
||||
|
||||
.setHeartbeatTime(25000)
|
||||
.setDisconnectDelay(5000)
|
||||
.setSessionCookieNeeded(false);
|
||||
|
||||
// Plain WebSocket endpoint without SockJS for native WebSocket clients (Flutter, mobile apps)
|
||||
registry.addEndpoint("/websocket")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.addInterceptors(new HttpSessionHandshakeInterceptor());
|
||||
|
||||
|
||||
// Additional endpoint specifically for mobile/Flutter clients that might have URL issues
|
||||
registry.addEndpoint("/stomp")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.addInterceptors(new HttpSessionHandshakeInterceptor());
|
||||
|
||||
log.info("WebSocket endpoints registered: /ws (with SockJS), /websocket, /stomp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.assecutor.votianlt.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.converter.DefaultContentTypeResolver;
|
||||
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
|
||||
import org.springframework.messaging.converter.MessageConverter;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Additional configuration for handling large WebSocket messages.
|
||||
* This configuration specifically addresses JSON parsing limits for large payloads.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebSocketMessageSizeConfig {
|
||||
|
||||
/**
|
||||
* Configure Jackson ObjectMapper to handle large JSON strings (like base64 photos).
|
||||
*/
|
||||
@Bean("webSocketObjectMapper")
|
||||
public ObjectMapper webSocketObjectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
return mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure message converter to use our custom ObjectMapper.
|
||||
*/
|
||||
@Bean
|
||||
public MappingJackson2MessageConverter messageConverter() {
|
||||
MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();
|
||||
converter.setObjectMapper(webSocketObjectMapper());
|
||||
|
||||
DefaultContentTypeResolver resolver = new DefaultContentTypeResolver();
|
||||
resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON);
|
||||
converter.setContentTypeResolver(resolver);
|
||||
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.CargoItem;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.Photo;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.pages.service.AppUserService;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
import de.assecutor.votianlt.repository.CargoItemRepository;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.repository.PhotoRepository;
|
||||
import de.assecutor.votianlt.repository.TaskRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -53,18 +55,22 @@ public class MessageController {
|
||||
@Autowired
|
||||
private TaskRepository taskRepository;
|
||||
|
||||
@Autowired
|
||||
private PhotoRepository photoRepository;
|
||||
|
||||
/**
|
||||
* Handles messages sent to /app/message and broadcasts them to all subscribers of /topic/messages
|
||||
*/
|
||||
@MessageMapping("/message")
|
||||
@SendTo("/topic/messages")
|
||||
public Map<String, Object> handleMessage(Map<String, Object> message) {
|
||||
log.error("=== ANY MESSAGE RECEIVED === STOMP Endpoint '/app/message' called");
|
||||
log.info("STOMP Endpoint '/app/message' called with data: {}", message);
|
||||
|
||||
|
||||
// Add timestamp to the message
|
||||
message.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
message.put("processed", true);
|
||||
|
||||
|
||||
log.info("STOMP Response for '/app/message' sent to '/topic/messages': {}", message);
|
||||
return message;
|
||||
}
|
||||
@@ -234,70 +240,16 @@ public class MessageController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Report task completion from apps.
|
||||
* Report generic task completion from apps.
|
||||
* Client sends to /app/task/completed with payload { taskId, completedBy?, note? }.
|
||||
* Broadcasts to /topic/task-updates and /topic/tasks/{taskId}.
|
||||
* This endpoint accepts any task type (fallback for GENERIC or unknown types).
|
||||
*/
|
||||
@MessageMapping("/task/completed")
|
||||
@SendTo("/topic/task-updates")
|
||||
public Map<String, Object> handleTaskCompleted(Map<String, Object> payload) {
|
||||
log.info("STOMP Endpoint '/app/task/completed' called with data: {}", payload);
|
||||
Map<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
response.put("type", "taskCompletedAck");
|
||||
|
||||
if (payload == null || !payload.containsKey("taskId") || payload.get("taskId") == null || payload.get("taskId").toString().isBlank()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "taskId ist erforderlich");
|
||||
log.info("Task completion failed: {}", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
String taskIdStr = payload.get("taskId").toString();
|
||||
String completedBy = payload.get("completedBy") != null ? payload.get("completedBy").toString() : null;
|
||||
String note = payload.get("note") != null ? payload.get("note").toString() : null;
|
||||
|
||||
try {
|
||||
org.bson.types.ObjectId taskId = new org.bson.types.ObjectId(taskIdStr);
|
||||
java.util.Optional<BaseTask> opt = taskRepository.findById(taskId);
|
||||
if (opt.isEmpty()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "Task nicht gefunden");
|
||||
return response;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
task.setCompleted(true);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
if (completedBy != null) task.setCompletedBy(completedBy);
|
||||
if (note != null) task.setCompletionNote(note);
|
||||
taskRepository.save(task);
|
||||
|
||||
java.util.Map<String, Object> event = new java.util.HashMap<>();
|
||||
event.put("taskId", task.getIdAsString());
|
||||
event.put("jobId", task.getJobIdAsString());
|
||||
event.put("completed", task.isCompleted());
|
||||
event.put("completedAt", task.getCompletedAt() != null ? task.getCompletedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null);
|
||||
event.put("completedBy", task.getCompletedBy());
|
||||
event.put("note", task.getCompletionNote());
|
||||
event.put("event", "taskCompleted");
|
||||
|
||||
// Send specific task topic
|
||||
messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event);
|
||||
|
||||
response.put("success", true);
|
||||
response.putAll(event);
|
||||
log.info("Task completion processed successfully for taskId={}", taskIdStr);
|
||||
return response;
|
||||
} catch (IllegalArgumentException e) {
|
||||
response.put("success", false);
|
||||
response.put("message", "Ungültige taskId");
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing task completion", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "Fehler bei der Verarbeitung");
|
||||
return response;
|
||||
}
|
||||
return processTaskCompletion(payload, null); // null means accept any task type
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,14 +266,15 @@ public class MessageController {
|
||||
|
||||
/**
|
||||
* Report photo task completion from apps.
|
||||
* Client sends to /app/task/photo/completed with payload { taskId, completedBy?, note? }.
|
||||
* Client sends to /app/task/photo/completed with payload { taskId, completedBy?, note?, extraData? }.
|
||||
* The extraData contains: { photos: base64List, count: base64List.length }
|
||||
* Broadcasts to /topic/task-updates and /topic/tasks/{taskId}.
|
||||
*/
|
||||
@MessageMapping("/task/photo/completed")
|
||||
@SendTo("/topic/task-updates")
|
||||
public Map<String, Object> handlePhotoTaskCompleted(Map<String, Object> payload) {
|
||||
log.info("STOMP Endpoint '/app/task/photo/completed' called with data: {}", payload);
|
||||
return processTaskCompletion(payload, "PHOTO");
|
||||
log.info("STOMP Endpoint '/app/task/photo/completed' called");
|
||||
return processPhotoTaskCompletion(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,6 +313,107 @@ public class MessageController {
|
||||
return processTaskCompletion(payload, "TODOLIST");
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized method to process photo task completion with extraData handling.
|
||||
* Saves photo data to the photos collection and processes task completion.
|
||||
*/
|
||||
private Map<String, Object> processPhotoTaskCompletion(Map<String, Object> payload) {
|
||||
Map<String, Object> response = new java.util.HashMap<>();
|
||||
response.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
response.put("type", "taskCompletedAck");
|
||||
|
||||
if (payload == null || !payload.containsKey("taskId") || payload.get("taskId") == null || payload.get("taskId").toString().isBlank()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "taskId ist erforderlich");
|
||||
log.info("Photo task completion failed: {}", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
String taskIdStr = payload.get("taskId").toString();
|
||||
String completedBy = payload.get("completedBy") != null ? payload.get("completedBy").toString() : null;
|
||||
String note = payload.get("note") != null ? payload.get("note").toString() : null;
|
||||
|
||||
try {
|
||||
org.bson.types.ObjectId taskId = new org.bson.types.ObjectId(taskIdStr);
|
||||
java.util.Optional<BaseTask> opt = taskRepository.findById(taskId);
|
||||
if (opt.isEmpty()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "Task nicht gefunden");
|
||||
return response;
|
||||
}
|
||||
|
||||
BaseTask task = opt.get();
|
||||
|
||||
// Validate task type is PHOTO
|
||||
if (!"PHOTO".equals(task.getTaskType())) {
|
||||
response.put("success", false);
|
||||
response.put("message", "Task-Typ stimmt nicht mit dem Endpunkt überein. Erwartet: PHOTO, Gefunden: " + task.getTaskType());
|
||||
log.warn("Task type mismatch for taskId={}: expected=PHOTO, actual={}", taskIdStr, task.getTaskType());
|
||||
return response;
|
||||
}
|
||||
|
||||
// Process extraData if present
|
||||
if (payload.containsKey("extraData") && payload.get("extraData") != null) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> extraData = (Map<String, Object>) payload.get("extraData");
|
||||
|
||||
if (extraData.containsKey("photos") && extraData.get("photos") != null) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> base64Photos = (List<String>) extraData.get("photos");
|
||||
|
||||
// Create and save Photo entity
|
||||
Photo photo = new Photo(task.getJobId(), task.getId(), base64Photos, completedBy);
|
||||
photoRepository.save(photo);
|
||||
|
||||
log.info("Saved {} photos for taskId={}, jobId={}, photoId={}",
|
||||
base64Photos.size(), taskIdStr, task.getJobIdAsString(), photo.getIdAsString());
|
||||
|
||||
response.put("photoId", photo.getIdAsString());
|
||||
response.put("photosCount", base64Photos.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing photo extraData for taskId={}: {}", taskIdStr, e.getMessage(), e);
|
||||
response.put("photoError", "Fehler beim Speichern der Fotos: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the task
|
||||
task.setCompleted(true);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
if (completedBy != null) task.setCompletedBy(completedBy);
|
||||
if (note != null) task.setCompletionNote(note);
|
||||
taskRepository.save(task);
|
||||
|
||||
java.util.Map<String, Object> event = new java.util.HashMap<>();
|
||||
event.put("taskId", task.getIdAsString());
|
||||
event.put("jobId", task.getJobIdAsString());
|
||||
event.put("completed", task.isCompleted());
|
||||
event.put("completedAt", task.getCompletedAt() != null ? task.getCompletedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null);
|
||||
event.put("completedBy", task.getCompletedBy());
|
||||
event.put("note", task.getCompletionNote());
|
||||
event.put("event", "taskCompleted");
|
||||
event.put("taskType", task.getTaskType());
|
||||
|
||||
// Send specific task topic
|
||||
messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event);
|
||||
|
||||
response.put("success", true);
|
||||
response.putAll(event);
|
||||
log.info("Photo task completion processed successfully for taskId={}", taskIdStr);
|
||||
return response;
|
||||
} catch (IllegalArgumentException e) {
|
||||
response.put("success", false);
|
||||
response.put("message", "Ungültige taskId");
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing photo task completion", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "Fehler bei der Verarbeitung");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common method to process task completion for different task types.
|
||||
* This method contains the shared logic for all task completion endpoints.
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import de.assecutor.votianlt.model.Photo;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.repository.PhotoRepository;
|
||||
import de.assecutor.votianlt.repository.TaskRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* REST endpoint for uploading photos for PHOTO tasks via HTTP POST instead of STOMP payload.
|
||||
*
|
||||
* Provides two content types on the same path:
|
||||
* - multipart/form-data: files[] (one or many images), optional completedBy, note
|
||||
* - application/json: { photos: [base64], completedBy?, note? }
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@CrossOrigin(origins = "*")
|
||||
@Slf4j
|
||||
public class PhotoUploadController {
|
||||
|
||||
@Autowired
|
||||
private TaskRepository taskRepository;
|
||||
|
||||
@Autowired
|
||||
private PhotoRepository photoRepository;
|
||||
|
||||
@Autowired
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@PostMapping(path = "/tasks/{taskId}/photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<Map<String, Object>> uploadPhotosMultipart(
|
||||
@PathVariable("taskId") String taskId,
|
||||
@RequestParam(value = "files") List<MultipartFile> files,
|
||||
@RequestParam(value = "completedBy", required = false) String completedBy,
|
||||
@RequestParam(value = "note", required = false) String note
|
||||
) {
|
||||
Map<String, Object> response = initResponse("photoUploadAck");
|
||||
if (!StringUtils.hasText(taskId)) {
|
||||
return badRequest(response, "taskId ist erforderlich");
|
||||
}
|
||||
if (files == null || files.isEmpty()) {
|
||||
return badRequest(response, "Mindestens eine Bilddatei (files) ist erforderlich");
|
||||
}
|
||||
try {
|
||||
ObjectId taskObjectId = new ObjectId(taskId);
|
||||
Optional<BaseTask> opt = taskRepository.findById(taskObjectId);
|
||||
if (opt.isEmpty()) {
|
||||
return notFound(response, "Task nicht gefunden");
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
if (!"PHOTO".equals(task.getTaskType())) {
|
||||
return badRequest(response, "Task-Typ stimmt nicht. Erwartet: PHOTO, Gefunden: " + task.getTaskType());
|
||||
}
|
||||
|
||||
// Convert files to base64 strings to keep storage compatible with existing Photo entity
|
||||
List<String> base64Photos = new ArrayList<>();
|
||||
for (MultipartFile file : files) {
|
||||
if (file == null || file.isEmpty()) continue;
|
||||
String base64 = Base64.getEncoder().encodeToString(file.getBytes());
|
||||
base64Photos.add(base64);
|
||||
}
|
||||
if (base64Photos.isEmpty()) {
|
||||
return badRequest(response, "Die übermittelten Dateien sind leer");
|
||||
}
|
||||
|
||||
Photo photo = new Photo(task.getJobId(), task.getId(), base64Photos, completedBy);
|
||||
photoRepository.save(photo);
|
||||
|
||||
// Build success response
|
||||
response.put("success", true);
|
||||
response.put("photoId", photo.getIdAsString());
|
||||
response.put("photosCount", base64Photos.size());
|
||||
response.put("taskId", task.getIdAsString());
|
||||
response.put("jobId", task.getJobIdAsString());
|
||||
|
||||
// Optionally broadcast a small event to task topic
|
||||
Map<String, Object> event = new HashMap<>();
|
||||
event.put("event", "photoUploaded");
|
||||
event.put("taskId", task.getIdAsString());
|
||||
event.put("jobId", task.getJobIdAsString());
|
||||
event.put("photosCount", base64Photos.size());
|
||||
event.put("timestamp", now());
|
||||
messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event);
|
||||
|
||||
log.info("Photo upload (multipart) successful: taskId={}, jobId={}, photoId={}, count={}",
|
||||
taskId, task.getJobIdAsString(), photo.getIdAsString(), base64Photos.size());
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return badRequest(response, "Ungültige taskId");
|
||||
} catch (IOException e) {
|
||||
log.error("Fehler beim Lesen der Dateien: {}", e.getMessage(), e);
|
||||
return serverError(response, "Fehler beim Lesen der Dateien: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("Fehler beim Speichern der Fotos: {}", e.getMessage(), e);
|
||||
return serverError(response, "Fehler beim Speichern der Fotos");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(path = "/tasks/{taskId}/photos", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Map<String, Object>> uploadPhotosJson(
|
||||
@PathVariable("taskId") String taskId,
|
||||
@RequestBody Map<String, Object> body
|
||||
) {
|
||||
Map<String, Object> response = initResponse("photoUploadAck");
|
||||
if (!StringUtils.hasText(taskId)) {
|
||||
return badRequest(response, "taskId ist erforderlich");
|
||||
}
|
||||
try {
|
||||
ObjectId taskObjectId = new ObjectId(taskId);
|
||||
Optional<BaseTask> opt = taskRepository.findById(taskObjectId);
|
||||
if (opt.isEmpty()) {
|
||||
return notFound(response, "Task nicht gefunden");
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
if (!"PHOTO".equals(task.getTaskType())) {
|
||||
return badRequest(response, "Task-Typ stimmt nicht. Erwartet: PHOTO, Gefunden: " + task.getTaskType());
|
||||
}
|
||||
|
||||
String completedBy = body.get("completedBy") != null ? body.get("completedBy").toString() : null;
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> photos = body.get("photos") instanceof List ? (List<String>) body.get("photos") : null;
|
||||
if (photos == null || photos.isEmpty()) {
|
||||
return badRequest(response, "Feld 'photos' (Liste von Base64-Strings) ist erforderlich");
|
||||
}
|
||||
|
||||
Photo photo = new Photo(task.getJobId(), task.getId(), photos, completedBy);
|
||||
photoRepository.save(photo);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("photoId", photo.getIdAsString());
|
||||
response.put("photosCount", photo.getCount());
|
||||
response.put("taskId", task.getIdAsString());
|
||||
response.put("jobId", task.getJobIdAsString());
|
||||
|
||||
Map<String, Object> event = new HashMap<>();
|
||||
event.put("event", "photoUploaded");
|
||||
event.put("taskId", task.getIdAsString());
|
||||
event.put("jobId", task.getJobIdAsString());
|
||||
event.put("photosCount", photo.getCount());
|
||||
event.put("timestamp", now());
|
||||
messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event);
|
||||
|
||||
log.info("Photo upload (json) successful: taskId={}, jobId={}, photoId={}, count={}",
|
||||
taskId, task.getJobIdAsString(), photo.getIdAsString(), photo.getCount());
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return badRequest(response, "Ungültige taskId");
|
||||
} catch (Exception e) {
|
||||
log.error("Fehler beim Speichern der Fotos: {}", e.getMessage(), e);
|
||||
return serverError(response, "Fehler beim Speichern der Fotos");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* New simple JSON endpoint: accept a single base64 photo with taskId in the body.
|
||||
* If there are multiple photos, call this endpoint multiple times.
|
||||
* Body: { "taskId": "<ObjectId>", "photo": "<base64>", "completedBy"?: "...", "note"?: "..." }
|
||||
*/
|
||||
@PostMapping(path = "/photos", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Map<String, Object>> uploadSinglePhotoJson(
|
||||
@RequestBody Map<String, Object> body
|
||||
) {
|
||||
Map<String, Object> response = initResponse("photoUploadAck");
|
||||
String taskId = body != null && body.get("taskId") != null ? body.get("taskId").toString() : null;
|
||||
String completedBy = body != null && body.get("completedBy") != null ? body.get("completedBy").toString() : null;
|
||||
String base64Photo = body != null && body.get("photo") != null ? body.get("photo").toString() : null;
|
||||
|
||||
if (!StringUtils.hasText(taskId)) {
|
||||
return badRequest(response, "taskId ist erforderlich");
|
||||
}
|
||||
if (!StringUtils.hasText(base64Photo)) {
|
||||
return badRequest(response, "Feld 'photo' (Base64-String) ist erforderlich");
|
||||
}
|
||||
try {
|
||||
ObjectId taskObjectId = new ObjectId(taskId);
|
||||
Optional<BaseTask> opt = taskRepository.findById(taskObjectId);
|
||||
if (opt.isEmpty()) {
|
||||
return notFound(response, "Task nicht gefunden");
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
if (!"PHOTO".equals(task.getTaskType())) {
|
||||
return badRequest(response, "Task-Typ stimmt nicht. Erwartet: PHOTO, Gefunden: " + task.getTaskType());
|
||||
}
|
||||
|
||||
Photo photo = new Photo(task.getJobId(), task.getId(), java.util.List.of(base64Photo), completedBy);
|
||||
photoRepository.save(photo);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("photoId", photo.getIdAsString());
|
||||
response.put("photosCount", 1);
|
||||
response.put("taskId", task.getIdAsString());
|
||||
response.put("jobId", task.getJobIdAsString());
|
||||
|
||||
Map<String, Object> event = new HashMap<>();
|
||||
event.put("event", "photoUploaded");
|
||||
event.put("taskId", task.getIdAsString());
|
||||
event.put("jobId", task.getJobIdAsString());
|
||||
event.put("photosCount", 1);
|
||||
event.put("timestamp", now());
|
||||
messagingTemplate.convertAndSend("/topic/tasks/" + task.getIdAsString(), event);
|
||||
|
||||
log.info("Photo upload (single json) successful: taskId={}, jobId={}, photoId={}",
|
||||
taskId, task.getJobIdAsString(), photo.getIdAsString());
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return badRequest(response, "Ungültige taskId");
|
||||
} catch (Exception e) {
|
||||
log.error("Fehler beim Speichern des Fotos: {}", e.getMessage(), e);
|
||||
return serverError(response, "Fehler beim Speichern des Fotos");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> initResponse(String type) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("timestamp", now());
|
||||
map.put("type", type);
|
||||
return map;
|
||||
}
|
||||
|
||||
private String now() {
|
||||
return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> badRequest(Map<String, Object> response, String msg) {
|
||||
response.put("success", false);
|
||||
response.put("message", msg);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
||||
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> notFound(Map<String, Object> response, String msg) {
|
||||
response.put("success", false);
|
||||
response.put("message", msg);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> serverError(Map<String, Object> response, String msg) {
|
||||
response.put("success", false);
|
||||
response.put("message", msg);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||
}
|
||||
}
|
||||
123
src/main/java/de/assecutor/votianlt/model/Photo.java
Normal file
123
src/main/java/de/assecutor/votianlt/model/Photo.java
Normal file
@@ -0,0 +1,123 @@
|
||||
package de.assecutor.votianlt.model;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Photo entity for storing photo data from task completions.
|
||||
* References the job ObjectId and stores base64 encoded photos.
|
||||
*/
|
||||
@Document(collection = "photos")
|
||||
public class Photo {
|
||||
|
||||
@Id
|
||||
private ObjectId id;
|
||||
|
||||
private ObjectId jobId;
|
||||
private ObjectId taskId;
|
||||
private List<String> photos; // base64 encoded photos
|
||||
private int count;
|
||||
private LocalDateTime createdAt;
|
||||
private String completedBy;
|
||||
|
||||
// Default constructor
|
||||
public Photo() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Constructor with parameters
|
||||
public Photo(ObjectId jobId, ObjectId taskId, List<String> photos, String completedBy) {
|
||||
this();
|
||||
this.jobId = jobId;
|
||||
this.taskId = taskId;
|
||||
this.photos = photos;
|
||||
this.count = photos != null ? photos.size() : 0;
|
||||
this.completedBy = completedBy;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public ObjectId getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(ObjectId id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getIdAsString() {
|
||||
return id != null ? id.toHexString() : null;
|
||||
}
|
||||
|
||||
public ObjectId getJobId() {
|
||||
return jobId;
|
||||
}
|
||||
|
||||
public void setJobId(ObjectId jobId) {
|
||||
this.jobId = jobId;
|
||||
}
|
||||
|
||||
public String getJobIdAsString() {
|
||||
return jobId != null ? jobId.toHexString() : null;
|
||||
}
|
||||
|
||||
public ObjectId getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(ObjectId taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getTaskIdAsString() {
|
||||
return taskId != null ? taskId.toHexString() : null;
|
||||
}
|
||||
|
||||
public List<String> getPhotos() {
|
||||
return photos;
|
||||
}
|
||||
|
||||
public void setPhotos(List<String> photos) {
|
||||
this.photos = photos;
|
||||
this.count = photos != null ? photos.size() : 0;
|
||||
}
|
||||
|
||||
public int getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public void setCount(int count) {
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getCompletedBy() {
|
||||
return completedBy;
|
||||
}
|
||||
|
||||
public void setCompletedBy(String completedBy) {
|
||||
this.completedBy = completedBy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Photo{" +
|
||||
"id=" + id +
|
||||
", jobId=" + jobId +
|
||||
", taskId=" + taskId +
|
||||
", count=" + count +
|
||||
", createdAt=" + createdAt +
|
||||
", completedBy='" + completedBy + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.assecutor.votianlt.repository;
|
||||
|
||||
import de.assecutor.votianlt.model.Photo;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository interface for Photo entities.
|
||||
* Provides database operations for the photos collection.
|
||||
*/
|
||||
@Repository
|
||||
public interface PhotoRepository extends MongoRepository<Photo, ObjectId> {
|
||||
|
||||
/**
|
||||
* Find all photos associated with a specific job ID.
|
||||
* @param jobId The ObjectId of the job
|
||||
* @return List of photos for the job
|
||||
*/
|
||||
List<Photo> findByJobId(ObjectId jobId);
|
||||
|
||||
/**
|
||||
* Find all photos associated with a specific task ID.
|
||||
* @param taskId The ObjectId of the task
|
||||
* @return List of photos for the task
|
||||
*/
|
||||
List<Photo> findByTaskId(ObjectId taskId);
|
||||
|
||||
/**
|
||||
* Find photos by job ID as string.
|
||||
* @param jobId The job ID as string
|
||||
* @return List of photos for the job
|
||||
*/
|
||||
default List<Photo> findByJobId(String jobId) {
|
||||
return findByJobId(new ObjectId(jobId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find photos by task ID as string.
|
||||
* @param taskId The task ID as string
|
||||
* @return List of photos for the task
|
||||
*/
|
||||
default List<Photo> findByTaskId(String taskId) {
|
||||
return findByTaskId(new ObjectId(taskId));
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,26 @@ mail.smtp.host=smtp.ionos.de
|
||||
mail.smtp.port=587
|
||||
|
||||
# WebSocket and STOMP Configuration
|
||||
# WebSocket message size limits (in bytes)
|
||||
spring.websocket.servlet.max-text-message-buffer-size=8192
|
||||
spring.websocket.servlet.max-binary-message-buffer-size=8192
|
||||
# WebSocket message size limits (in bytes) - Increased for large photo payloads
|
||||
# Enable STOMP over WebSocket
|
||||
spring.websocket.stomp.enabled=true
|
||||
# STOMP heartbeat settings (in milliseconds)
|
||||
spring.websocket.stomp.heartbeat.outgoing=10000
|
||||
spring.websocket.stomp.heartbeat.incoming=10000
|
||||
# HTTP request size limits for large payloads
|
||||
server.max-http-request-header-size=8MB
|
||||
# Spring messaging size limits for STOMP
|
||||
# Tomcat connector limits
|
||||
server.tomcat.max-http-form-post-size=64MB
|
||||
server.tomcat.max-save-post-size=64MB
|
||||
server.tomcat.max-swallow-size=64MB
|
||||
# Multipart upload limits for photo HTTP uploads
|
||||
spring.servlet.multipart.max-file-size=32MB
|
||||
spring.servlet.multipart.max-request-size=64MB
|
||||
# Additional WebSocket and messaging limits for large payloads
|
||||
# STOMP broker relay limits
|
||||
# Jackson message converter limits
|
||||
spring.jackson.default-property-inclusion=non_null
|
||||
|
||||
# 2FA Configuration
|
||||
app.security.two-factor.enabled=false
|
||||
Reference in New Issue
Block a user