From 9b24ed04a1e03e3c70e080ce4f961cf79f1fd445 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 25 May 2026 16:06:44 +0800 Subject: [PATCH 1/7] Add RegexUtility for email and phone number validation; enhance SpringUtil with static method to retrieve application context --- .../io/theurl/framework/utility/RegexUtility.java | 11 +++++++++++ .../java/io/theurl/framework/utility/SpringUtil.java | 7 +++++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 framework/src/main/java/io/theurl/framework/utility/RegexUtility.java diff --git a/framework/src/main/java/io/theurl/framework/utility/RegexUtility.java b/framework/src/main/java/io/theurl/framework/utility/RegexUtility.java new file mode 100644 index 0000000..a0a397b --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/utility/RegexUtility.java @@ -0,0 +1,11 @@ +package io.theurl.framework.utility; + +public class RegexUtility { + public static boolean isEmail(String email) { + return email != null && email.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + } + + public static boolean isPhone(String phone) { + return phone != null && phone.matches("^\\+?[0-9]{7,15}$"); + } +} diff --git a/framework/src/main/java/io/theurl/framework/utility/SpringUtil.java b/framework/src/main/java/io/theurl/framework/utility/SpringUtil.java index a1afca1..8090d03 100644 --- a/framework/src/main/java/io/theurl/framework/utility/SpringUtil.java +++ b/framework/src/main/java/io/theurl/framework/utility/SpringUtil.java @@ -1,6 +1,5 @@ package io.theurl.framework.utility; -import lombok.Getter; import org.jspecify.annotations.NonNull; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; @@ -10,7 +9,7 @@ @SuppressWarnings("unused") @Component public class SpringUtil implements ApplicationContextAware { - @Getter + private static ApplicationContext applicationContext; @Override @@ -20,6 +19,10 @@ public void setApplicationContext(@NonNull ApplicationContext context) throws Be } } + public static ApplicationContext getApplicationContext() { + return applicationContext; + } + public static T getBean(Class clazz) { return applicationContext.getBean(clazz); } From 2a6471ae91c0423b97f55a0eb1eab37095cdb95e Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 25 May 2026 22:01:47 +0800 Subject: [PATCH 2/7] Enhance event handling in AggregateRoot and DomainEvent; add applyEvent method and update attach method for better event management --- .../java/io/theurl/framework/domain/AggregateRoot.java | 8 +++++++- .../main/java/io/theurl/framework/domain/DomainEvent.java | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java index a0da441..63d9fdb 100644 --- a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java +++ b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; public class AggregateRoot> extends Entity implements IAggregateRoot, IHasDomainEvents { @@ -29,7 +30,8 @@ public void clearEvents() { @Override public void raiseEvent(E event) { - + event.attach(this); + this.events.add(event); } @Override @@ -37,6 +39,10 @@ public void applyEvent(E event) { } + public void applyEvent(Consumer handler, E event) { + handler.accept(event); + } + @Override public void attachEvent() { diff --git a/framework/src/main/java/io/theurl/framework/domain/DomainEvent.java b/framework/src/main/java/io/theurl/framework/domain/DomainEvent.java index 71737b4..e3eca5b 100644 --- a/framework/src/main/java/io/theurl/framework/domain/DomainEvent.java +++ b/framework/src/main/java/io/theurl/framework/domain/DomainEvent.java @@ -36,7 +36,6 @@ public EventAggregate getEventAggregate() { return aggregate; } - public V getAggregate(Class targetType) { var aggregate = getAggregatePayload(); if (aggregate == null) { @@ -47,4 +46,11 @@ public V getAggregate(Class targetType) { } return (V) TypeHelper.coerceValue(targetType, aggregate.getClass(), aggregate); } + + @Override + public > void attach(IAggregateRoot aggregateRoot) { + setOriginatorId(aggregateRoot.getId().toString()); + setOriginatorType(aggregateRoot.getClass().getName()); + setAggregatePayload(aggregateRoot); + } } From 0665fa760022b2b7394f972d31ab58736740fc50 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 25 May 2026 23:33:30 +0800 Subject: [PATCH 3/7] Add User aggregate with event handling for email and phone changes; implement UserRole and UserAuthority entities --- .../identity/domain/aggregate/User.java | 195 ++++++++++++++++++ .../domain/aggregate/UserAuthority.java | 33 +++ .../identity/domain/aggregate/UserRole.java | 17 ++ .../domain/event/UserCreatedEvent.java | 13 ++ .../domain/event/UserEmailChangedEvent.java | 13 ++ .../domain/event/UserLockedEvent.java | 14 ++ .../event/UserPasswordChangedEvent.java | 15 ++ .../domain/event/UserPhoneChangedEvent.java | 13 ++ .../domain/event/UserUnlockedEvent.java | 20 ++ 9 files changed, 333 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/domain/aggregate/UserAuthority.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/aggregate/UserRole.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/event/UserCreatedEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/event/UserEmailChangedEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/event/UserLockedEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/event/UserPasswordChangedEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/event/UserPhoneChangedEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/event/UserUnlockedEvent.java diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/User.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/User.java index 4d44339..4484ff3 100644 --- a/identity/src/main/java/io/theurl/identity/domain/aggregate/User.java +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/User.java @@ -1,8 +1,30 @@ package io.theurl.identity.domain.aggregate; import io.theurl.framework.domain.AggregateRoot; +import io.theurl.framework.utility.RegexUtility; +import io.theurl.framework.utility.SnowflakeId; +import io.theurl.identity.domain.event.*; +import lombok.Getter; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.HashSet; +import java.util.Locale; +import java.util.Objects; + +@SuppressWarnings("LombokSetterMayBeUsed") +@Getter public class User extends AggregateRoot { + private String username; + private String nickname; + private String email; + private String phone; + private String password; + private Integer accessFailedCount; + private LocalDateTime lockedUntil; + private Collection roles = new HashSet<>(); + private Collection authorities = new HashSet<>(); + /** * Initializes the aggregate with the given id. * @@ -10,5 +32,178 @@ public class User extends AggregateRoot { */ public User(Long id) { super(id); + registerEvent(UserPhoneChangedEvent.class, event -> this.phone = event.getNewPhone()); + registerEvent(UserEmailChangedEvent.class, event -> this.email = event.getNewEmail()); + } + + public static User create(String username) { + var id = SnowflakeId.getInstance().nextId(); + var user = new User(id); + user.setUsername(username); + user.raiseEvent(new UserCreatedEvent(username)); + return user; + } + + public void setUsername(String username) { + this.username = username.toLowerCase(Locale.ROOT); + } + + public void setNickname(String nickname) { + if (nickname == null || nickname.isEmpty()) { + throw new IllegalArgumentException("nickname cannot be null or empty"); + } + + if (Objects.equals(this.nickname, nickname)) { + return; + } + + this.nickname = nickname; + } + + /** + * Sets the email for the user. + * + * @param email the email to set + */ + public void setEmail(String email) { + if (email == null || email.isEmpty()) { + throw new IllegalArgumentException("email cannot be null or empty"); + } + + if (Objects.equals(this.email, email)) { + return; + } + + email = email.trim().toLowerCase(Locale.ROOT); + if (RegexUtility.isEmail(email)) { + raiseEvent(new UserEmailChangedEvent(this.getId(), this.email, email)); + } else { + throw new IllegalArgumentException("email is not valid"); + } + } + + /** + * Sets the phone number for the user. + * + * @param phone the phone number to set + */ + public void setPhone(String phone) { + if (phone == null || phone.isEmpty()) { + throw new IllegalArgumentException("phone cannot be null or empty"); + } + + if (Objects.equals(this.phone, phone)) { + return; + } + + phone = phone.trim().toLowerCase(Locale.ROOT); + if (RegexUtility.isPhone(phone)) { + raiseEvent(new UserPhoneChangedEvent(this.getId(), this.phone, phone)); + } else { + throw new IllegalArgumentException("phone is not valid"); + } + } + + /** + * Sets the password for the user. + * + * @param password the password to set + * @param changeType the type of change being made + */ + public void setPassword(String password, String changeType) { + if (password == null || password.isEmpty()) { + throw new IllegalArgumentException("password cannot be null or empty"); + } + + this.password = password; + + if (!changeType.equals("init")) { + raiseEvent(new UserPasswordChangedEvent(this.getId(), LocalDateTime.now(), changeType)); + } + } + + public void setAccessFailedCount(Integer accessFailedCount) { + this.accessFailedCount = accessFailedCount; + } + + public void setLockedUntil(LocalDateTime lockedUntil) { + this.lockedUntil = lockedUntil; + } + + public void increaseAccessFailedCount() { + this.accessFailedCount += 1; + if (this.accessFailedCount >= 10) { + var minutes = 15 * (this.accessFailedCount - 9); + if (minutes > 3600 * 24) { + minutes = 3600 * 24; + } + this.lockedUntil = LocalDateTime.now().plusMinutes(minutes); + raiseEvent(new UserLockedEvent(this.getId(), this.lockedUntil)); + } + } + + public void resetAccessFailedCount() { + if (accessFailedCount == 0) { + return; + } + var previousValue = this.accessFailedCount; + + this.accessFailedCount = 0; + this.lockedUntil = null; + + if (previousValue > 0) { + raiseEvent(new UserUnlockedEvent(this.getId(), this.lockedUntil)); + } + + } + + /** + * Sets the roles for the user. + * NOTES: this method is only defined for persistence purpose, it will override the existing roles with the given collection. + * + * @param roles the collection of roles to set + */ + public void setRoles(Collection roles) { + this.roles = roles; + } + + public void addRole(String name) { + if (roles.stream().anyMatch(r -> r.getName().equals(name))) { + throw new IllegalArgumentException("role already exists"); + } + + var role = new UserRole(SnowflakeId.getInstance().nextId(), name); + this.roles.add(role); + } + + public void removeRole(String name) { + if (roles.stream().anyMatch(r -> r.getName().equals(name))) { + roles.removeIf(r -> r.getName().equals(name)); + } else { + throw new IllegalArgumentException("role does not exist"); + } } + + public void addAuthorities(Collection authorities) { + this.authorities = authorities; + } + + public void addAuthority(String provider, String openId, String name) { + if (authorities.stream().anyMatch(r -> r.getProvider().equals(provider) && r.getOpenId().equals(openId))) { + throw new IllegalArgumentException("authority already exists"); + } + + var authority = new UserAuthority(SnowflakeId.getInstance().nextId(), provider, openId); + authority.setName(name); + this.authorities.add(authority); + } + + public void removeAuthority(String provider, String openId) { + if (authorities.stream().anyMatch(r -> r.getProvider().equals(provider) && r.getOpenId().equals(openId))) { + authorities.removeIf(r -> r.getProvider().equals(provider) && r.getOpenId().equals(openId)); + } else { + throw new IllegalArgumentException("authority does not exist"); + } + } + } diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/UserAuthority.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/UserAuthority.java new file mode 100644 index 0000000..a9a3a8e --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/UserAuthority.java @@ -0,0 +1,33 @@ +package io.theurl.identity.domain.aggregate; + +import io.theurl.framework.domain.Entity; + +@SuppressWarnings({"LombokSetterMayBeUsed", "LombokGetterMayBeUsed"}) +public class UserAuthority extends Entity { + + private final String provider; + private final String openId; + private String name; + + public UserAuthority(Long id, String provider, String openId) { + super(id); + this.provider = provider; + this.openId = openId; + } + + public String getProvider() { + return provider; + } + + public String getOpenId() { + return openId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/UserRole.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/UserRole.java new file mode 100644 index 0000000..ee9f800 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/UserRole.java @@ -0,0 +1,17 @@ +package io.theurl.identity.domain.aggregate; + +import io.theurl.framework.domain.Entity; + +@SuppressWarnings({"LombokGetterMayBeUsed"}) +public class UserRole extends Entity { + private final String name; + + public UserRole(Long id, String name) { + super(id); + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/identity/src/main/java/io/theurl/identity/domain/event/UserCreatedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/UserCreatedEvent.java new file mode 100644 index 0000000..f41e22f --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/UserCreatedEvent.java @@ -0,0 +1,13 @@ +package io.theurl.identity.domain.event; + +import io.theurl.framework.domain.DomainEvent; +import lombok.Getter; + +@Getter +public class UserCreatedEvent extends DomainEvent { + private final String username; + + public UserCreatedEvent(String username) { + this.username = username; + } +} diff --git a/identity/src/main/java/io/theurl/identity/domain/event/UserEmailChangedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/UserEmailChangedEvent.java new file mode 100644 index 0000000..ed9a373 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/UserEmailChangedEvent.java @@ -0,0 +1,13 @@ +package io.theurl.identity.domain.event; + +import io.theurl.framework.domain.DomainEvent; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public final class UserEmailChangedEvent extends DomainEvent { + private final Long id; + private final String oldEmail; + private final String newEmail; +} diff --git a/identity/src/main/java/io/theurl/identity/domain/event/UserLockedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/UserLockedEvent.java new file mode 100644 index 0000000..599b2e4 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/UserLockedEvent.java @@ -0,0 +1,14 @@ +package io.theurl.identity.domain.event; + +import io.theurl.framework.domain.DomainEvent; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public final class UserLockedEvent extends DomainEvent { + private final Long id; + private final LocalDateTime lockedUntil; +} diff --git a/identity/src/main/java/io/theurl/identity/domain/event/UserPasswordChangedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/UserPasswordChangedEvent.java new file mode 100644 index 0000000..5630394 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/UserPasswordChangedEvent.java @@ -0,0 +1,15 @@ +package io.theurl.identity.domain.event; + +import io.theurl.framework.domain.DomainEvent; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public final class UserPasswordChangedEvent extends DomainEvent { + private final Long id; + private final LocalDateTime changeTime; + private final String changeType; +} diff --git a/identity/src/main/java/io/theurl/identity/domain/event/UserPhoneChangedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/UserPhoneChangedEvent.java new file mode 100644 index 0000000..142d616 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/UserPhoneChangedEvent.java @@ -0,0 +1,13 @@ +package io.theurl.identity.domain.event; + +import io.theurl.framework.domain.DomainEvent; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public final class UserPhoneChangedEvent extends DomainEvent { + private final Long id; + private final String oldPhone; + private final String newPhone; +} diff --git a/identity/src/main/java/io/theurl/identity/domain/event/UserUnlockedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/UserUnlockedEvent.java new file mode 100644 index 0000000..85dbe55 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/UserUnlockedEvent.java @@ -0,0 +1,20 @@ +package io.theurl.identity.domain.event; + +import io.theurl.framework.domain.DomainEvent; +import io.theurl.framework.domain.IAggregateRoot; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class UserUnlockedEvent extends DomainEvent { + private final Long id; + private final LocalDateTime unlockTime; + + @Override + public > void attach(IAggregateRoot aggregateRoot) { + + } +} From 607f88fc68bd1942c581430a6e7f97f3ce4cd21e Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 25 May 2026 23:33:46 +0800 Subject: [PATCH 4/7] Add command handlers for user creation and password change; implement asynchronous processing and validation for user data --- .../command/UserCreateCommand.java | 11 ++++- .../command/UserPasswordChangeCommand.java | 13 +++++ .../UserAccessFailureCountCommandHandler.java | 13 ++++- .../handler/UserCreateCommandHandler.java | 47 +++++++++++++++++++ .../UserPasswordChangeCommandHandler.java | 34 ++++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java index a296625..ea6b9aa 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java @@ -1,4 +1,13 @@ package io.theurl.identity.application.command; -public class UserCreateCommand { +import com.neroyun.mediator.Command; +import lombok.Data; + +@Data +public class UserCreateCommand implements Command { + private String username; + private String password; + private String nickname; + private String email; + private String phone; } diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java new file mode 100644 index 0000000..7c96255 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java @@ -0,0 +1,13 @@ +package io.theurl.identity.application.command; + +import com.neroyun.mediator.Command; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserPasswordChangeCommand implements Command { + private final Long userId; + private final String password; + private final String changeType; +} diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java index 238dc43..0e13df2 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java @@ -22,6 +22,17 @@ public UserAccessFailureCountCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAccessFailureCountCommand message) { - return null; + var user = repository.findById(message.getUserId()); + if (user == null) { + return CompletableFuture.completedFuture(null); + } + + switch (message.getAction()) { + case "increase" -> user.increaseAccessFailedCount(); + case "reset" -> user.resetAccessFailedCount(); + } + + repository.save(user); + return CompletableFuture.completedFuture(null); } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java new file mode 100644 index 0000000..2d1f0a6 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java @@ -0,0 +1,47 @@ +package io.theurl.identity.application.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.identity.application.command.UserCreateCommand; +import io.theurl.identity.domain.aggregate.User; +import io.theurl.identity.domain.repository.UserRepository; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +public class UserCreateCommandHandler implements Handler { + private final UserRepository repository; + + public UserCreateCommandHandler(UserRepository repository) { + this.repository = repository; + } + + @Async + @Transactional + @Override + public CompletableFuture handleAsync(UserCreateCommand message) { + var exists = repository.findByAnyOf(message.getUsername(), message.getEmail(), message.getPhone()); + + if (exists != null) { + throw new IllegalArgumentException("User with the same username, email or phone already exists."); + } + + var user = User.create(message.getUsername()); + user.setPassword(message.getPassword(), "init"); + user.setNickname(message.getNickname()); + if (message.getEmail() != null) { + user.setEmail(message.getEmail()); + } + if (message.getPhone() != null) { + user.setPhone(message.getPhone()); + } + repository.save(user); + return CompletableFuture.completedFuture(null); + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java new file mode 100644 index 0000000..f99cd54 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java @@ -0,0 +1,34 @@ +package io.theurl.identity.application.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.identity.application.command.UserPasswordChangeCommand; +import io.theurl.identity.domain.repository.UserRepository; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.stereotype.Component; +import org.springframework.web.context.WebApplicationContext; + +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +public class UserPasswordChangeCommandHandler implements Handler { + + private final UserRepository repository; + + public UserPasswordChangeCommandHandler(UserRepository repository) { + this.repository = repository; + } + + @Override + public CompletableFuture handleAsync(UserPasswordChangeCommand message) { + var user = repository.findById(message.getUserId()); + if (user == null) { + return CompletableFuture.completedFuture(null); + } + + user.setPassword(message.getPassword(), message.getChangeType()); + repository.save(user); + return CompletableFuture.completedFuture(null); + } +} From 5b38dd7068307c913246ec1e7e8db60029fc96d9 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 25 May 2026 23:34:04 +0800 Subject: [PATCH 5/7] Add JpaUserRepository and UserRepositoryImpl for user data access; implement methods for finding users by username, email, and phone --- .../identity/persistence/entity/User.java | 9 +++ .../persistence/profile/UserMapProfile.java | 42 +++++++++++ .../repository/JpaUserRepository.java | 20 ++++++ .../repository/UserRepositoryImpl.java | 71 +++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/repository/JpaUserRepository.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/User.java b/identity/src/main/java/io/theurl/identity/persistence/entity/User.java index d9490d9..99fb606 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/entity/User.java +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/User.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Persistable; import java.time.LocalDateTime; +import java.util.Collection; @Data @Entity @@ -54,6 +55,14 @@ public class User implements Persistable { @Column(name = "updated_at") private LocalDateTime updatedAt = LocalDateTime.now(); + @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "user_id") + private Collection roles; + + @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "user_id") + private Collection authorities; + @Override public Long getId() { return id; diff --git a/identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java b/identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java new file mode 100644 index 0000000..e893b03 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java @@ -0,0 +1,42 @@ +package io.theurl.identity.persistence.profile; + +import jakarta.annotation.PostConstruct; +import org.modelmapper.ModelMapper; +import org.modelmapper.Provider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class UserMapProfile { + @Autowired + private ModelMapper mapper; + + @PostConstruct + public void configure() { + Provider userProvider = request -> new io.theurl.identity.domain.aggregate.User((Long) request.getSource()); + + mapper.createTypeMap(io.theurl.identity.persistence.entity.User.class, io.theurl.identity.domain.aggregate.User.class) + .setProvider(userProvider) + .addMappings(expression -> { + expression.map(io.theurl.identity.persistence.entity.User::getUsername, io.theurl.identity.domain.aggregate.User::setUsername); + expression.map(io.theurl.identity.persistence.entity.User::getNickname, io.theurl.identity.domain.aggregate.User::setNickname); + expression.map(io.theurl.identity.persistence.entity.User::getEmail, io.theurl.identity.domain.aggregate.User::setEmail); + expression.map(io.theurl.identity.persistence.entity.User::getPhone, io.theurl.identity.domain.aggregate.User::setPhone); + expression.map(io.theurl.identity.persistence.entity.User::getAccessFailedCount, io.theurl.identity.domain.aggregate.User::setAccessFailedCount); + expression.map(io.theurl.identity.persistence.entity.User::getLockedUntil, io.theurl.identity.domain.aggregate.User::setLockedUntil); + expression.map(io.theurl.identity.persistence.entity.User::getRoles, io.theurl.identity.domain.aggregate.User::setRoles); + }); + + mapper.createTypeMap(io.theurl.identity.domain.aggregate.User.class, io.theurl.identity.persistence.entity.User.class) + .addMappings(expression -> { + expression.map(io.theurl.identity.domain.aggregate.User::getId, io.theurl.identity.persistence.entity.User::setId); + expression.map(io.theurl.identity.domain.aggregate.User::getUsername, io.theurl.identity.persistence.entity.User::setUsername); + expression.map(io.theurl.identity.domain.aggregate.User::getNickname, io.theurl.identity.persistence.entity.User::setNickname); + expression.map(io.theurl.identity.domain.aggregate.User::getEmail, io.theurl.identity.persistence.entity.User::setEmail); + expression.map(io.theurl.identity.domain.aggregate.User::getPhone, io.theurl.identity.persistence.entity.User::setPhone); + expression.map(io.theurl.identity.domain.aggregate.User::getAccessFailedCount, io.theurl.identity.persistence.entity.User::setAccessFailedCount); + expression.map(io.theurl.identity.domain.aggregate.User::getLockedUntil, io.theurl.identity.persistence.entity.User::setLockedUntil); + expression.map(io.theurl.identity.domain.aggregate.User::getRoles, io.theurl.identity.persistence.entity.User::setRoles); + }); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/JpaUserRepository.java b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaUserRepository.java new file mode 100644 index 0000000..6c53844 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaUserRepository.java @@ -0,0 +1,20 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.identity.persistence.entity.User; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface JpaUserRepository extends CrudRepository { + Optional findByUsername(String username); + + Optional findByEmail(String email); + + Optional findByPhone(String phone); + + @Query("SELECT u FROM User u WHERE u.username = :username OR u.email = :email OR u.phone = :phone") + Optional findByAnyOf(String username, String email, String phone); +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java b/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java new file mode 100644 index 0000000..920112e --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java @@ -0,0 +1,71 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.framework.utility.Cryptography; +import io.theurl.framework.utility.RandomUtility; +import io.theurl.identity.domain.aggregate.User; +import io.theurl.identity.domain.repository.UserRepository; +import lombok.AllArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Repository; + +@Repository +@AllArgsConstructor +public class UserRepositoryImpl implements UserRepository { + private final JpaUserRepository repository; + private final ModelMapper mapper; + + @Override + public void save(User user) { + var entity = repository.findById(user.getId()) + .orElseGet(io.theurl.identity.persistence.entity.User::new); + + mapper.map(user, entity); + + if (user.getPassword() != null) { + var salt = RandomUtility.randomString(32); + try { + var passwordHash = Cryptography.AES.encrypt(user.getPassword(), salt); + entity.setPasswordHash(passwordHash); + entity.setPasswordSalt(salt); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt password", e); + } + } + repository.save(entity); + } + + @Override + public User findById(Long id) { + return repository.findById(id) + .map(entity -> mapper.map(entity, User.class)) + .orElse(null); + } + + @Override + public User findByUsername(String username) { + return repository.findByUsername(username) + .map(entity -> mapper.map(entity, User.class)) + .orElse(null); + } + + @Override + public User findByEmail(String email) { + return repository.findByEmail(email) + .map(entity -> mapper.map(entity, User.class)) + .orElse(null); + } + + @Override + public User findByPhone(String phone) { + return repository.findByPhone(phone) + .map(entity -> mapper.map(entity, User.class)) + .orElse(null); + } + + @Override + public User findByAnyOf(String username, String email, String phone) { + return repository.findByAnyOf(username, email, phone) + .map(entity -> mapper.map(entity, User.class)) + .orElse(null); + } +} From e84e463319192660b54b98e2704ddf41a641a9d6 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 25 May 2026 23:34:17 +0800 Subject: [PATCH 6/7] Add event handler registration to AggregateRoot; implement applyEvent method for improved event processing --- .../framework/domain/AggregateRoot.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java index 63d9fdb..e7a5f03 100644 --- a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java +++ b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java @@ -1,13 +1,17 @@ package io.theurl.framework.domain; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.function.Consumer; public class AggregateRoot> extends Entity implements IAggregateRoot, IHasDomainEvents { private final List events; + private final Map, Consumer> eventHandlers = new HashMap<>(); + /** * Initializes the aggregate with the given id. * @@ -30,21 +34,26 @@ public void clearEvents() { @Override public void raiseEvent(E event) { - event.attach(this); + applyEvent(event); this.events.add(event); } @Override public void applyEvent(E event) { - + var handler = eventHandlers.get(event.getClass()); + if (handler != null) { + handler.accept(event); + } } - public void applyEvent(Consumer handler, E event) { - handler.accept(event); + public void registerEvent(Class eventType, Consumer handler) { + eventHandlers.put(eventType, event -> handler.accept(eventType.cast(event))); } @Override public void attachEvent() { - + for (var event : events) { + event.attach(this); + } } } From 47aaa94d05d2f995960fedd0d0faa6b694260670 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 25 May 2026 23:50:01 +0800 Subject: [PATCH 7/7] Refactor exception classes to use Object for identity; update UserAccessFailureCountCommand and UserPasswordChangeCommand to use records for improved clarity and conciseness --- .../framework/security/AccountException.java | 10 +- .../security/AccountExpiredException.java | 6 +- .../security/AccountLockedException.java | 6 +- .../security/AccountNotFoundException.java | 6 +- identity/pom.xml | 2 +- .../UserAccessFailureCountCommand.java | 11 +- .../command/UserPasswordChangeCommand.java | 16 +-- .../UserAccessFailureCountCommandHandler.java | 4 +- .../UserPasswordChangeCommandHandler.java | 4 +- .../implement/AuthApplicationServiceImpl.java | 7 +- message/pom.xml | 12 +- message/src/main/resources/logback-spring.xml | 107 ++++++++++++++++++ 12 files changed, 143 insertions(+), 48 deletions(-) create mode 100644 message/src/main/resources/logback-spring.xml diff --git a/framework/src/main/java/io/theurl/framework/security/AccountException.java b/framework/src/main/java/io/theurl/framework/security/AccountException.java index 33433b7..1be97f5 100644 --- a/framework/src/main/java/io/theurl/framework/security/AccountException.java +++ b/framework/src/main/java/io/theurl/framework/security/AccountException.java @@ -9,25 +9,25 @@ */ @SuppressWarnings("unused") public class AccountException extends RuntimeException { - private final String identity; + private final Object identity; private final Map details = Collections.emptyMap(); - public AccountException(String identity) { + public AccountException(Object identity) { this.identity = identity; } - public AccountException(String identity, String message) { + public AccountException(Object identity, String message) { super(message); this.identity = identity; } - public AccountException(String identity, String message, Throwable cause) { + public AccountException(Object identity, String message, Throwable cause) { super(message, cause); this.identity = identity; } - public String getIdentity() { + public Object getIdentity() { return identity; } diff --git a/framework/src/main/java/io/theurl/framework/security/AccountExpiredException.java b/framework/src/main/java/io/theurl/framework/security/AccountExpiredException.java index 182e66c..8bd867f 100644 --- a/framework/src/main/java/io/theurl/framework/security/AccountExpiredException.java +++ b/framework/src/main/java/io/theurl/framework/security/AccountExpiredException.java @@ -6,15 +6,15 @@ */ @SuppressWarnings("unused") public class AccountExpiredException extends AccountException { - public AccountExpiredException(String identity) { + public AccountExpiredException(Object identity) { super(identity); } - public AccountExpiredException(String identity, String message) { + public AccountExpiredException(Object identity, String message) { super(identity, message); } - public AccountExpiredException(String identity, String message, Throwable cause) { + public AccountExpiredException(Object identity, String message, Throwable cause) { super(identity, message, cause); } } diff --git a/framework/src/main/java/io/theurl/framework/security/AccountLockedException.java b/framework/src/main/java/io/theurl/framework/security/AccountLockedException.java index 004ec3c..7679e04 100644 --- a/framework/src/main/java/io/theurl/framework/security/AccountLockedException.java +++ b/framework/src/main/java/io/theurl/framework/security/AccountLockedException.java @@ -6,15 +6,15 @@ */ @SuppressWarnings("unused") public class AccountLockedException extends AccountException { - public AccountLockedException(String identity) { + public AccountLockedException(Object identity) { super(identity); } - public AccountLockedException(String identity, String message) { + public AccountLockedException(Object identity, String message) { super(identity, message); } - public AccountLockedException(String identity, String message, Throwable cause) { + public AccountLockedException(Object identity, String message, Throwable cause) { super(identity, message, cause); } } diff --git a/framework/src/main/java/io/theurl/framework/security/AccountNotFoundException.java b/framework/src/main/java/io/theurl/framework/security/AccountNotFoundException.java index 096afa3..9a4f09a 100644 --- a/framework/src/main/java/io/theurl/framework/security/AccountNotFoundException.java +++ b/framework/src/main/java/io/theurl/framework/security/AccountNotFoundException.java @@ -6,15 +6,15 @@ */ @SuppressWarnings("unused") public class AccountNotFoundException extends AccountException { - public AccountNotFoundException(String identity) { + public AccountNotFoundException(Object identity) { super(identity); } - public AccountNotFoundException(String identity, String message) { + public AccountNotFoundException(Object identity, String message) { super(identity, message); } - public AccountNotFoundException(String identity, String message, Throwable cause) { + public AccountNotFoundException(Object identity, String message, Throwable cause) { super(identity, message, cause); } } diff --git a/identity/pom.xml b/identity/pom.xml index 1985f53..929e189 100644 --- a/identity/pom.xml +++ b/identity/pom.xml @@ -20,7 +20,7 @@ io.theurl framework - 1.0 + ${project.version} compile diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java index c82a235..15f17a5 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java @@ -1,21 +1,12 @@ package io.theurl.identity.application.command; import com.neroyun.mediator.Command; -import lombok.Getter; /** * Command to update the access failure count of a user. * This command is typically used when a user authentication attempt fails, and we want to increment the failure count for that user. * The command contains the user ID and the new failure count to be set. */ -@Getter -public class UserAccessFailureCountCommand implements Command { +public record UserAccessFailureCountCommand(Long userId, String action) implements Command { - private final Long userId; - private final String action; - - public UserAccessFailureCountCommand(Long userId, String action) { - this.userId = userId; - this.action = action; - } } diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java index 7c96255..4630a42 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserPasswordChangeCommand.java @@ -1,13 +1,13 @@ package io.theurl.identity.application.command; import com.neroyun.mediator.Command; -import lombok.AllArgsConstructor; -import lombok.Getter; -@Getter -@AllArgsConstructor -public class UserPasswordChangeCommand implements Command { - private final Long userId; - private final String password; - private final String changeType; +/** + * Command for changing user password. The changeType can be "reset" for resetting password or "update" for updating password. + * + * @param userId the ID of the user whose password is to be changed + * @param password the new password for the user + * @param changeType the type of password change, either "reset" or "update" + */ +public record UserPasswordChangeCommand(Long userId, String password, String changeType) implements Command { } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java index 0e13df2..215e65e 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java @@ -22,12 +22,12 @@ public UserAccessFailureCountCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAccessFailureCountCommand message) { - var user = repository.findById(message.getUserId()); + var user = repository.findById(message.userId()); if (user == null) { return CompletableFuture.completedFuture(null); } - switch (message.getAction()) { + switch (message.action()) { case "increase" -> user.increaseAccessFailedCount(); case "reset" -> user.resetAccessFailedCount(); } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java index f99cd54..8b93bc3 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java @@ -22,12 +22,12 @@ public UserPasswordChangeCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserPasswordChangeCommand message) { - var user = repository.findById(message.getUserId()); + var user = repository.findById(message.userId()); if (user == null) { return CompletableFuture.completedFuture(null); } - user.setPassword(message.getPassword(), message.getChangeType()); + user.setPassword(message.password(), message.changeType()); repository.save(user); return CompletableFuture.completedFuture(null); } diff --git a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java index ba16aa0..a07be80 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java @@ -30,10 +30,7 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.CompletableFuture; @Slf4j @@ -110,7 +107,7 @@ public CompletableFuture grant(TokenGrantRequestDto reque handleException(e, ex -> { switch (ex) { case AccountLockedException exception: - event.setUserId(exception.getIdentity()); + event.setUserId((Long) exception.getIdentity()); event.setGrantType(request.grantType()); event.setGrantTime(LocalDateTime.now()); event.setError(exception.getLocalizedMessage()); diff --git a/message/pom.xml b/message/pom.xml index fc8ce14..62db91c 100644 --- a/message/pom.xml +++ b/message/pom.xml @@ -12,6 +12,12 @@ message message service + + io.theurl + framework + ${project.version} + compile + com.neroyun mediator @@ -83,12 +89,6 @@ spring-boot-starter-webmvc-test test - - io.theurl - framework - 1.0 - compile - diff --git a/message/src/main/resources/logback-spring.xml b/message/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..41f1af3 --- /dev/null +++ b/message/src/main/resources/logback-spring.xml @@ -0,0 +1,107 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + utf8 + + + + + ${LOG_PATH}/message/info/current.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/message/info/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/message/error/current.log + + ERROR + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/message/error/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/message/warn/current.log + + WARN + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/message/warn/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/message/debug/current.log + + DEBUG + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/message/debug/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/message/sql/current.log + + DEBUG + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/message/sql/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + + + + + + + + + + + + + + +