Skip to content

Commit a23cbd8

Browse files
authored
Merge pull request #530 from iflytek/fix/issue-51-profile-review-notifications
fix: admin notifications for profile review requests
2 parents a73dba5 + 52251fc commit a23cbd8

17 files changed

Lines changed: 294 additions & 16 deletions

File tree

server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.springframework.data.domain.Page;
1717
import org.springframework.data.domain.PageRequest;
1818
import org.springframework.data.domain.Sort;
19+
import org.springframework.http.MediaType;
1920
import org.springframework.validation.annotation.Validated;
2021
import org.springframework.web.bind.annotation.*;
2122
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -78,7 +79,7 @@ public ApiResponse<Void> deleteRead(@PathVariable Long id,
7879
return ok("response.success.deleted", null);
7980
}
8081

81-
@GetMapping("/sse")
82+
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
8283
public SseEmitter sse(@RequestAttribute("userId") String userId) {
8384
return sseEmitterManager.register(userId);
8485
}
@@ -113,6 +114,9 @@ private NotificationTarget resolveTarget(Notification notification) {
113114
if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) {
114115
return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId);
115116
}
117+
if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) {
118+
return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile");
119+
}
116120
if ("PROMOTION_SUBMITTED".equals(eventType)) {
117121
return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions");
118122
}

server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
3030
private static final Set<String> SKIP_PREFIXES = Set.of(
3131
"/actuator", "/favicon.ico", "/assets/"
3232
);
33+
private static final Set<String> SKIP_SUFFIXES = Set.of(
34+
"/sse"
35+
);
3336

3437
@Override
3538
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -89,6 +92,11 @@ private boolean shouldSkip(String uri) {
8992
return true;
9093
}
9194
}
95+
for (String suffix : SKIP_SUFFIXES) {
96+
if (uri.endsWith(suffix)) {
97+
return true;
98+
}
99+
}
92100
return false;
93101
}
94102

server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ public void onReviewSubmitted(ReviewSubmittedEvent event) {
128128
});
129129
}
130130

131+
@Async("skillhubEventExecutor")
132+
@TransactionalEventListener
133+
public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) {
134+
String title = "Profile review submitted";
135+
Map<String, Object> body = new LinkedHashMap<>();
136+
body.put("profileReviewId", event.profileReviewId());
137+
body.put("submitterId", event.submitterId());
138+
body.put("fields", event.fields());
139+
String json = toJson(body);
140+
List<String> admins = recipientResolver.resolvePlatformUserAdmins();
141+
for (String admin : admins.stream().distinct().toList()) {
142+
dispatcher.dispatch(admin, NotificationCategory.REVIEW,
143+
"PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId());
144+
}
145+
}
146+
131147
@Async("skillhubEventExecutor")
132148
@TransactionalEventListener
133149
public void onReviewApproved(ReviewApprovedEvent event) {

server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,14 @@ public List<String> resolvePlatformSkillAdmins() {
3939
List::copyOf
4040
));
4141
}
42+
43+
public List<String> resolvePlatformUserAdmins() {
44+
return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))
45+
.stream()
46+
.map(binding -> binding.getUserId())
47+
.collect(java.util.stream.Collectors.collectingAndThen(
48+
java.util.stream.Collectors.toCollection(LinkedHashSet::new),
49+
List::copyOf
50+
));
51+
}
4252
}

server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ void list_shouldExposeReviewTargetRouteForSubmittedReviewNotifications() {
7070
verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class));
7171
}
7272

