diff --git a/common-events/src/main/java/events/user/UserEvent.java b/common-events/src/main/java/events/user/UserEvent.java new file mode 100644 index 00000000..5a22e22e --- /dev/null +++ b/common-events/src/main/java/events/user/UserEvent.java @@ -0,0 +1,22 @@ +package events.user; + +import events.user.data.UserPayload; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UserEvent { + Type type; + String id; // userId + UserPayload payload; // null nếu DELETED + + public enum Type { CREATED, UPDATED, DELETED, RESTORED } +} \ No newline at end of file diff --git a/common-events/src/main/java/events/user/UserRegisteredEvent.java b/common-events/src/main/java/events/user/UserRegisteredEvent.java new file mode 100644 index 00000000..8d7cffc4 --- /dev/null +++ b/common-events/src/main/java/events/user/UserRegisteredEvent.java @@ -0,0 +1,21 @@ +package events.user; + +import events.user.data.UserPayload; +import events.user.data.UserProfileCreationPayload; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UserRegisteredEvent { + String id; + UserPayload user; + UserProfileCreationPayload profile; +} diff --git a/common-events/src/main/java/events/user/data/UserPayload.java b/common-events/src/main/java/events/user/data/UserPayload.java new file mode 100644 index 00000000..a32d8919 --- /dev/null +++ b/common-events/src/main/java/events/user/data/UserPayload.java @@ -0,0 +1,27 @@ +package events.user.data; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +import java.time.Instant; +import java.util.Set; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UserPayload { + String userId; // id của identity -> dùng làm @Id trong profile + String username; + String email; + boolean active; // enabled của identity + + Set roles; // ["ADMIN","USER",...] + Instant createdAt; + Instant updatedAt; +} \ No newline at end of file diff --git a/common-events/src/main/java/events/user/data/UserProfileCreationPayload.java b/common-events/src/main/java/events/user/data/UserProfileCreationPayload.java new file mode 100644 index 00000000..abe7b131 --- /dev/null +++ b/common-events/src/main/java/events/user/data/UserProfileCreationPayload.java @@ -0,0 +1,27 @@ +package events.user.data; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +public class UserProfileCreationPayload { + String firstName; + String lastName; + Instant dob; + String bio; + Boolean gender; + String displayName; + Integer education; + String[] links; + String city; +} diff --git a/identity-service/pom.xml b/identity-service/pom.xml index 0994211a..9b0929c8 100644 --- a/identity-service/pom.xml +++ b/identity-service/pom.xml @@ -212,6 +212,12 @@ spring-security-test test + + + com.codecampus + common-events + ${project.version} + diff --git a/identity-service/src/main/java/com/codecampus/identity/configuration/config/init/ApplicationInitializationService.java b/identity-service/src/main/java/com/codecampus/identity/configuration/config/init/ApplicationInitializationService.java index 5a050b54..a19ed7df 100644 --- a/identity-service/src/main/java/com/codecampus/identity/configuration/config/init/ApplicationInitializationService.java +++ b/identity-service/src/main/java/com/codecampus/identity/configuration/config/init/ApplicationInitializationService.java @@ -1,13 +1,15 @@ package com.codecampus.identity.configuration.config.init; -import com.codecampus.identity.dto.request.profile.UserProfileCreationRequest; import com.codecampus.identity.entity.account.Role; import com.codecampus.identity.entity.account.User; +import com.codecampus.identity.mapper.kafka.UserPayloadMapper; import com.codecampus.identity.repository.account.PermissionRepository; import com.codecampus.identity.repository.account.RoleRepository; import com.codecampus.identity.repository.account.UserRepository; import com.codecampus.identity.repository.httpclient.profile.ProfileClient; +import com.codecampus.identity.service.kafka.UserEventProducer; import com.codecampus.identity.utils.ConvertUtils; +import events.user.data.UserProfileCreationPayload; import lombok.AccessLevel; import lombok.Builder; import lombok.RequiredArgsConstructor; @@ -32,11 +34,19 @@ public class ApplicationInitializationService { RoleRepository roleRepository; PermissionRepository permissionRepository; UserRepository userRepository; + PasswordEncoder passwordEncoder; ProfileClient profileClient; + UserPayloadMapper userPayloadMapper; + + UserEventProducer userEventProducer; + @Transactional - void createAdminUser(String username, String password, String email) { + void createAdminUser( + String username, + String password, + String email) { Role adminRole = checkRoleAndCreate(ADMIN_ROLE); Set roles = new HashSet<>(); @@ -50,9 +60,10 @@ void createAdminUser(String username, String password, String email) { .roles(roles) .build()); - profileClient.internalCreateUserProfile( - UserProfileCreationRequest.builder() - .userId(user.getId()) + userEventProducer.publishCreatedUserEvent(user); + + UserProfileCreationPayload payload = + UserProfileCreationPayload.builder() .firstName("Admin") .lastName("Sys") .dob(ConvertUtils.parseDdMmYyyyToInstant("28/03/2004")) @@ -63,8 +74,9 @@ void createAdminUser(String username, String password, String email) { .links(new String[] {"https://github.com/yunomix2834", "https://github.com/CapstoneProjectCMC/backend"}) .city("Vietnam") - .build() - ); + .build(); + + userEventProducer.publishRegisteredUserEvent(user, payload); } @Transactional @@ -82,9 +94,10 @@ void createUser(String username, String password, String email) { .roles(roles) .build()); - profileClient.internalCreateUserProfile( - UserProfileCreationRequest.builder() - .userId(user.getId()) + userEventProducer.publishCreatedUserEvent(user); + + UserProfileCreationPayload payload = + UserProfileCreationPayload.builder() .firstName("Code") .lastName("Campus") .dob(ConvertUtils.parseDdMmYyyyToInstant("28/03/2004")) @@ -95,8 +108,9 @@ void createUser(String username, String password, String email) { .links(new String[] {"https://github.com/yunomix2834", "https://github.com/CapstoneProjectCMC/backend"}) .city("Vietnam") - .build() - ); + .build(); + + userEventProducer.publishRegisteredUserEvent(user, payload); } Role checkRoleAndCreate(String roleName) { diff --git a/identity-service/src/main/java/com/codecampus/identity/configuration/kafka/KafkaProducerConfig.java b/identity-service/src/main/java/com/codecampus/identity/configuration/kafka/KafkaProducerConfig.java new file mode 100644 index 00000000..5bcb5ae4 --- /dev/null +++ b/identity-service/src/main/java/com/codecampus/identity/configuration/kafka/KafkaProducerConfig.java @@ -0,0 +1,45 @@ +package com.codecampus.identity.configuration.kafka; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableKafka +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class KafkaProducerConfig { + Environment env; + + @Bean + public ProducerFactory producerFactory() { + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, + env.getProperty("spring.kafka.bootstrap-servers", + "localhost:9092")); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + StringSerializer.class); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + return new DefaultKafkaProducerFactory<>(props); + } + + @Bean + public KafkaTemplate kafkaTemplate( + ProducerFactory factory) { + return new KafkaTemplate<>(factory); + } +} diff --git a/identity-service/src/main/java/com/codecampus/identity/controller/authentication/EmailChangeController.java b/identity-service/src/main/java/com/codecampus/identity/controller/authentication/EmailChangeController.java new file mode 100644 index 00000000..072de770 --- /dev/null +++ b/identity-service/src/main/java/com/codecampus/identity/controller/authentication/EmailChangeController.java @@ -0,0 +1,45 @@ +package com.codecampus.identity.controller.authentication; + +import com.codecampus.identity.dto.common.ApiResponse; +import com.codecampus.identity.dto.request.authentication.ChangeEmailRequest; +import com.codecampus.identity.dto.request.authentication.ChangeEmailVerifyRequest; +import com.codecampus.identity.service.authentication.EmailChangeService; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +@Builder +@Slf4j +@RequestMapping("/user/email") +public class EmailChangeController { + + EmailChangeService emailChangeService; + + @PostMapping("/change/request") + public ApiResponse request( + @RequestBody ChangeEmailRequest changeEmailRequest) { + emailChangeService.requestChangeEmail(changeEmailRequest); + return ApiResponse.builder() + .message("OTP đã gửi tới email mới") + .build(); + } + + @PostMapping("/change/verify") + public ApiResponse verify( + @RequestBody ChangeEmailVerifyRequest changeEmailVerifyRequest) { + emailChangeService.verifyOtp(changeEmailVerifyRequest); + emailChangeService.verifyOtp(changeEmailVerifyRequest); + return ApiResponse.builder() + .message("Đổi email thành công") + .build(); + } +} diff --git a/identity-service/src/main/java/com/codecampus/identity/controller/authentication/UserController.java b/identity-service/src/main/java/com/codecampus/identity/controller/authentication/UserController.java index c84c3338..baec5afc 100644 --- a/identity-service/src/main/java/com/codecampus/identity/controller/authentication/UserController.java +++ b/identity-service/src/main/java/com/codecampus/identity/controller/authentication/UserController.java @@ -33,10 +33,10 @@ public class UserController { @PreAuthorize("hasRole('ADMIN')") @PostMapping("/user") - ApiResponse createUser( + ApiResponse createUser( @RequestBody @Valid UserCreationRequest request) { - return ApiResponse.builder() - .result(userService.createUser(request)) + userService.createUser(request); + return ApiResponse.builder() .message("Create User successful") .build(); } @@ -92,20 +92,20 @@ ApiResponse deleteUser( @PreAuthorize("hasRole('ADMIN')") @PutMapping("/user/{userId}") - ApiResponse updateUser( + ApiResponse updateUser( @PathVariable("userId") String userId, @RequestBody UserUpdateRequest request) { - return ApiResponse.builder() - .result(userService.updateUser(userId, request)) + userService.updateUserById(userId, request); + return ApiResponse.builder() .message("Update User successful") .build(); } @PutMapping("/user/my-info") - ApiResponse updateMyInfo( + ApiResponse updateMyInfo( UserUpdateRequest request) { - return ApiResponse.builder() - .result(userService.updateMyInfo(request)) + userService.updateMyInfo(request); + return ApiResponse.builder() .message("Update My Info successful") .build(); } diff --git a/identity-service/src/main/java/com/codecampus/identity/dto/request/authentication/ChangeEmailRequest.java b/identity-service/src/main/java/com/codecampus/identity/dto/request/authentication/ChangeEmailRequest.java new file mode 100644 index 00000000..109d07cb --- /dev/null +++ b/identity-service/src/main/java/com/codecampus/identity/dto/request/authentication/ChangeEmailRequest.java @@ -0,0 +1,12 @@ +package com.codecampus.identity.dto.request.authentication; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChangeEmailRequest { + String newEmail; +} diff --git a/identity-service/src/main/java/com/codecampus/identity/dto/request/authentication/ChangeEmailVerifyRequest.java b/identity-service/src/main/java/com/codecampus/identity/dto/request/authentication/ChangeEmailVerifyRequest.java new file mode 100644 index 00000000..76ee485b --- /dev/null +++ b/identity-service/src/main/java/com/codecampus/identity/dto/request/authentication/ChangeEmailVerifyRequest.java @@ -0,0 +1,13 @@ +package com.codecampus.identity.dto.request.authentication; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChangeEmailVerifyRequest { + String newEmail; + String otpCode; +} diff --git a/identity-service/src/main/java/com/codecampus/identity/entity/account/User.java b/identity-service/src/main/java/com/codecampus/identity/entity/account/User.java index 125c0fff..27587600 100644 --- a/identity-service/src/main/java/com/codecampus/identity/entity/account/User.java +++ b/identity-service/src/main/java/com/codecampus/identity/entity/account/User.java @@ -63,7 +63,7 @@ public class User extends AuditMetadata { @PreRemove private void doSoftDelete() { - this.setDeletedBy(AuthenticationHelper.getMyEmail()); + this.setDeletedBy(AuthenticationHelper.getMyUsername()); this.setDeletedAt(Instant.now()); } } diff --git a/identity-service/src/main/java/com/codecampus/identity/mapper/authentication/UserMapper.java b/identity-service/src/main/java/com/codecampus/identity/mapper/authentication/UserMapper.java index 211291c0..70a16634 100644 --- a/identity-service/src/main/java/com/codecampus/identity/mapper/authentication/UserMapper.java +++ b/identity-service/src/main/java/com/codecampus/identity/mapper/authentication/UserMapper.java @@ -13,16 +13,17 @@ @Mapper(componentModel = "spring", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) public interface UserMapper { - User toUser(UserCreationRequest userCreationRequest); + User toUserFromUserCreationRequest(UserCreationRequest userCreationRequest); @Mapping(target = "userId", ignore = true) - UserProfileCreationRequest toUserProfileCreationRequest( - UserCreationRequest req); + UserProfileCreationRequest toUserProfileCreationRequestFromUserCreationRequest( + UserCreationRequest userCreationRequest); - UserResponse toUserResponse(User user); + UserResponse toUserResponseFromUser( + User user); @Mapping(target = "roles", ignore = true) - void updateUser( + void updateUserUpdateRequestToUser( @MappingTarget User user, UserUpdateRequest userUpdateRequest ); diff --git a/identity-service/src/main/java/com/codecampus/identity/mapper/kafka/UserPayloadMapper.java b/identity-service/src/main/java/com/codecampus/identity/mapper/kafka/UserPayloadMapper.java new file mode 100644 index 00000000..447c4c24 --- /dev/null +++ b/identity-service/src/main/java/com/codecampus/identity/mapper/kafka/UserPayloadMapper.java @@ -0,0 +1,34 @@ +package com.codecampus.identity.mapper.kafka; + +import com.codecampus.identity.dto.request.authentication.UserCreationRequest; +import com.codecampus.identity.entity.account.Role; +import com.codecampus.identity.entity.account.User; +import events.user.data.UserPayload; +import events.user.data.UserProfileCreationPayload; +import org.mapstruct.Mapper; + +import java.time.Instant; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring") +public interface UserPayloadMapper { + + UserProfileCreationPayload toUserProfileCreationPayloadFromUserCreationRequest( + UserCreationRequest userCreationRequest); + + default UserPayload toUserPayloadFromUser(User user) { + return UserPayload.builder() + .userId(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .active(user.isEnabled()) + .roles(user.getRoles() + .stream() + .map(Role::getName) + .collect(Collectors.toSet())) + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt() == null ? Instant.now() : + user.getUpdatedAt()) + .build(); + } +} diff --git a/identity-service/src/main/java/com/codecampus/identity/service/account/UserService.java b/identity-service/src/main/java/com/codecampus/identity/service/account/UserService.java index 9e975b3a..a980f84b 100644 --- a/identity-service/src/main/java/com/codecampus/identity/service/account/UserService.java +++ b/identity-service/src/main/java/com/codecampus/identity/service/account/UserService.java @@ -13,10 +13,13 @@ import com.codecampus.identity.helper.ProfileSyncHelper; import com.codecampus.identity.mapper.authentication.UserMapper; import com.codecampus.identity.mapper.client.UserProfileMapper; +import com.codecampus.identity.mapper.kafka.UserPayloadMapper; import com.codecampus.identity.repository.account.RoleRepository; import com.codecampus.identity.repository.account.UserRepository; import com.codecampus.identity.repository.httpclient.profile.ProfileClient; import com.codecampus.identity.service.authentication.OtpService; +import com.codecampus.identity.service.kafka.UserEventProducer; +import events.user.data.UserProfileCreationPayload; import lombok.AccessLevel; import lombok.Builder; import lombok.RequiredArgsConstructor; @@ -62,11 +65,13 @@ public class UserService { UserMapper userMapper; UserProfileMapper userProfileMapper; + UserPayloadMapper userPayloadMapper; PasswordEncoder passwordEncoder; ProfileClient profileClient; AuthenticationHelper authenticationHelper; ProfileSyncHelper profileSyncHelper; + UserEventProducer userEventProducer; /** * Tạo mới người dùng, gán vai trò USER và khởi tạo profile. @@ -79,20 +84,18 @@ public class UserService { *

* * @param request thông tin tạo người dùng mới - * @return thông tin người dùng vừa tạo (UserResponse) * @throws AppException nếu user đã tồn tại */ @PreAuthorize("hasRole('ADMIN')") @Transactional - public UserResponse createUser(UserCreationRequest request) { + public void createUser(UserCreationRequest request) { authenticationHelper.checkExistsUsernameEmail( request.getUsername(), request.getEmail() ); - User user = userMapper.toUser(request); + User user = userMapper.toUserFromUserCreationRequest(request); user.setPassword(passwordEncoder.encode(user.getPassword())); - HashSet roles = new HashSet<>(); roleRepository.findById(USER_ROLE) .ifPresent(roles::add); @@ -101,12 +104,15 @@ public UserResponse createUser(UserCreationRequest request) { try { user = userRepository.save(user); - profileSyncHelper.createProfile(user, request); + userEventProducer.publishCreatedUserEvent(user); + UserProfileCreationPayload profilePayload = + userPayloadMapper.toUserProfileCreationPayloadFromUserCreationRequest( + request); + userEventProducer.publishRegisteredUserEvent( + user, profilePayload); } catch (DataIntegrityViolationException e) { throw new AppException(ErrorCode.USER_ALREADY_EXISTS); } - - return userMapper.toUserResponse(user); } /** @@ -146,14 +152,13 @@ public UserResponse getMyInfo() { * * @param userId ID người dùng cần cập nhật * @param request thông tin cập nhật - * @return UserResponse chứa thông tin sau khi cập nhật */ @PreAuthorize("hasRole('ADMIN')") - public UserResponse updateUser( + public void updateUserById( String userId, UserUpdateRequest request) { User user = findUser(userId); - return updateUserAndReturnUserResponse(request, user); + updateUser(request, user); } /** @@ -162,24 +167,24 @@ public UserResponse updateUser( *

Chỉ cho phép khi username trả về khớp tên trong authentication.

* * @param request thông tin cập nhật - * @return UserResponse sau khi cập nhật */ - public UserResponse updateMyInfo( + public void updateMyInfo( UserUpdateRequest request) { User user = findUser(AuthenticationHelper.getMyUserId()); - return updateUserAndReturnUserResponse(request, user); + updateUser(request, user); } - private UserResponse updateUserAndReturnUserResponse( + private void updateUser( UserUpdateRequest request, User user) { - userMapper.updateUser(user, request); + userMapper.updateUserUpdateRequestToUser(user, request); user.setPassword(passwordEncoder.encode(user.getPassword())); // List roles = roleRepository.findAllById(request.getRoles()); // user.setRoles(new HashSet<>(roles)); + userRepository.save(user); - return userMapper.toUserResponse(userRepository.save(user)); + userEventProducer.publishUpdatedUserEvent(user); } /** @@ -196,8 +201,20 @@ public void deleteUser(String userId) { user.markDeleted(AuthenticationHelper.getMyEmail()); userRepository.save(user); - profileSyncHelper.softDeleteProfile(userId, - AuthenticationHelper.getMyUsername()); + userEventProducer.publishDeletedUserEvent(user); + } + + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public void restoreUser(String userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + if (user.getDeletedAt() != null) { + user.setDeletedAt(null); + user.setDeletedBy(null); + userRepository.save(user); + userEventProducer.publishRestoredUserEvent(user); + } } /** @@ -215,7 +232,7 @@ public PageResponse getUsers( Pageable pageable = PageRequest.of(page - 1, size); Page pageData = userRepository .findAll(pageable) - .map(userMapper::toUserResponse); + .map(userMapper::toUserResponseFromUser); return toPageResponse(pageData, page); } @@ -228,7 +245,7 @@ public PageResponse getUsers( * @throws AppException nếu không tìm thấy */ public UserResponse getUser(String userId) { - return userMapper.toUserResponse( + return userMapper.toUserResponseFromUser( userRepository.findById(userId) .orElseThrow(() -> new AppException( ErrorCode.USER_NOT_FOUND)) diff --git a/identity-service/src/main/java/com/codecampus/identity/service/authentication/AuthenticationService.java b/identity-service/src/main/java/com/codecampus/identity/service/authentication/AuthenticationService.java index 04b1f44c..700f381c 100644 --- a/identity-service/src/main/java/com/codecampus/identity/service/authentication/AuthenticationService.java +++ b/identity-service/src/main/java/com/codecampus/identity/service/authentication/AuthenticationService.java @@ -16,13 +16,14 @@ import com.codecampus.identity.exception.AppException; import com.codecampus.identity.exception.ErrorCode; import com.codecampus.identity.helper.AuthenticationHelper; -import com.codecampus.identity.helper.ProfileSyncHelper; import com.codecampus.identity.mapper.authentication.UserMapper; +import com.codecampus.identity.mapper.kafka.UserPayloadMapper; import com.codecampus.identity.repository.account.InvalidatedTokenRepository; import com.codecampus.identity.repository.account.RoleRepository; import com.codecampus.identity.repository.account.UserRepository; import com.codecampus.identity.repository.httpclient.google.OutboundGoogleIdentityClient; import com.codecampus.identity.repository.httpclient.google.OutboundGoogleUserClient; +import com.codecampus.identity.service.kafka.UserEventProducer; import com.codecampus.identity.utils.EmailUtils; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JOSEObjectType; @@ -33,6 +34,7 @@ import com.nimbusds.jose.crypto.MACVerifier; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; +import events.user.data.UserProfileCreationPayload; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; @@ -84,13 +86,14 @@ public class AuthenticationService { OtpService otpService; UserMapper userMapper; + UserPayloadMapper userPayloadMapper; PasswordEncoder passwordEncoder; OutboundGoogleIdentityClient outboundGoogleIdentityClient; OutboundGoogleUserClient outboundGoogleUserClient; AuthenticationHelper authenticationHelper; - ProfileSyncHelper profileSyncHelper; + UserEventProducer userEventProducer; @NonFinal @Value("${app.jwt.signerKey}") @@ -189,7 +192,12 @@ public AuthenticationResponse outboundGoogleLogin( .displayName(googleUser.getName()) .build(); - profileSyncHelper.createProfile(newUser, googleRequest); + userEventProducer.publishCreatedUserEvent(newUser); + UserProfileCreationPayload profilePayload = + userPayloadMapper.toUserProfileCreationPayloadFromUserCreationRequest( + googleRequest); + userEventProducer.publishRegisteredUserEvent(newUser, + profilePayload); return newUser; } ); @@ -265,18 +273,22 @@ public void register(UserCreationRequest request) { ); // Tạo user nhưng chưa kích hoạt - User user = userMapper.toUser(request); + User user = userMapper.toUserFromUserCreationRequest(request); user.setPassword(passwordEncoder.encode(user.getPassword())); user.setEnabled(false); // Chưa kích hoạt - - // Gán role mặc định - HashSet roles = new HashSet<>(); - roleRepository.findById(USER_ROLE).ifPresent(roles::add); - user.setRoles(roles); + user.setRoles(new HashSet<>()); + roleRepository.findById(USER_ROLE) + .ifPresent(r -> user.getRoles().add(r)); try { userRepository.save(user); - profileSyncHelper.createProfile(user, request); + // Sự kiện định danh + userEventProducer.publishCreatedUserEvent(user); + // Sự kiện khởi tạo profile giàu dữ liệu + var profilePayload = + userPayloadMapper.toUserProfileCreationPayloadFromUserCreationRequest( + request); + userEventProducer.publishRegisteredUserEvent(user, profilePayload); } catch (DataIntegrityViolationException e) { throw new AppException(ErrorCode.USER_ALREADY_EXISTS); } diff --git a/identity-service/src/main/java/com/codecampus/identity/service/authentication/EmailChangeService.java b/identity-service/src/main/java/com/codecampus/identity/service/authentication/EmailChangeService.java new file mode 100644 index 00000000..56c42ef5 --- /dev/null +++ b/identity-service/src/main/java/com/codecampus/identity/service/authentication/EmailChangeService.java @@ -0,0 +1,111 @@ +package com.codecampus.identity.service.authentication; + +import com.codecampus.identity.dto.request.authentication.ChangeEmailRequest; +import com.codecampus.identity.dto.request.authentication.ChangeEmailVerifyRequest; +import com.codecampus.identity.entity.account.OtpVerification; +import com.codecampus.identity.entity.account.User; +import com.codecampus.identity.exception.AppException; +import com.codecampus.identity.exception.ErrorCode; +import com.codecampus.identity.helper.AuthenticationHelper; +import com.codecampus.identity.repository.account.OtpVerificationRepository; +import com.codecampus.identity.repository.account.UserRepository; +import com.codecampus.identity.service.kafka.UserEventProducer; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.experimental.NonFinal; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Random; + +@Service +@RequiredArgsConstructor +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class EmailChangeService { + + OtpVerificationRepository otpVerificationRepository; + UserRepository userRepository; + UserEventProducer userEventProducer; + + JavaMailSender javaMailSender; + + @Value("${app.otp.expiry-minutes}") + @NonFinal + protected int otpExpiryMinutes; + + public void requestChangeEmail( + ChangeEmailRequest changeEmailRequest) { + // Không cho trùng email + if (userRepository.existsByEmail(changeEmailRequest.getNewEmail())) { + throw new AppException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + String otpCode = generateOtp(); + Instant expiryTime = Instant.now() + .plus(otpExpiryMinutes, ChronoUnit.MINUTES); + + OtpVerification otpVerification = otpVerificationRepository.findByEmail( + changeEmailRequest.getNewEmail()) + .map(o -> { + o.setOtpCode(otpCode); + o.setExpiryTime(expiryTime); + o.setVerified(false); + return o; + }) + .orElseGet(() -> OtpVerification.builder() + .email(changeEmailRequest.getNewEmail()) + .otpCode(otpCode) + .expiryTime(expiryTime) + .verified(false) + .build()); + otpVerificationRepository.save(otpVerification); + + sendEmail(changeEmailRequest.getNewEmail(), otpCode); + } + + public void verifyOtp(ChangeEmailVerifyRequest changeEmailVerifyRequest) { + OtpVerification otpVerification = otpVerificationRepository.findByEmail( + changeEmailVerifyRequest.getNewEmail()) + .orElseThrow(() -> new AppException(ErrorCode.EMAIL_NOT_FOUND)); + + if (!otpVerification.getOtpCode() + .equals(changeEmailVerifyRequest.getOtpCode())) { + throw new AppException(ErrorCode.INVALID_OTP); + } + + if (Instant.now().isAfter(otpVerification.getExpiryTime())) { + throw new AppException(ErrorCode.OTP_EXPIRED); + } + + // Cập nhật email cho user hiện tại + User user = userRepository.findById(AuthenticationHelper.getMyUserId()) + .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); + user.setEmail(changeEmailVerifyRequest.getNewEmail()); + userRepository.save(user); + userEventProducer.publishUpdatedUserEvent(user); + + otpVerification.setVerified(true); + otpVerificationRepository.save(otpVerification); + } + + private String generateOtp() { + return String.format("%06d", new Random().nextInt(999999)); + } + + private void sendEmail(String email, String otpCode) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(email); + message.setSubject("Xác minh đổi email"); + message.setText( + "Mã OTP để xác minh email mới: " + otpCode + "\nHiệu lực: " + + otpExpiryMinutes + " phút."); + javaMailSender.send(message); + } +} diff --git a/identity-service/src/main/java/com/codecampus/identity/service/authentication/OtpService.java b/identity-service/src/main/java/com/codecampus/identity/service/authentication/OtpService.java index 222b56df..c0dd15fb 100644 --- a/identity-service/src/main/java/com/codecampus/identity/service/authentication/OtpService.java +++ b/identity-service/src/main/java/com/codecampus/identity/service/authentication/OtpService.java @@ -9,6 +9,7 @@ import com.codecampus.identity.exception.ErrorCode; import com.codecampus.identity.repository.account.OtpVerificationRepository; import com.codecampus.identity.repository.account.UserRepository; +import com.codecampus.identity.service.kafka.UserEventProducer; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.experimental.FieldDefaults; @@ -41,6 +42,8 @@ public class OtpService { JavaMailSender mailSender; OtpVerificationRepository otpRepository; UserRepository userRepository; + + UserEventProducer userEventProducer; @Value("${app.otp.expiry-minutes}") @NonFinal @@ -139,6 +142,7 @@ public void verifyOtp(OtpVerificationRequest request) { user.setEnabled(true); userRepository.save(user); + userEventProducer.publishUpdatedUserEvent(user); otpRepository.save(otp); } diff --git a/identity-service/src/main/java/com/codecampus/identity/service/kafka/UserEventProducer.java b/identity-service/src/main/java/com/codecampus/identity/service/kafka/UserEventProducer.java new file mode 100644 index 00000000..f6d8d637 --- /dev/null +++ b/identity-service/src/main/java/com/codecampus/identity/service/kafka/UserEventProducer.java @@ -0,0 +1,102 @@ +package com.codecampus.identity.service.kafka; + +import com.codecampus.identity.entity.account.User; +import com.codecampus.identity.mapper.kafka.UserPayloadMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import events.user.UserEvent; +import events.user.UserRegisteredEvent; +import events.user.data.UserProfileCreationPayload; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.experimental.NonFinal; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class UserEventProducer { + + KafkaTemplate kafkaTemplate; + ObjectMapper objectMapper; + UserPayloadMapper userPayloadMapper; + + @NonFinal + @Value("${app.event.user-registrations}") + String USER_REGISTRATIONS_TOPIC; + @Value("${app.event.user-events}") + @NonFinal + String USER_EVENTS_TOPIC; + + public void publishCreatedUserEvent( + User user) { + publishEvent(UserEvent.Type.CREATED, user); + } + + public void publishUpdatedUserEvent(User user) { + publishEvent(UserEvent.Type.UPDATED, user); + } + + public void publishDeletedUserEvent(User user) { + publishEvent(UserEvent.Type.DELETED, user); + } + + public void publishRestoredUserEvent(User user) { + publishEvent(UserEvent.Type.RESTORED, user); + } + + void publishEvent( + UserEvent.Type type, + User user) { + UserEvent userEvent = UserEvent.builder() + .type(type) + .id(user.getId()) + .payload(type == UserEvent.Type.DELETED ? null + : userPayloadMapper.toUserPayloadFromUser( + user)) + .build(); + + sendEvent(USER_EVENTS_TOPIC, + user.getId(), + userEvent + ); + } + + public void publishRegisteredUserEvent( + User user, + UserProfileCreationPayload profilePayload) { + UserRegisteredEvent userRegisteredEvent = UserRegisteredEvent.builder() + .id(user.getId()) + .user(userPayloadMapper.toUserPayloadFromUser(user)) + .profile(profilePayload) + .build(); + + sendEvent(USER_REGISTRATIONS_TOPIC, + user.getId(), + userRegisteredEvent + ); + } + + private void sendEvent( + String topic, + String key, + Object event) { + try { + String jsonObject = objectMapper.writeValueAsString( + event); + + kafkaTemplate.send( + topic, + key, + jsonObject); + } catch (JsonProcessingException exception) { + log.error("[Kafka] Serialize thất bại", exception); + throw new RuntimeException(exception); + } + } +} diff --git a/identity-service/src/main/resources/application.yml b/identity-service/src/main/resources/application.yml index da5123ed..63087d98 100644 --- a/identity-service/src/main/resources/application.yml +++ b/identity-service/src/main/resources/application.yml @@ -44,6 +44,11 @@ spring: auth: true starttls: enable: true + kafka: + bootstrap-servers: localhost:9094 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer app: services: @@ -58,4 +63,7 @@ app: client-id: "839020123858-qnan968uvj0u9d5h6bq5cd5ulls9h7dk.apps.googleusercontent.com" client-secret: "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" redirect-uri: "http://localhost:4200/auth/identity/authenticate" + event: + user-events: "user-events" + user-registrations: "user-registrations" diff --git a/profile-service/pom.xml b/profile-service/pom.xml index 937ef10a..62cda186 100644 --- a/profile-service/pom.xml +++ b/profile-service/pom.xml @@ -178,6 +178,12 @@ spring-security-test test + + + com.codecampus + common-events + ${project.version} + diff --git a/profile-service/src/main/java/com/codecampus/profile/config/kafka/KafkaConsumerConfig.java b/profile-service/src/main/java/com/codecampus/profile/config/kafka/KafkaConsumerConfig.java new file mode 100644 index 00000000..34bb6dc7 --- /dev/null +++ b/profile-service/src/main/java/com/codecampus/profile/config/kafka/KafkaConsumerConfig.java @@ -0,0 +1,59 @@ +package com.codecampus.profile.config.kafka; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ContainerProperties; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableKafka +@RequiredArgsConstructor +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class KafkaConsumerConfig { + + Environment env; + + @Bean + public ConsumerFactory consumerFactory() { + + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + env.getProperty("spring.kafka.bootstrap-servers", + "localhost:9092")); + props.put(ConsumerConfig.GROUP_ID_CONFIG, + env.getProperty("spring.kafka.consumer.group-id", + "search-service")); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, + env.getProperty("spring.kafka.consumer.auto-offset-reset", + "earliest")); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + StringDeserializer.class); + + return new DefaultKafkaConsumerFactory<>(props); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.getContainerProperties() + .setAckMode(ContainerProperties.AckMode.BATCH); + factory.setBatchListener(false); + return factory; + } +} diff --git a/profile-service/src/main/java/com/codecampus/profile/controller/InternalUserProfileController.java b/profile-service/src/main/java/com/codecampus/profile/controller/InternalUserProfileController.java index 2d5567c6..848bd10d 100644 --- a/profile-service/src/main/java/com/codecampus/profile/controller/InternalUserProfileController.java +++ b/profile-service/src/main/java/com/codecampus/profile/controller/InternalUserProfileController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -24,10 +25,11 @@ @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @Builder @Slf4j +@RequestMapping("/internal") public class InternalUserProfileController { UserProfileService userProfileService; - @PostMapping("/internal/user") + @PostMapping("/user") ApiResponse internalCreateUserProfile( @RequestBody UserProfileCreationRequest request) { return ApiResponse.builder() @@ -35,7 +37,7 @@ ApiResponse internalCreateUserProfile( .build(); } - @GetMapping("/internal/user/{userId}") + @GetMapping("/user/{userId}") ApiResponse internalGetUserProfileByUserId( @PathVariable("userId") String userId) { return ApiResponse.builder() @@ -43,7 +45,7 @@ ApiResponse internalGetUserProfileByUserId( .build(); } - @PatchMapping("/internal/user/{userId}") + @PatchMapping("/user/{userId}") ApiResponse internalUpdateProfileByUserId( @PathVariable String userId, @RequestBody UserProfileUpdateRequest request) { @@ -52,7 +54,7 @@ ApiResponse internalUpdateProfileByUserId( .build(); } - @DeleteMapping("/internal/user/{userId}") + @DeleteMapping("/user/{userId}") ApiResponse internalSoftDeleteByUserId( @PathVariable String userId, @RequestParam(required = false) String deletedBy) { @@ -62,7 +64,7 @@ ApiResponse internalSoftDeleteByUserId( .build(); } - @PatchMapping("/internal/user/{userId}/restore") + @PatchMapping("/user/{userId}/restore") ApiResponse restoreByUserId( @PathVariable String userId) { userProfileService.restoreByUserId(userId); diff --git a/profile-service/src/main/java/com/codecampus/profile/dto/response/UserProfileResponse.java b/profile-service/src/main/java/com/codecampus/profile/dto/response/UserProfileResponse.java index 95ffc64a..86485f84 100644 --- a/profile-service/src/main/java/com/codecampus/profile/dto/response/UserProfileResponse.java +++ b/profile-service/src/main/java/com/codecampus/profile/dto/response/UserProfileResponse.java @@ -16,6 +16,11 @@ public class UserProfileResponse { String id; String userId; + + String username; + String email; + boolean active; + String firstName; String lastName; String dob; diff --git a/profile-service/src/main/java/com/codecampus/profile/entity/UserProfile.java b/profile-service/src/main/java/com/codecampus/profile/entity/UserProfile.java index af1ea454..ccbd6d12 100644 --- a/profile-service/src/main/java/com/codecampus/profile/entity/UserProfile.java +++ b/profile-service/src/main/java/com/codecampus/profile/entity/UserProfile.java @@ -23,11 +23,9 @@ import lombok.NoArgsConstructor; import lombok.Setter; import lombok.experimental.FieldDefaults; -import org.springframework.data.neo4j.core.schema.GeneratedValue; import org.springframework.data.neo4j.core.schema.Id; import org.springframework.data.neo4j.core.schema.Node; import org.springframework.data.neo4j.core.schema.Relationship; -import org.springframework.data.neo4j.core.support.UUIDStringGenerator; import java.time.Instant; import java.util.HashSet; @@ -42,11 +40,13 @@ @Node("User") public class UserProfile { @Id - @GeneratedValue(generatorClass = UUIDStringGenerator.class) - String id; - String userId; + String username; + String email; + @Builder.Default + Boolean active = true; + String firstName; String lastName; Instant dob; diff --git a/profile-service/src/main/java/com/codecampus/profile/mapper/UserProfileMapper.java b/profile-service/src/main/java/com/codecampus/profile/mapper/UserProfileMapper.java index 228455f3..ef6fa166 100644 --- a/profile-service/src/main/java/com/codecampus/profile/mapper/UserProfileMapper.java +++ b/profile-service/src/main/java/com/codecampus/profile/mapper/UserProfileMapper.java @@ -22,14 +22,16 @@ public interface UserProfileMapper { target = "dob", qualifiedByName = "DdMmYyyyToInstant" ) - UserProfile toUserProfile(UserProfileCreationRequest request); + UserProfile toUserProfileFromUserProfileCreationRequest( + UserProfileCreationRequest request); @Mapping( source = "dob", target = "dob", qualifiedByName = "instantToDdMmYyyyUTC" ) - UserProfileResponse toUserProfileResponse(UserProfile userProfile); + UserProfileResponse toUserProfileResponseFromUserProfile( + UserProfile userProfile); @BeanMapping(nullValuePropertyMappingStrategy = IGNORE) @Mapping( @@ -37,7 +39,7 @@ public interface UserProfileMapper { target = "dob", qualifiedByName = "DdMmYyyyToInstant" ) - void updateUserProfile( + void updateUserProfileUpdateRequestToUserProfile( @MappingTarget UserProfile userProfile, UserProfileUpdateRequest request ); diff --git a/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java b/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java index 0dd62bd1..63b881c7 100644 --- a/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java +++ b/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java @@ -30,225 +30,312 @@ public interface UserProfileRepository Optional findByUserId(String userId); @Query(""" - MATCH (u:User {userId:$userId}) - WHERE u.deletedAt IS NULL - RETURN u - LIMIT 1 + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + RETURN u + LIMIT 1 """) - Optional findActiveByUserId( - String userId); + Optional findActiveByUserId(String userId); boolean existsByUserId(String userId); - // Exercise + /* ========================= EXERCISE ========================= */ + @Query(value = """ - MATCH (u:User {userId:$userId})-[completed:COMPLETED_EXERCISE]->(e:Exercise) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[completed:COMPLETED_EXERCISE]->(e:Exercise) RETURN completed, e ORDER BY completed.completedAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[completed:COMPLETED_EXERCISE]->(:Exercise) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[completed:COMPLETED_EXERCISE]->(:Exercise) RETURN count(completed) """) Page findCompletedExercises( - String userId, Pageable pageable); - + String userId, + Pageable pageable); @Query(value = """ - MATCH (u:User {userId:$userId})-[saved:SAVED_EXERCISE]->(e:Exercise) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[saved:SAVED_EXERCISE]->(e:Exercise) RETURN saved, e ORDER BY saved.saveAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[saved:SAVED_EXERCISE]->(:Exercise) - RETURN count(saved) - """ - ) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[saved:SAVED_EXERCISE]->(:Exercise) + RETURN count(saved) + """) Page findSavedExercises( - String userId, Pageable pageable); + String userId, + Pageable pageable); @Query(value = """ - MATCH (u:User {userId:$userId})-[created:CREATED_EXERCISE]->(e:Exercise) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[created:CREATED_EXERCISE]->(e:Exercise) RETURN created, e ORDER BY created.id DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[created:CREATED_EXERCISE]->(:Exercise) - RETURN count(created) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[created:CREATED_EXERCISE]->(:Exercise) + RETURN count(created) """) Page findCreatedExercises( - String userId, Pageable pageable); + String userId, + Pageable pageable); + + /* ========================= CONTEST ========================= */ @Query(value = """ - MATCH (u:User {userId:$userId})-[cs:CONTEST_STATUS]->(c:Contest) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[cs:CONTEST_STATUS]->(c:Contest) RETURN cs, c ORDER BY cs.updatedAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[cs:CONTEST_STATUS]->(:Contest) - RETURN count(cs) - """ - ) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[cs:CONTEST_STATUS]->(:Contest) + RETURN count(cs) + """) Page findContestStatuses( - String userId, Pageable pageable); + String userId, + Pageable pageable); + + /* ========================= POST ========================= */ - // Post @Query(value = """ - MATCH (u:User {userId:$userId})-[sp:SAVED_POST]->(p:Post) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[sp:SAVED_POST]->(p:Post) RETURN sp, p ORDER BY sp.saveAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[sp:SAVED_POST]->(:Post) - RETURN count(sp) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[sp:SAVED_POST]->(:Post) + RETURN count(sp) """) Page findSavedPosts( - String userId, Pageable pageable); + String userId, + Pageable pageable); @Query(value = """ - MATCH (u:User {userId:$userId})-[r:REACTION]->(p:Post) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[r:REACTION]->(p:Post) RETURN r, p ORDER BY r.at DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[r:REACTION]->(:Post) - RETURN count(r) - """ - ) - Page findReactions(String userId, Pageable pageable); + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[r:REACTION]->(:Post) + RETURN count(r) + """) + Page findReactions( + String userId, + Pageable pageable); @Query(value = """ - MATCH (u:User {userId:$userId})-[rp:REPORTED_POST]->(p:Post) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[rp:REPORTED_POST]->(p:Post) RETURN rp, p ORDER BY rp.reportedAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[rp:REPORTED_POST]->(:Post) - RETURN count(rp) - """ - ) - Page findReportedPosts(String userId, Pageable pageable); + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[rp:REPORTED_POST]->(:Post) + RETURN count(rp) + """) + Page findReportedPosts( + String userId, + Pageable pageable); + + /* ========================= ACTIVITY TIME ========================= */ - // Activity Time @Query(value = """ - MATCH (u:User {userId:$userId})-[:HAS_ACTIVITY]->(a:ActivityWeek) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[:HAS_ACTIVITY]->(a:ActivityWeek) RETURN a ORDER BY a.weekStart DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[:HAS_ACTIVITY]->(a:ActivityWeek) - RETURN count(a) - """ - ) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[:HAS_ACTIVITY]->(a:ActivityWeek) + RETURN count(a) + """) Page findActivityWeek( - String userId, Pageable pageable); + String userId, + Pageable pageable); + + /* ========================= FOLLOW / BLOCK ========================= */ - // Follow / Block @Query(value = """ - MATCH (me:User {userId:$userId})-[f:FOLLOWS]->(target:User) + MATCH (me:User {userId:$userId}) + WHERE me.deletedAt IS NULL + MATCH (me)-[f:FOLLOWS]->(target:User) + WHERE target.deletedAt IS NULL RETURN f, target ORDER BY f.since DESC """, countQuery = """ - MATCH (me:User {userId:$userId})-[f:FOLLOWS]->(:User) - RETURN count(f) - """ - ) - Page findFollowings(String userId, Pageable pageable); + MATCH (me:User {userId:$userId}) + WHERE me.deletedAt IS NULL + MATCH (me)-[f:FOLLOWS]->(target:User) + WHERE target.deletedAt IS NULL + RETURN count(f) + """) + Page findFollowings( + String userId, + Pageable pageable); @Query(value = """ - MATCH (src:User)-[f:FOLLOWS]->(me:User {userId:$userId}) + MATCH (me:User {userId:$userId}) + WHERE me.deletedAt IS NULL + MATCH (src:User)-[f:FOLLOWS]->(me) + WHERE src.deletedAt IS NULL RETURN f, src ORDER BY f.since DESC """, countQuery = """ - MATCH (:User)-[f:FOLLOWS]->(me:User {userId:$userId}) - RETURN count(f) - """ - ) - Page findFollowers(String userId, Pageable pageable); + MATCH (me:User {userId:$userId}) + WHERE me.deletedAt IS NULL + MATCH (src:User)-[f:FOLLOWS]->(me) + WHERE src.deletedAt IS NULL + RETURN count(f) + """) + Page findFollowers( + String userId, + Pageable pageable); @Query(value = """ - MATCH (me:User {userId:$userId})-[b:BLOCKS]->(target:User) + MATCH (me:User {userId:$userId}) + WHERE me.deletedAt IS NULL + MATCH (me)-[b:BLOCKS]->(target:User) + WHERE target.deletedAt IS NULL RETURN b, target ORDER BY b.since DESC """, countQuery = """ - MATCH (me:User {userId:$userId})-[b:BLOCKS]->(:User) - RETURN count(b) - """ - ) - Page findBlocked(String userId, Pageable pageable); + MATCH (me:User {userId:$userId}) + WHERE me.deletedAt IS NULL + MATCH (me)-[b:BLOCKS]->(target:User) + WHERE target.deletedAt IS NULL + RETURN count(b) + """) + Page findBlocked( + String userId, + Pageable pageable); + + /* ========================= ORG ========================= */ - // Org @Query(value = """ - MATCH (u:User {userId:$userId})-[m:MEMBER_ORG]->(o:Organization) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[m:MEMBER_ORG]->(o:Organization) RETURN m, o ORDER BY m.joinAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[m:MEMBER_ORG]->(:Organization) - RETURN count(m) - """ - ) - Page findMemberOrgs(String userId, Pageable pageable); + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[m:MEMBER_ORG]->(:Organization) + RETURN count(m) + """) + Page findMemberOrgs( + String userId, + Pageable pageable); @Query(value = """ - MATCH (u:User {userId:$userId})-[m:MEMBER_ORG]->(o:Organization) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[m:MEMBER_ORG]->(o:Organization) WHERE m.memberRole = $role RETURN m, o ORDER BY m.joinAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[m:MEMBER_ORG]->(:Organization) - WHERE m.memberRole = $role - RETURN count(m) - """ - ) - Page findMemberOrgsByRole(String userId, String role, - Pageable p); + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[m:MEMBER_ORG]->(:Organization) + WHERE m.memberRole = $role + RETURN count(m) + """) + Page findMemberOrgsByRole( + String userId, + String role, + Pageable p); @Query(value = """ - MATCH (u:User {userId:$userId})-[c:CREATED_ORG]->(o:Organization) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[c:CREATED_ORG]->(o:Organization) RETURN c, o ORDER BY c.createdAt DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[c:CREATED_ORG]->(:Organization) - RETURN count(c) - """ - ) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[c:CREATED_ORG]->(:Organization) + RETURN count(c) + """) Page findCreatedOrgs( - String userId, Pageable pageable); + String userId, + Pageable pageable); + + /* ========================= PACKAGE ========================= */ - // Package @Query(value = """ - MATCH (u:User {userId:$userId})-[s:SUBSCRIBED_TO]->(p:Package) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[s:SUBSCRIBED_TO]->(p:Package) RETURN s, p ORDER BY s.start DESC """, countQuery = """ - MATCH (u:User {userId:$userId})-[s:SUBSCRIBED_TO]->(:Package) - RETURN count(s) - """ - ) + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[s:SUBSCRIBED_TO]->(:Package) + RETURN count(s) + """) Page findSubscriptions( - String userId, Pageable pageable); + String userId, + Pageable pageable); + + /* ========================= RESOURCE ========================= */ - // Resource - @Query( - value = """ - MATCH (u:User {userId:$userId})-[sr:SAVED_RESOURCE]->(f:FileResource) + @Query(value = """ + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[sr:SAVED_RESOURCE]->(f:FileResource) + WHERE f.type = $type + RETURN sr, f + ORDER BY sr.saveAt DESC + """, + countQuery = """ + MATCH (u:User {userId:$userId}) + WHERE u.deletedAt IS NULL + MATCH (u)-[sr:SAVED_RESOURCE]->(f:FileResource) WHERE f.type = $type - RETURN sr, f - ORDER BY sr.saveAt DESC - """, - countQuery = """ - MATCH (u:User {userId:$userId})-[sr:SAVED_RESOURCE]->(f:FileResource) - WHERE f.type = $type - RETURN count(sr) - """ - ) + RETURN count(sr) + """) Page findSavedResourcesByType( - String userId, String type, Pageable pageable); + String userId, + String type, + Pageable pageable); } diff --git a/profile-service/src/main/java/com/codecampus/profile/service/FamilyService.java b/profile-service/src/main/java/com/codecampus/profile/service/FamilyService.java index 040ec0f0..c274be0d 100644 --- a/profile-service/src/main/java/com/codecampus/profile/service/FamilyService.java +++ b/profile-service/src/main/java/com/codecampus/profile/service/FamilyService.java @@ -60,7 +60,8 @@ public void addChild(String parentId, String childId) { */ public void removeChild(String parentId, String childId) { UserProfile parent = userProfileService.getUserProfile(parentId); - parent.getChildren().removeIf(child -> childId.equals(child.getId())); + parent.getChildren() + .removeIf(child -> childId.equals(child.getUserId())); userProfileRepository.save(parent); } diff --git a/profile-service/src/main/java/com/codecampus/profile/service/UserProfileService.java b/profile-service/src/main/java/com/codecampus/profile/service/UserProfileService.java index 84bbed35..70b319d9 100644 --- a/profile-service/src/main/java/com/codecampus/profile/service/UserProfileService.java +++ b/profile-service/src/main/java/com/codecampus/profile/service/UserProfileService.java @@ -65,11 +65,14 @@ public UserProfileResponse createUserProfile( UserProfileCreationRequest request) { authenticationHelper.checkExistsUserid(request.getUserId()); - UserProfile userProfile = userProfileMapper.toUserProfile(request); + UserProfile userProfile = + userProfileMapper.toUserProfileFromUserProfileCreationRequest( + request); userProfile.setCreatedAt(Instant.now()); userProfile = userProfileRepository.save(userProfile); - return userProfileMapper.toUserProfileResponse(userProfile); + return userProfileMapper.toUserProfileResponseFromUserProfile( + userProfile); } /** @@ -82,7 +85,7 @@ public UserProfileResponse createUserProfile( public UserProfileResponse getUserProfileByUserId(String userId) { return userProfileRepository .findByUserId(userId) - .map(userProfileMapper::toUserProfileResponse) + .map(userProfileMapper::toUserProfileResponseFromUserProfile) .orElseThrow( () -> new AppException(ErrorCode.USER_NOT_FOUND) ); @@ -98,7 +101,7 @@ public UserProfileResponse getUserProfileByUserId(String userId) { public UserProfileResponse getUserProfileById(String id) { return userProfileRepository .findById(id) - .map(userProfileMapper::toUserProfileResponse) + .map(userProfileMapper::toUserProfileResponseFromUserProfile) .orElseThrow( () -> new AppException(ErrorCode.USER_NOT_FOUND) ); @@ -117,7 +120,7 @@ public PageResponse getAllUserProfiles(int page, Pageable pageable = PageRequest.of(page - 1, size); var pageData = userProfileRepository .findAll(pageable) - .map(userProfileMapper::toUserProfileResponse); + .map(userProfileMapper::toUserProfileResponseFromUserProfile); return toPageResponse(pageData, page); } @@ -177,8 +180,9 @@ public void updateMyUserProfile( UserProfile profile = getUserProfile(); - userProfileMapper.updateUserProfile(profile, request); - userProfileMapper.toUserProfileResponse( + userProfileMapper.updateUserProfileUpdateRequestToUserProfile(profile, + request); + userProfileMapper.toUserProfileResponseFromUserProfile( userProfileRepository.save(profile) ); } @@ -189,7 +193,8 @@ public void updateUserProfileById( UserProfile profile = userProfileRepository .findActiveByUserId(userId) .orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND)); - userProfileMapper.updateUserProfile(profile, request); + userProfileMapper.updateUserProfileUpdateRequestToUserProfile(profile, + request); userProfileRepository.save(profile); } diff --git a/profile-service/src/main/java/com/codecampus/profile/service/kafka/UserEventListener.java b/profile-service/src/main/java/com/codecampus/profile/service/kafka/UserEventListener.java new file mode 100644 index 00000000..f29f7e68 --- /dev/null +++ b/profile-service/src/main/java/com/codecampus/profile/service/kafka/UserEventListener.java @@ -0,0 +1,77 @@ +package com.codecampus.profile.service.kafka; + +import com.codecampus.profile.entity.UserProfile; +import com.codecampus.profile.repository.UserProfileRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import events.user.UserEvent; +import events.user.data.UserPayload; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class UserEventListener { + + UserProfileRepository userProfileRepository; + ObjectMapper objectMapper; + + @KafkaListener(topics = "${app.event.user-events}", + groupId = "profile-service" + ) + public void onMessageUser(String raw) { + try { + UserEvent userEvent = objectMapper.readValue( + raw, + UserEvent.class); + + switch (userEvent.getType()) { + case CREATED, UPDATED, RESTORED -> + upsert(userEvent.getPayload()); + case DELETED -> softDelete(userEvent.getId()); + } + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + void upsert(UserPayload userPayload) { + Optional userProfile = + userProfileRepository.findByUserId( + userPayload.getUserId()); + + UserProfile profile = userProfile.orElseGet(() -> UserProfile.builder() + .userId(userPayload.getUserId()) + .createdAt(Instant.now()) + .build()); + + profile.setUsername(userPayload.getUsername()); + profile.setEmail(userPayload.getEmail()); + profile.setActive(userPayload.isActive()); + if (profile.getDeletedAt() != null && userPayload.isActive()) { + // nếu muốn auto-restore khi nhận RESTORED/UPDATED active=true + profile.setDeletedAt(null); + profile.setDeletedBy(null); + } + userProfileRepository.save(profile); + } + + private void softDelete(String userId) { + userProfileRepository.findByUserId(userId).ifPresent(p -> { + if (p.getDeletedAt() == null) { + p.setDeletedAt(Instant.now()); + p.setDeletedBy("identity-service"); + userProfileRepository.save(p); + } + }); + } +} diff --git a/profile-service/src/main/java/com/codecampus/profile/service/kafka/UserRegistrationListener.java b/profile-service/src/main/java/com/codecampus/profile/service/kafka/UserRegistrationListener.java new file mode 100644 index 00000000..2a4bc89c --- /dev/null +++ b/profile-service/src/main/java/com/codecampus/profile/service/kafka/UserRegistrationListener.java @@ -0,0 +1,78 @@ +package com.codecampus.profile.service.kafka; + +import com.codecampus.profile.entity.UserProfile; +import com.codecampus.profile.repository.UserProfileRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import events.user.UserRegisteredEvent; +import events.user.data.UserPayload; +import events.user.data.UserProfileCreationPayload; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) +public class UserRegistrationListener { + + UserProfileRepository userProfileRepository; + ObjectMapper objectMapper; + + @KafkaListener( + topics = "${app.event.user-registrations}", + groupId = "profile-service" + ) + public void onRegistrationUser(String raw) { + try { + UserRegisteredEvent event = + objectMapper.readValue(raw, UserRegisteredEvent.class); + upsertFromRegistration(event); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + void upsertFromRegistration(UserRegisteredEvent event) { + UserPayload u = event.getUser(); + UserProfileCreationPayload p = event.getProfile(); + + UserProfile profile = userProfileRepository.findByUserId(u.getUserId()) + .orElseGet(() -> UserProfile.builder() + .userId(u.getUserId()) + .createdAt(u.getCreatedAt() != null ? u.getCreatedAt() : + Instant.now()) + .build()); + + // fields từ UserPayload + profile.setUsername(u.getUsername()); + profile.setEmail(u.getEmail()); + profile.setActive(u.isActive()); + + // fields profile chi tiết + profile.setFirstName(p.getFirstName()); + profile.setLastName(p.getLastName()); + profile.setDob(p.getDob()); + profile.setBio(p.getBio()); + profile.setGender(p.getGender()); + profile.setDisplayName(p.getDisplayName()); + profile.setEducation(p.getEducation()); + profile.setLinks(p.getLinks()); + profile.setCity(p.getCity()); + + // nếu đang bị soft-delete mà nhận được registration (enabled=true) -> auto-restore + if (profile.getDeletedAt() != null && + Boolean.TRUE.equals(profile.getActive())) { + profile.setDeletedAt(null); + profile.setDeletedBy(null); + } + + userProfileRepository.save(profile); + } +} diff --git a/profile-service/src/main/resources/application-docker.yml b/profile-service/src/main/resources/application-docker.yml index 22999e6a..fbd676bc 100644 --- a/profile-service/src/main/resources/application-docker.yml +++ b/profile-service/src/main/resources/application-docker.yml @@ -9,4 +9,10 @@ spring: authentication: username: ${NEO4J_USERNAME} password: ${NEO4J_PASSWORD} + kafka: + bootstrap-servers: kafka:9092 + +app: + services: + file: http://file-service:8082/file diff --git a/profile-service/src/main/resources/application.yml b/profile-service/src/main/resources/application.yml index def2e05e..f06c74c7 100644 --- a/profile-service/src/main/resources/application.yml +++ b/profile-service/src/main/resources/application.yml @@ -21,8 +21,18 @@ spring: username: neo4j password: dinhanst2832004 schema-action: create + kafka: + bootstrap-servers: localhost:9094 + consumer: + group-id: profile-service + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + properties: + spring.json.trusted.packages: "*" app: services: file: http://localhost:8082/file - + event: + user-events: "user-events" diff --git a/search-service/src/main/java/com/codecampus/search/service/ExerciseIndexer.java b/search-service/src/main/java/com/codecampus/search/service/kafka/ExerciseEventListener.java similarity index 50% rename from search-service/src/main/java/com/codecampus/search/service/ExerciseIndexer.java rename to search-service/src/main/java/com/codecampus/search/service/kafka/ExerciseEventListener.java index e05364ff..92198292 100644 --- a/search-service/src/main/java/com/codecampus/search/service/ExerciseIndexer.java +++ b/search-service/src/main/java/com/codecampus/search/service/kafka/ExerciseEventListener.java @@ -1,7 +1,8 @@ -package com.codecampus.search.service; +package com.codecampus.search.service.kafka; import com.codecampus.search.mapper.ExerciseMapper; import com.codecampus.search.repository.ExerciseDocumentRepository; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import events.exercise.ExerciseEvent; import lombok.AccessLevel; @@ -11,13 +12,11 @@ import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Service; -import java.io.IOException; - @Service @RequiredArgsConstructor @Slf4j @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) -public class ExerciseIndexer { +public class ExerciseEventListener { ExerciseDocumentRepository exerciseDocumentRepository; @@ -27,15 +26,19 @@ public class ExerciseIndexer { @KafkaListener( topics = "exercise-events", groupId = "search-service") - public void onMessageExercise(String raw) throws IOException { - ExerciseEvent exerciseEvent = - objectMapper.readValue(raw, ExerciseEvent.class); - switch (exerciseEvent.getType()) { - case CREATED, UPDATED -> exerciseDocumentRepository.save( - exerciseMapper.toExerciseDocumentFromExercisePayload( - exerciseEvent.getPayload())); - case DELETED -> exerciseDocumentRepository.deleteById( - exerciseEvent.getId()); + public void onMessageExercise(String raw) { + try { + ExerciseEvent exerciseEvent = + objectMapper.readValue(raw, ExerciseEvent.class); + switch (exerciseEvent.getType()) { + case CREATED, UPDATED -> exerciseDocumentRepository.save( + exerciseMapper.toExerciseDocumentFromExercisePayload( + exerciseEvent.getPayload())); + case DELETED -> exerciseDocumentRepository.deleteById( + exerciseEvent.getId()); + } + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } } } diff --git a/submission-service/src/main/java/com/codecampus/submission/service/kafka/ExerciseEventProducer.java b/submission-service/src/main/java/com/codecampus/submission/service/kafka/ExerciseEventProducer.java index e781c7b6..f61ca8d6 100644 --- a/submission-service/src/main/java/com/codecampus/submission/service/kafka/ExerciseEventProducer.java +++ b/submission-service/src/main/java/com/codecampus/submission/service/kafka/ExerciseEventProducer.java @@ -22,7 +22,7 @@ public class ExerciseEventProducer { @Value("${app.event.exercise-events}") @NonFinal - static String EXERCISE_EVENTS_TOPIC = "exercise-events"; + static String EXERCISE_EVENTS_TOPIC; KafkaTemplate kafkaTemplate; ObjectMapper objectMapper;