diff --git a/docs/api/NOTIFICATION_API_GUIDE.md b/docs/api/NOTIFICATION_API_GUIDE.md new file mode 100644 index 0000000..f478b1a --- /dev/null +++ b/docs/api/NOTIFICATION_API_GUIDE.md @@ -0,0 +1,91 @@ +# Notification API Guide + +## 1. Tổng quan +- Hỗ trợ **thông báo realtime cho sinh viên** qua WebSocket. +- Các sự kiện đã phát hiện và gửi notification: + - Nạp tiền thành công (deposit webhook SePay). + - Print job hoàn thành. + - Print job bị hủy. +- Mỗi notification được lưu DB (bảng `notification`) và có thể đẩy realtime nếu client đang subscribe. + +## 2. WebSocket Notifications + +- **WebSocket endpoint:** `/ws` +- **Subscribe (STOMP):** gửi message đến `/app/students/{studentId}/notifications/subscribe` +- **Unsubscribe:** gửi message đến `/app/students/{studentId}/notifications/unsubscribe` +- **Topic nhận:** `/topic/students/{studentId}/notifications` + +### 2.1 Message format +```json +{ + "type": "notification", + "data": { + "notificationId": "f2c9c2f1-6c3d-4b4d-b12c-4bcbcb8c1234", + "notificationType": "DEPOSIT_COMPLETED", + "title": "Nạp tiền thành công", + "message": "Bạn đã nạp thành công 50,000 VND vào tài khoản SmartPrint.", + "isRead": false, + "createdAt": "2025-12-23T10:00:00" + }, + "timestamp": "2025-12-23T10:00:00" +} +``` + +- `type`: luôn `"notification"` cho thông báo push. +- `notificationType` gợi ý: + - `DEPOSIT_COMPLETED` + - `PRINT_JOB_COMPLETED` + - `PRINT_JOB_CANCELLED` + +### 2.2 Trình tự client +1) Kết nối SockJS/STOMP tới `/ws`. +2) Gửi subscribe: + - Destination: `/app/students/{studentId}/notifications/subscribe` + - Body: rỗng. +3) Lắng nghe topic `/topic/students/{studentId}/notifications`. +4) Khi nhận message `type=notification`, hiển thị nội dung `title`, `message`. +5) Khi rời màn hình: gửi `/app/students/{studentId}/notifications/unsubscribe`. + +### 2.3 Ví dụ JS (tóm tắt) +```javascript +const socket = new SockJS('/ws'); +const stomp = Stomp.over(socket); +stomp.connect({}, () => { + stomp.subscribe(`/topic/students/${studentId}/notifications`, (msg) => { + const payload = JSON.parse(msg.body); + if (payload.type === 'notification') { + console.log('Notification:', payload.data.title, payload.data.message); + } + }); + stomp.send(`/app/students/${studentId}/notifications/subscribe`, {}, {}); +}); +``` + +## 3. Các sự kiện hiện đã hook +- **Nạp tiền thành công (SePay webhook):** + - Tạo bản ghi notification với `notificationType = "DEPOSIT_COMPLETED"`. + - Đẩy WS nếu có subscriber của sinh viên. +- **Print job hoàn thành:** + - Khi job chuyển `printing → completed`, tạo notification `PRINT_JOB_COMPLETED`. +- **Print job bị hủy:** + - Khi hủy job (queued/pending_payment), tạo notification `PRINT_JOB_CANCELLED` (kèm thông tin refund nếu có). + +## 4. Database schema (tóm tắt) +- Bảng `notification`: + - `notification_id` (PK) + - `student_id` (FK -> student) + - `notification_type` (VARCHAR 50) + - `title` (NVARCHAR 200) + - `message` (NVARCHAR 1000) + - `is_read` (BIT, default 0) + - `created_at` (DATETIME, default GETDATE) + +## 5. REST endpoints (chưa triển khai) +- Dự kiến: + - `GET /api/students/notifications` — danh sách notification (paging). + - `POST /api/students/notifications/{notificationId}/read` — đánh dấu đã đọc. +- Hiện tại sử dụng WebSocket realtime; nếu cần REST, hãy yêu cầu để triển khai thêm. + +## 6. Lưu ý bảo mật +- WebSocket hiện không enforce ownership trong subscribe handler; cần cấu hình frontend chỉ subscribe đúng `studentId` của người đăng nhập. Có thể tăng cường bằng cách map `userId -> studentId` từ JWT và kiểm tra trong controller/subscription. + diff --git a/src/main/java/com/siu/controller/StudentNotificationWebSocketController.java b/src/main/java/com/siu/controller/StudentNotificationWebSocketController.java new file mode 100644 index 0000000..2260ccf --- /dev/null +++ b/src/main/java/com/siu/controller/StudentNotificationWebSocketController.java @@ -0,0 +1,57 @@ +package com.siu.controller; + +import com.siu.dto.WebSocketMessage; +import com.siu.service.NotificationWebSocketService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.stereotype.Controller; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +/** + * WebSocket controller for student-level notifications. + * Client: + * - subscribe: /app/students/{studentId}/notifications/subscribe + * - unsubscribe: /app/students/{studentId}/notifications/unsubscribe + * - receive: /topic/students/{studentId}/notifications + */ +@Controller +@RequiredArgsConstructor +@Slf4j +public class StudentNotificationWebSocketController { + + private final NotificationWebSocketService notificationWebSocketService; + + @MessageMapping("/students/{studentId}/notifications/subscribe") + public WebSocketMessage subscribe( + @DestinationVariable UUID studentId, + SimpMessageHeaderAccessor headerAccessor + ) { + String sessionId = headerAccessor.getSessionId(); + notificationWebSocketService.subscribe(studentId, sessionId); + log.debug("Client {} subscribed to notifications for student {}", sessionId, studentId); + + return WebSocketMessage.builder() + .type("subscribed") + .data(Map.of("studentId", studentId.toString())) + .timestamp(LocalDateTime.now()) + .build(); + } + + @MessageMapping("/students/{studentId}/notifications/unsubscribe") + public void unsubscribe( + @DestinationVariable UUID studentId, + SimpMessageHeaderAccessor headerAccessor + ) { + String sessionId = headerAccessor.getSessionId(); + notificationWebSocketService.unsubscribe(studentId, sessionId); + log.debug("Client {} unsubscribed from notifications for student {}", sessionId, studentId); + } +} + + diff --git a/src/main/java/com/siu/dto/NotificationResponse.java b/src/main/java/com/siu/dto/NotificationResponse.java new file mode 100644 index 0000000..379370e --- /dev/null +++ b/src/main/java/com/siu/dto/NotificationResponse.java @@ -0,0 +1,38 @@ +package com.siu.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Notification dto for student") +public class NotificationResponse { + + @Schema(description = "Notification ID") + private UUID notificationId; + + @Schema(description = "Notification type", example = "DEPOSIT_COMPLETED") + private String notificationType; + + @Schema(description = "Title", example = "Nạp tiền thành công") + private String title; + + @Schema(description = "Message", example = "Bạn đã nạp 50.000đ vào tài khoản SmartPrint") + private String message; + + @Schema(description = "Read flag") + private Boolean isRead; + + @Schema(description = "Created time") + private LocalDateTime createdAt; +} + + diff --git a/src/main/java/com/siu/entity/Notification.java b/src/main/java/com/siu/entity/Notification.java new file mode 100644 index 0000000..b59f85f --- /dev/null +++ b/src/main/java/com/siu/entity/Notification.java @@ -0,0 +1,61 @@ +package com.siu.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "notification", indexes = { + @Index(name = "idx_notification_student", columnList = "student_id, created_at"), + @Index(name = "idx_notification_unread", columnList = "student_id, is_read, created_at") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "notification_id", nullable = false) + private UUID notificationId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id", nullable = false) + private Student student; + + @Column(name = "notification_type", nullable = false, length = 50) + private String notificationType; + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "message", nullable = false, length = 1000) + private String message; + + @Builder.Default + @Column(name = "is_read", nullable = false) + private Boolean isRead = false; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + if (isRead == null) { + isRead = false; + } + } +} + + diff --git a/src/main/java/com/siu/repository/NotificationRepository.java b/src/main/java/com/siu/repository/NotificationRepository.java new file mode 100644 index 0000000..630fb2d --- /dev/null +++ b/src/main/java/com/siu/repository/NotificationRepository.java @@ -0,0 +1,17 @@ +package com.siu.repository; + +import com.siu.entity.Notification; +import com.siu.entity.Student; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface NotificationRepository extends JpaRepository { + + List findTop20ByStudentOrderByCreatedAtDesc(Student student); + + List findByStudentAndIsReadFalseOrderByCreatedAtDesc(Student student); +} + + diff --git a/src/main/java/com/siu/scheduler/PrintJobProgressUpdater.java b/src/main/java/com/siu/scheduler/PrintJobProgressUpdater.java index 53abc1e..5232b58 100644 --- a/src/main/java/com/siu/scheduler/PrintJobProgressUpdater.java +++ b/src/main/java/com/siu/scheduler/PrintJobProgressUpdater.java @@ -43,6 +43,7 @@ public class PrintJobProgressUpdater { private final PrinterPhysicalRepository printerPhysicalRepository; private final PrintJobWebSocketService webSocketService; private final StudentPrintJobService studentPrintJobService; + private final com.siu.service.NotificationService notificationService; // Page printing rate: configurable via application.properties @Value("${app.print-job.page-printing-rate-seconds:10}") @@ -225,6 +226,20 @@ private void completeJob(PrintJob job) { job.setEndTime(LocalDateTime.now()); printJobRepository.save(job); + // Gửi notification cho sinh viên + try { + notificationService.createNotificationForStudent( + job.getStudent().getStudentId(), + "PRINT_JOB_COMPLETED", + "Print job hoàn thành", + String.format("Print job %s đã hoàn thành trên máy in %s.", + job.getJobId(), + job.getPrinter() != null ? job.getPrinter().getPrinterId() : "") + ); + } catch (Exception e) { + log.warn("Failed to create notification for completed print job {}: {}", job.getJobId(), e.getMessage()); + } + // Bước 2: Update printer status back to idle để Queue Processor xử lý job tiếp theo PrinterPhysical printer = job.getPrinter(); if (printer != null) { diff --git a/src/main/java/com/siu/service/NotificationService.java b/src/main/java/com/siu/service/NotificationService.java new file mode 100644 index 0000000..e467506 --- /dev/null +++ b/src/main/java/com/siu/service/NotificationService.java @@ -0,0 +1,17 @@ +package com.siu.service; + +import com.siu.dto.NotificationResponse; + +import java.util.List; +import java.util.UUID; + +public interface NotificationService { + + void createNotificationForStudent(UUID studentId, String type, String title, String message); + + List getRecentNotificationsForStudent(UUID studentId); + + void markNotificationAsRead(UUID studentId, UUID notificationId); +} + + diff --git a/src/main/java/com/siu/service/NotificationWebSocketService.java b/src/main/java/com/siu/service/NotificationWebSocketService.java new file mode 100644 index 0000000..65b008b --- /dev/null +++ b/src/main/java/com/siu/service/NotificationWebSocketService.java @@ -0,0 +1,69 @@ +package com.siu.service; + +import com.siu.dto.NotificationResponse; +import com.siu.dto.WebSocketMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * WebSocket service for per-student notifications. + * Topic: /topic/students/{studentId}/notifications + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationWebSocketService { + + private final SimpMessagingTemplate messagingTemplate; + + // studentId -> sessionIds + private final Map> subscribedStudents = new ConcurrentHashMap<>(); + + public void subscribe(UUID studentId, String sessionId) { + subscribedStudents.computeIfAbsent(studentId, k -> ConcurrentHashMap.newKeySet()) + .add(sessionId); + log.debug("Client {} subscribed to notifications of student {}", sessionId, studentId); + } + + public void unsubscribe(UUID studentId, String sessionId) { + Set sessions = subscribedStudents.get(studentId); + if (sessions != null) { + sessions.remove(sessionId); + if (sessions.isEmpty()) { + subscribedStudents.remove(studentId); + } + log.debug("Client {} unsubscribed from notifications of student {}", sessionId, studentId); + } + } + + public boolean hasSubscribers(UUID studentId) { + Set sessions = subscribedStudents.get(studentId); + return sessions != null && !sessions.isEmpty(); + } + + public void sendNotification(UUID studentId, NotificationResponse notification) { + if (!hasSubscribers(studentId)) { + return; + } + + WebSocketMessage message = WebSocketMessage.builder() + .type("notification") + .data(notification) + .timestamp(LocalDateTime.now()) + .build(); + + String destination = "/topic/students/" + studentId + "/notifications"; + messagingTemplate.convertAndSend(destination, message); + log.debug("Sent notification {} to student {}", notification.getNotificationId(), studentId); + } +} + + diff --git a/src/main/java/com/siu/service/impl/NotificationServiceImpl.java b/src/main/java/com/siu/service/impl/NotificationServiceImpl.java new file mode 100644 index 0000000..751f027 --- /dev/null +++ b/src/main/java/com/siu/service/impl/NotificationServiceImpl.java @@ -0,0 +1,92 @@ +package com.siu.service.impl; + +import com.siu.dto.NotificationResponse; +import com.siu.entity.Notification; +import com.siu.entity.Student; +import com.siu.exception.ResourceNotFoundException; +import com.siu.repository.NotificationRepository; +import com.siu.repository.StudentRepository; +import com.siu.service.NotificationService; +import com.siu.service.NotificationWebSocketService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationServiceImpl implements NotificationService { + + private final NotificationRepository notificationRepository; + private final StudentRepository studentRepository; + private final NotificationWebSocketService notificationWebSocketService; + + @Override + @Transactional + public void createNotificationForStudent(UUID studentId, String type, String title, String message) { + Student student = studentRepository.findById(studentId) + .orElseThrow(() -> new ResourceNotFoundException("Student", "id", studentId.toString())); + + Notification notification = Notification.builder() + .student(student) + .notificationType(type) + .title(title) + .message(message) + .build(); + + notification = notificationRepository.save(notification); + + NotificationResponse dto = toDto(notification); + + // Push via WebSocket if there are subscribers + try { + notificationWebSocketService.sendNotification(studentId, dto); + } catch (Exception e) { + log.warn("Failed to send notification via WebSocket for student {}: {}", studentId, e.getMessage()); + } + } + + @Override + @Transactional(readOnly = true) + public List getRecentNotificationsForStudent(UUID studentId) { + Student student = studentRepository.findById(studentId) + .orElseThrow(() -> new ResourceNotFoundException("Student", "id", studentId.toString())); + + return notificationRepository.findTop20ByStudentOrderByCreatedAtDesc(student) + .stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @Override + @Transactional + public void markNotificationAsRead(UUID studentId, UUID notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new ResourceNotFoundException("Notification", "id", notificationId.toString())); + + if (!notification.getStudent().getStudentId().equals(studentId)) { + throw new IllegalArgumentException("Bạn không có quyền cập nhật thông báo này"); + } + + notification.setIsRead(true); + notificationRepository.save(notification); + } + + private NotificationResponse toDto(Notification notification) { + return NotificationResponse.builder() + .notificationId(notification.getNotificationId()) + .notificationType(notification.getNotificationType()) + .title(notification.getTitle()) + .message(notification.getMessage()) + .isRead(notification.getIsRead()) + .createdAt(notification.getCreatedAt()) + .build(); + } +} + + diff --git a/src/main/java/com/siu/service/impl/PaymentServiceImpl.java b/src/main/java/com/siu/service/impl/PaymentServiceImpl.java index 8027557..2a7eb35 100644 --- a/src/main/java/com/siu/service/impl/PaymentServiceImpl.java +++ b/src/main/java/com/siu/service/impl/PaymentServiceImpl.java @@ -29,6 +29,7 @@ import com.siu.repository.StudentWalletLedgerRepository; import com.siu.repository.UserRepository; import com.siu.service.DepositWebSocketService; +import com.siu.service.NotificationService; import com.siu.service.PrintJobPaymentWebSocketService; import com.siu.service.PaymentService; import lombok.RequiredArgsConstructor; @@ -66,6 +67,7 @@ public class PaymentServiceImpl implements PaymentService { private final PaymentRepository paymentRepository; private final PrintJobRepository printJobRepository; private final PrintJobPaymentWebSocketService printJobPaymentWebSocketService; + private final NotificationService notificationService; @Value("${sepay.account-number:0123499999}") private String sepayAccountNumber; @@ -358,6 +360,18 @@ private boolean processDepositWebhook(SepayWebhookRequest webhookRequest, String log.warn("Late payment processed for deposit {} (code: {})", deposit.getDepositId(), depositCode); } + // Create notification for student + try { + notificationService.createNotificationForStudent( + deposit.getStudent().getStudentId(), + "DEPOSIT_COMPLETED", + "Nạp tiền thành công", + String.format("Bạn đã nạp thành công %s vào tài khoản SmartPrint.", deposit.getTotalCredited()) + ); + } catch (Exception e) { + log.warn("Failed to create notification for completed deposit {}: {}", deposit.getDepositId(), e.getMessage()); + } + // Send WebSocket status update try { DepositStatusResponse statusResponse = toDepositStatusResponse(deposit); diff --git a/src/main/java/com/siu/service/impl/StudentPrintJobServiceImpl.java b/src/main/java/com/siu/service/impl/StudentPrintJobServiceImpl.java index 031449c..670e5b7 100644 --- a/src/main/java/com/siu/service/impl/StudentPrintJobServiceImpl.java +++ b/src/main/java/com/siu/service/impl/StudentPrintJobServiceImpl.java @@ -10,6 +10,7 @@ import com.siu.entity.*; import com.siu.exception.ResourceNotFoundException; import com.siu.repository.*; +import com.siu.service.NotificationService; import com.siu.service.StudentPrintJobService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,6 +40,7 @@ public class StudentPrintJobServiceImpl implements StudentPrintJobService { private final RefundPrintJobRepository refundPrintJobRepository; private final StudentRepository studentRepository; private final StudentWalletLedgerRepository studentWalletLedgerRepository; + private final NotificationService notificationService; // Page printing rate: configurable via application.properties @Value("${app.print-job.page-printing-rate-seconds:10}") @@ -503,6 +505,22 @@ public CancelPrintJobResponse cancelPrintJob(UUID studentId, UUID jobId) { // No ledger entry needed - payment was never completed } + // Gửi notification cho sinh viên về việc hủy job + try { + notificationService.createNotificationForStudent( + printJob.getStudent().getStudentId(), + "PRINT_JOB_CANCELLED", + "Print job đã bị hủy", + String.format("Print job %s đã được hủy.%s", + jobId, + "completed".equals(payment.getPaymentStatus()) && "balance".equals(payment.getPaymentMethod()) + ? " Số tiền đã được hoàn lại vào tài khoản." + : "") + ); + } catch (Exception e) { + log.warn("Failed to create notification for cancelled print job {}: {}", jobId, e.getMessage()); + } + return CancelPrintJobResponse.builder() .jobId(jobId) .refundAmount(printJob.getTotalPrice())