73+
@Test
74+
void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() {
75+
Notification notification = notification(
76+
15L,
77+
NotificationCategory.REVIEW,
78+
"PROFILE_REVIEW_SUBMITTED",
79+
"{\"profileReviewId\":77,\"submitterId\":\"user-1\",\"fields\":[\"displayName\"]}",
80+
"PROFILE_REVIEW",
81+
77L
82+
);
83+
when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)))
84+
.thenReturn(new PageImpl<>(java.util.List.of(notification)));
85+
86+
PageResponse<NotificationResponse> page = controller.list("admin-1", "REVIEW", 0, 20).data();
87+
88+
assertThat(page.items()).singleElement().satisfies(item -> {
89+
assertThat(item.targetType()).isEqualTo("PROFILE_REVIEW");
90+
assertThat(item.targetId()).isEqualTo(77L);
91+
assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews?type=profile");
92+
});
93+
}
94+
7395
@Test
7496
void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() {
7597
Notification notification = notification(

server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,29 @@ void doFilterInternal_skipsActuatorEndpoints()
7878
assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health"));
7979
}
8080

81+
@Test
82+
void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse()
83+
throws ServletException, IOException {
84+
RequestLoggingFilter filter = new RequestLoggingFilter();
85+
attachAppender();
86+
87+
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse");
88+
MockHttpServletResponse response = new MockHttpServletResponse();
89+
90+
FilterChain filterChain = (req, res) -> {
91+
assertThat(res).isSameAs(response);
92+
res.setContentType("text/event-stream");
93+
res.getWriter().write("event:connected\n");
94+
res.getWriter().flush();
95+
};
96+
97+
filter.doFilter(request, response, filterChain);
98+
99+
assertThat(response.getHeader("Content-Length")).isNull();
100+
assertThat(response.getContentAsString()).isEqualTo("event:connected\n");
101+
assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse"));
102+
}
103+
81104
@Test
82105
void doFilterInternal_logsCoreSummaryFields()
83106
throws ServletException, IOException {

server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@ void onReviewSubmitted_shouldDispatchToNamespaceAdmins() throws Exception {
127127
verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any());
128128
}
129129

130+
@Test
131+
void onProfileReviewSubmitted_shouldDispatchToPlatformUserAdmins() throws Exception {
132+
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
133+
when(recipientResolver.resolvePlatformUserAdmins())
134+
.thenReturn(List.of("user-admin-1", "super-admin-1", "user-admin-1"));
135+
136+
listener.onProfileReviewSubmitted(
137+
new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName")));
138+
139+
verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW),
140+
eq("PROFILE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("PROFILE_REVIEW"), eq(77L));
141+
verify(dispatcher).dispatch(eq("user-admin-1"), any(), any(), any(), any(), any(), any());
142+
verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any());
143+
}
144+
130145
@Test
131146
void onReviewApproved_shouldDispatchToSubmitter() throws Exception {
132147
Skill skill = mockSkill(1L);

server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,20 @@ void resolvePlatformSkillAdmins_shouldIncludeSuperAdminsAndDeduplicateUsers() {
8080

8181
assertThat(result).containsExactly("skill-admin", "super-admin");
8282
}
83+
84+
@Test
85+
void resolvePlatformUserAdmins_shouldReturnUserAdminsAndSuperAdmins() {
86+
UserRoleBinding userAdmin = mock(UserRoleBinding.class);
87+
UserRoleBinding superAdmin = mock(UserRoleBinding.class);
88+
UserRoleBinding duplicate = mock(UserRoleBinding.class);
89+
when(userAdmin.getUserId()).thenReturn("user-admin");
90+
when(superAdmin.getUserId()).thenReturn("super-admin");
91+
when(duplicate.getUserId()).thenReturn("user-admin");
92+
when(userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN")))
93+
.thenReturn(List.of(userAdmin, superAdmin, duplicate));
94+
95+
List<String> result = resolver.resolvePlatformUserAdmins();
96+
97+
assertThat(result).containsExactly("user-admin", "super-admin");
98+
}
8399
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.iflytek.skillhub.domain.event;
2+
3+
import java.util.List;
4+
5+
public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List<String> fields) {
6+
7+
public ProfileReviewSubmittedEvent {
8+
fields = List.copyOf(fields);
9+
}
10+
}

server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import com.fasterxml.jackson.core.JsonProcessingException;
44
import com.fasterxml.jackson.databind.ObjectMapper;
55
import com.iflytek.skillhub.domain.audit.AuditLogService;
6+
import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent;
7+
import org.springframework.context.ApplicationEventPublisher;
68
import org.springframework.stereotype.Service;
79
import org.springframework.transaction.annotation.Transactional;
810

911
import java.util.LinkedHashMap;
12+
import java.util.List;
1013
import java.util.Map;
1114

1215
/**
@@ -29,19 +32,22 @@ public class UserProfileService {
2932
private final ProfileModerationConfig moderationConfig;
3033
private final ProfileFieldPolicyConfig fieldPolicyConfig;
3134
private final AuditLogService auditLogService;
35+
private final ApplicationEventPublisher eventPublisher;
3236

3337
public UserProfileService(UserAccountRepository userAccountRepository,
3438
ProfileChangeRequestRepository changeRequestRepository,
3539
ProfileModerationService moderationService,
3640
ProfileModerationConfig moderationConfig,
3741
ProfileFieldPolicyConfig fieldPolicyConfig,
38-
AuditLogService auditLogService) {
42+
AuditLogService auditLogService,
43+
ApplicationEventPublisher eventPublisher) {
3944
this.userAccountRepository = userAccountRepository;
4045
this.changeRequestRepository = changeRequestRepository;
4146
this.moderationService = moderationService;
4247
this.moderationConfig = moderationConfig;
4348
this.fieldPolicyConfig = fieldPolicyConfig;
4449
this.auditLogService = auditLogService;
50+
this.eventPublisher = eventPublisher;
4551
}
4652

4753
/**
@@ -112,8 +118,10 @@ public UpdateProfileResult updateProfile(String userId,
112118
// 5. Queue review changes
113119
if (!reviewChanges.isEmpty()) {
114120
cancelPendingRequests(userId);
115-
saveChangeRequest(userId, reviewChanges, oldValues, ProfileChangeStatus.PENDING,
116-
machineTag, null);
121+
ProfileChangeRequest pendingRequest = saveChangeRequest(userId, reviewChanges, oldValues,
122+
ProfileChangeStatus.PENDING, machineTag, null);
123+
eventPublisher.publishEvent(new ProfileReviewSubmittedEvent(
124+
pendingRequest.getId(), userId, List.copyOf(reviewChanges.keySet())));
117125
}
118126

119127
// 6. Return appropriate result
@@ -166,9 +174,9 @@ private Map<String, String> buildOldValues(UserAccount user, Map<String, String>
166174
/**
167175
* Persist a change request record for audit and review purposes.
168176
*/
169-
private void saveChangeRequest(String userId, Map<String, String> changes,
170-
Map<String, String> oldValues, ProfileChangeStatus status,
171-
String machineResult, String machineReason) {
177+
private ProfileChangeRequest saveChangeRequest(String userId, Map<String, String> changes,
178+
Map<String, String> oldValues, ProfileChangeStatus status,
179+
String machineResult, String machineReason) {
172180
ProfileChangeRequest request = new ProfileChangeRequest(
173181
userId,
174182
toJson(changes),
@@ -177,7 +185,7 @@ private void saveChangeRequest(String userId, Map<String, String> changes,
177185
machineResult,
178186
machineReason
179187
);
180-
changeRequestRepository.save(request);
188+
return changeRequestRepository.save(request);
181189
}
182190

183191
private String toJson(Object obj) {

0 commit comments

Comments
 (0)