From d3c5e66aabddf8b8c50d55ccb6a2be8bdfb9aea8 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 1 Jun 2026 15:02:59 +0800 Subject: [PATCH 1/3] Refactor account and authentication controllers for asynchronous operations and enhance OTP functionality --- .../controller/AccountController.java | 14 +++---- .../interfaces/controller/AuthController.java | 14 ++++--- .../controller/OnetimePasswordController.java | 42 +++++++++++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java index 5a76543..c93bba7 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java @@ -24,7 +24,7 @@ public AccountController(UserApplicationService service) { public ResponseEntity create(@RequestBody UserCreateRequestDto user) { service.createAsync(user) .join(); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @GetMapping("/profile") @@ -40,7 +40,7 @@ public ResponseEntity getProfile() { public ResponseEntity changePassword(@RequestBody UserPasswordChangeRequestDto user) { service.changePasswordAsync(user.getOldPassword(), user.getNewPassword()) .join(); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @PutMapping("/email") @@ -48,7 +48,7 @@ public ResponseEntity changePassword(@RequestBody UserPasswordChangeReques public ResponseEntity changeEmail(@RequestBody UserUpdateRequestDto data) { service.changeEmailAsync(data) .join(); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @PutMapping("/phone") @@ -56,7 +56,7 @@ public ResponseEntity changeEmail(@RequestBody UserUpdateRequestDto data) public ResponseEntity changePhone(@RequestBody UserUpdateRequestDto data) { service.changePhoneAsync(data) .join(); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @PutMapping("/nickname") @@ -64,7 +64,7 @@ public ResponseEntity changePhone(@RequestBody UserUpdateRequestDto data) public ResponseEntity changeNickname(@RequestBody UserUpdateRequestDto data) { service.changeNicknameAsync(data.getNickname()) .join(); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @PostMapping("/authority/{provider}") @@ -72,7 +72,7 @@ public ResponseEntity changeNickname(@RequestBody UserUpdateRequestDto dat public ResponseEntity bindAuthority(@PathVariable String provider, @RequestParam String code) { service.connectAuthorityAsync(provider, code) .join(); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @DeleteMapping("/authority/{provider}") @@ -80,6 +80,6 @@ public ResponseEntity bindAuthority(@PathVariable String provider, @Reques public ResponseEntity unbindAuthority(@PathVariable String provider, @RequestParam String openId) { service.removeAuthorityAsync(provider, openId) .join(); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } } diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java index d19dbe3..d0a06cf 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java @@ -6,6 +6,8 @@ import io.theurl.identity.application.dto.TokenGrantResponseDto; import org.springframework.web.bind.annotation.*; +import java.util.concurrent.CompletableFuture; + @RestController @RequestMapping("auth") public class AuthController { @@ -27,8 +29,8 @@ public AuthController(AuthApplicationService service) { */ @PostMapping("token/grant") @Operation(summary = "Grant access token") - public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto request) { - return service.grant(request).join(); + public CompletableFuture grantToken(@RequestBody TokenGrantRequestDto request) { + return service.grant(request); } /** @@ -41,9 +43,9 @@ public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto reques */ @PostMapping("token/refresh") @Operation(summary = "Refresh access token") - public TokenGrantResponseDto refreshToken(@RequestParam String token) { + public CompletableFuture refreshToken(@RequestParam String token) { var request = new TokenGrantRequestDto(token, null, "refresh_token", null); - return service.grant(request).join(); + return service.grant(request); } /** @@ -55,7 +57,7 @@ public TokenGrantResponseDto refreshToken(@RequestParam String token) { */ @PostMapping("token/revoke") @Operation(summary = "Revoke access token") - public void revokeToken(@RequestParam String jti) { - service.revoke(jti).join(); + public CompletableFuture revokeToken(@RequestParam String jti) { + return service.revoke(jti); } } diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java index 29b1ee4..2d28960 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java @@ -15,18 +15,60 @@ public OnetimePasswordController(OnetimePasswordApplicationService service) { this.service = service; } + /** + * Send a one-time password (OTP) to the specified recipient for authentication purposes. + * This endpoint allows clients to request the sending of a one-time password (OTP) to a specified recipient, which can be used for authentication purposes. + * The client must provide the recipient's information in the request body, and the server will process the request to generate and send the OTP to the recipient. + * The server will return a response indicating the success or failure of the OTP sending operation, along with any relevant information such as the OTP code or an error message if the operation fails. + * + * @param request The request containing the recipient's information. + * @return A CompletableFuture representing the asynchronous operation, containing the OTP code or an error message. + */ @PostMapping("authentication") public CompletableFuture sendAuthOtp(@RequestBody OnetimePasswordSendRequestDto request) { return service.sendAsync(request.recipient(), "authentication"); } + /** + * Send a one-time password (OTP) to the specified recipient for email change verification. + * This endpoint allows clients to request the sending of a one-time password (OTP) to a specified recipient for the purpose of verifying an email change request. + * The client must provide the recipient's information in the request body, and the server will process the request to generate and send the OTP to the recipient for email change verification. + * The server will return a response indicating the success or failure of the OTP sending operation, along with any relevant information such as the OTP code or an error message if the operation fails. + * + * @param request The request containing the recipient's information. + * @return A CompletableFuture representing the asynchronous operation, containing the OTP code or an error message. + */ @PostMapping("change-email") public CompletableFuture sendChangeEmailOtp(@RequestBody OnetimePasswordSendRequestDto request) { return service.sendAsync(request.recipient(), "change-email"); } + /** + * Send a one-time password (OTP) to the specified recipient for password reset verification. + * This endpoint allows clients to request the sending of a one-time password (OTP) to a specified recipient for the purpose of verifying a password reset request. + * The client must provide the recipient's information in the request body, and the server will process the request to generate and send the OTP to the recipient for password reset verification. + * The server will return a response indicating the success or failure of the OTP sending operation, along with any relevant information such as the OTP code or an error message if the operation fails. + * + * @param request The request containing the recipient's information. + * @return A CompletableFuture representing the asynchronous operation, containing the OTP code or an error message. + */ @PostMapping("reset-password") public CompletableFuture sendResetPasswordOtp(@RequestBody OnetimePasswordSendRequestDto request) { return service.sendAsync(request.recipient(), "reset-password"); } + + /** + * Send a one-time password (OTP) to the specified recipient for a custom intent. + * This endpoint allows clients to request the sending of a one-time password (OTP) to a specified recipient for a custom intent defined by the client. + * The client must provide the recipient's information in the request body and specify the intent as a query parameter, and the server will process the request to generate and send the OTP to the recipient for the specified intent. + * The server will return a response indicating the success or failure of the OTP sending operation, along with any relevant information such as the OTP code or an error message if the operation fails. + * + * @param request The request containing the recipient's information. + * @param intent The intent for which the OTP is being sent. + * @return A CompletableFuture representing the asynchronous operation, containing the OTP code or an error message. + */ + @PostMapping("send") + public CompletableFuture sendOtp(@RequestBody OnetimePasswordSendRequestDto request, @RequestParam String intent) { + return service.sendAsync(request.recipient(), intent); + } } From 4b4da47f145d229984d90606cc9058108774d543 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 1 Jun 2026 15:03:26 +0800 Subject: [PATCH 2/3] Add component scanning for framework and bundle packages in BundleApplication --- .../src/main/java/io/theurl/bundle/BundleApplication.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bundle/src/main/java/io/theurl/bundle/BundleApplication.java b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java index db134cb..7605c28 100644 --- a/bundle/src/main/java/io/theurl/bundle/BundleApplication.java +++ b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java @@ -2,10 +2,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.ComponentScans; import org.springframework.scheduling.annotation.EnableAsync; @EnableAsync @SpringBootApplication +@ComponentScans({ + @ComponentScan("io.theurl.framework"), + @ComponentScan("io.theurl.bundle") +}) public class BundleApplication { public static void main(String[] args) { From 91ba6820cd836eda97ff3ab65f5553fe4d2064eb Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 1 Jun 2026 15:43:32 +0800 Subject: [PATCH 3/3] Implement bundle deletion and update commands with associated handlers and DTOs --- .../command/BundleDeleteCommand.java | 6 + .../command/BundleUpdateCommand.java | 44 +++++++ .../contract/BundleApplicationService.java | 49 +++++++ .../application/dto/BundleCreateDto.java | 7 + .../application/dto/BundleItemEditDto.java | 11 ++ .../handler/BundleCreateCommandHandler.java | 10 +- .../handler/BundleDeleteCommandHandler.java | 63 +++++++++ .../handler/BundleUpdateCommandHandler.java | 64 ++++++++++ .../BundleApplicationServiceImpl.java | 82 +++++++++++- .../controller/BundleController.java | 120 ++++++++++++++++++ .../interfaces/controller/package-info.java | 5 + .../bundle/persistence/entity/Bundle.java | 4 - .../persistence/entity/BundleComment.java | 9 +- .../persistence/entity/BundleExtend.java | 9 +- .../bundle/persistence/entity/BundleItem.java | 4 + .../persistence/profile/BundleMapProfile.java | 53 +++++--- .../application/BaseApplicationService.java | 8 ++ 17 files changed, 508 insertions(+), 40 deletions(-) create mode 100644 bundle/src/main/java/io/theurl/bundle/application/command/BundleDeleteCommand.java create mode 100644 bundle/src/main/java/io/theurl/bundle/application/command/BundleUpdateCommand.java create mode 100644 bundle/src/main/java/io/theurl/bundle/application/dto/BundleItemEditDto.java create mode 100644 bundle/src/main/java/io/theurl/bundle/application/handler/BundleDeleteCommandHandler.java create mode 100644 bundle/src/main/java/io/theurl/bundle/application/handler/BundleUpdateCommandHandler.java create mode 100644 bundle/src/main/java/io/theurl/bundle/interfaces/controller/BundleController.java create mode 100644 bundle/src/main/java/io/theurl/bundle/interfaces/controller/package-info.java diff --git a/bundle/src/main/java/io/theurl/bundle/application/command/BundleDeleteCommand.java b/bundle/src/main/java/io/theurl/bundle/application/command/BundleDeleteCommand.java new file mode 100644 index 0000000..7143d63 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/application/command/BundleDeleteCommand.java @@ -0,0 +1,6 @@ +package io.theurl.bundle.application.command; + +import com.neroyun.mediator.Command; + +public record BundleDeleteCommand(String vanity) implements Command { +} diff --git a/bundle/src/main/java/io/theurl/bundle/application/command/BundleUpdateCommand.java b/bundle/src/main/java/io/theurl/bundle/application/command/BundleUpdateCommand.java new file mode 100644 index 0000000..769a778 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/application/command/BundleUpdateCommand.java @@ -0,0 +1,44 @@ +package io.theurl.bundle.application.command; + +import com.neroyun.mediator.Command; + +@SuppressWarnings({"LombokGetterMayBeUsed", "LombokSetterMayBeUsed"}) +public class BundleUpdateCommand implements Command { + private final String vanity; + + private String name; + private String description; + private String image; + + public BundleUpdateCommand(String vanity) { + this.vanity = vanity; + } + + public String getVanity() { + return vanity; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getImage() { + return image; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setImage(String image) { + this.image = image; + } +} diff --git a/bundle/src/main/java/io/theurl/bundle/application/contract/BundleApplicationService.java b/bundle/src/main/java/io/theurl/bundle/application/contract/BundleApplicationService.java index 186d7fd..2aea1f4 100644 --- a/bundle/src/main/java/io/theurl/bundle/application/contract/BundleApplicationService.java +++ b/bundle/src/main/java/io/theurl/bundle/application/contract/BundleApplicationService.java @@ -1,15 +1,64 @@ package io.theurl.bundle.application.contract; import io.theurl.bundle.application.dto.BundleCreateDto; +import io.theurl.bundle.application.dto.BundleItemEditDto; import io.theurl.bundle.application.dto.BundleUpdateDto; import io.theurl.framework.application.ApplicationService; import java.util.concurrent.CompletableFuture; public interface BundleApplicationService extends ApplicationService { + + /** + * Creates a new bundle with the provided data. If the vanity is not provided, it will be generated automatically. + * + * @param data The data for the new bundle. + * @return A CompletableFuture that will complete with the vanity of the created bundle. + */ CompletableFuture createAsync(BundleCreateDto data); + /** + * Updates an existing bundle identified by the vanity with the provided data. + * + * @param vanity The vanity of the bundle to update. + * @param data The data to update the bundle with. + * @return A CompletableFuture that will complete when the update is done. + */ CompletableFuture updateAsync(String vanity, BundleUpdateDto data); + /** + * Deletes the bundle identified by the vanity. + * + * @param vanity The vanity of the bundle to delete. + * @return A CompletableFuture that will complete when the deletion is done. + */ CompletableFuture deleteAsync(String vanity); + + /** + * Appends an item to the bundle identified by the vanity with the provided data. + * + * @param vanity The vanity of the bundle to append the item to. + * @param data The data of the item to append. + * @return A CompletableFuture that will complete when the item is appended. + */ + CompletableFuture appendItemAsync(String vanity, BundleItemEditDto data); + + /** + * Updates an item in the bundle identified by the vanity with the provided data. + * + * @param vanity The vanity of the bundle containing the item to update. + * @param itemId The ID of the item to update. + * @param data The data to update the item with. + * @return A CompletableFuture that will complete when the item is updated. + */ + CompletableFuture updateItemAsync(String vanity, long itemId, BundleItemEditDto data); + + /** + * Removes an item from the bundle identified by the vanity. + * + * @param vanity The vanity of the bundle to remove the item from. + * @param itemId The ID of the item to remove. + * @return A CompletableFuture that will complete when the item is removed. + */ + CompletableFuture removeItemAsync(String vanity, long itemId); } diff --git a/bundle/src/main/java/io/theurl/bundle/application/dto/BundleCreateDto.java b/bundle/src/main/java/io/theurl/bundle/application/dto/BundleCreateDto.java index 124c265..c6dc8b5 100644 --- a/bundle/src/main/java/io/theurl/bundle/application/dto/BundleCreateDto.java +++ b/bundle/src/main/java/io/theurl/bundle/application/dto/BundleCreateDto.java @@ -21,4 +21,11 @@ public class BundleCreateDto extends BundleBaseDto { * A unique identifier for the bundle, often used in URLs. This field is used to create a user-friendly and memorable URL for the bundle. */ private String vanity; + + /** + * Indicates whether the bundle is shared or not. + * If true, the bundle can be accessed by anyone with the link. + * If false, the bundle is private and can only be accessed by the owner. + */ + private boolean shared; } diff --git a/bundle/src/main/java/io/theurl/bundle/application/dto/BundleItemEditDto.java b/bundle/src/main/java/io/theurl/bundle/application/dto/BundleItemEditDto.java new file mode 100644 index 0000000..eed552a --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/application/dto/BundleItemEditDto.java @@ -0,0 +1,11 @@ +package io.theurl.bundle.application.dto; + +import lombok.Data; + +@Data +public class BundleItemEditDto { + private String url; + private String title; + private String description; + private String image; +} diff --git a/bundle/src/main/java/io/theurl/bundle/application/handler/BundleCreateCommandHandler.java b/bundle/src/main/java/io/theurl/bundle/application/handler/BundleCreateCommandHandler.java index 82f8812..6fecf13 100644 --- a/bundle/src/main/java/io/theurl/bundle/application/handler/BundleCreateCommandHandler.java +++ b/bundle/src/main/java/io/theurl/bundle/application/handler/BundleCreateCommandHandler.java @@ -1,7 +1,6 @@ package io.theurl.bundle.application.handler; import com.neroyun.mediator.Handler; -import com.neroyun.mediator.Mediator; import com.neroyun.mediator.MessageContext; import io.theurl.bundle.application.command.BundleCreateCommand; import io.theurl.bundle.domain.aggregate.Bundle; @@ -9,22 +8,22 @@ import io.theurl.framework.core.BeanScope; import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; @Component -@Scope(BeanScope.PROTOTYPE) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class BundleCreateCommandHandler implements Handler { private final BundleRepository repository; - private final Mediator mediator; - public BundleCreateCommandHandler(BundleRepository repository, Mediator mediator) { + public BundleCreateCommandHandler(BundleRepository repository) { this.repository = repository; - this.mediator = mediator; } @Override @@ -39,6 +38,7 @@ public CompletableFuture handleAsync(BundleCreateCommand message, MessageC } aggregate.setOwner(message.getOwnerId(), message.getOwnerName()); repository.save(aggregate, userId); + context.onComplete(List.copyOf(aggregate.getEvents())); return CompletableFuture.completedFuture(null); } diff --git a/bundle/src/main/java/io/theurl/bundle/application/handler/BundleDeleteCommandHandler.java b/bundle/src/main/java/io/theurl/bundle/application/handler/BundleDeleteCommandHandler.java new file mode 100644 index 0000000..f87630e --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/application/handler/BundleDeleteCommandHandler.java @@ -0,0 +1,63 @@ +package io.theurl.bundle.application.handler; + +import com.neroyun.mediator.Handler; +import com.neroyun.mediator.MessageContext; +import io.theurl.bundle.application.command.BundleDeleteCommand; +import io.theurl.bundle.domain.repository.BundleRepository; +import io.theurl.framework.core.BeanScope; +import io.theurl.framework.security.UnauthorizedAccessException; +import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +public class BundleDeleteCommandHandler implements Handler { + private final BundleRepository repository; + + public BundleDeleteCommandHandler(BundleRepository repository) { + this.repository = repository; + } + + @Override + public CompletableFuture handleAsync(BundleDeleteCommand message, MessageContext context) { + var aggregate = repository.findByVanity(message.vanity()); + if (aggregate == null) { + throw new EntityNotFoundException("Bundle with vanity " + message.vanity() + " not found"); + } + + var userId = Long.getLong(Objects.requireNonNull(getRequest()).getUserPrincipal().getName()); + + if (aggregate.getOwnerId() > 0) { + if (!Objects.equals(aggregate.getOwnerId(), userId)) { + throw new UnauthorizedAccessException("You are not the owner of this bundle"); + } + } else { + if (!getRequest().isUserInRole("ADMIN")) { + throw new UnauthorizedAccessException("You are not the owner of this bundle"); + } + } + + aggregate.delete(); + repository.save(aggregate, userId); + context.onComplete(aggregate.getEvents()); + return CompletableFuture.completedFuture(null); + } + + private HttpServletRequest getRequest() { + var request = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (request == null) { + return null; + } + + return request.getRequest(); + } +} diff --git a/bundle/src/main/java/io/theurl/bundle/application/handler/BundleUpdateCommandHandler.java b/bundle/src/main/java/io/theurl/bundle/application/handler/BundleUpdateCommandHandler.java new file mode 100644 index 0000000..50c53a8 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/application/handler/BundleUpdateCommandHandler.java @@ -0,0 +1,64 @@ +package io.theurl.bundle.application.handler; + +import com.neroyun.mediator.Handler; +import com.neroyun.mediator.MessageContext; +import io.theurl.bundle.application.command.BundleUpdateCommand; +import io.theurl.bundle.domain.repository.BundleRepository; +import io.theurl.framework.core.BeanScope; +import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +public class BundleUpdateCommandHandler implements Handler { + + private final BundleRepository repository; + + public BundleUpdateCommandHandler(BundleRepository repository) { + this.repository = repository; + } + + @Override + public CompletableFuture handleAsync(BundleUpdateCommand message, MessageContext context) { + var aggregate = repository.findByVanity(message.getVanity()); + + if (aggregate == null) { + throw new EntityNotFoundException("Bundle with vanity '" + message.getVanity() + "' not found."); + } + + aggregate.setName(message.getName()); + aggregate.setDescription(message.getDescription()); + aggregate.setImage(message.getImage()); + + repository.save(aggregate, getUserId()); + + return CompletableFuture.completedFuture(null); + } + + private HttpServletRequest getRequest() { + var request = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (request == null) { + return null; + } + + return request.getRequest(); + } + + private long getUserId() { + var request = getRequest(); + + if (request == null || request.getUserPrincipal() == null) { + return 0; + } + + return Long.parseLong(request.getUserPrincipal().getName()); + } +} diff --git a/bundle/src/main/java/io/theurl/bundle/application/implement/BundleApplicationServiceImpl.java b/bundle/src/main/java/io/theurl/bundle/application/implement/BundleApplicationServiceImpl.java index 0c7b88a..3a3bf93 100644 --- a/bundle/src/main/java/io/theurl/bundle/application/implement/BundleApplicationServiceImpl.java +++ b/bundle/src/main/java/io/theurl/bundle/application/implement/BundleApplicationServiceImpl.java @@ -1,8 +1,12 @@ package io.theurl.bundle.application.implement; +import com.neroyun.mediator.Event; import io.theurl.bundle.application.command.BundleCreateCommand; +import io.theurl.bundle.application.command.BundleDeleteCommand; +import io.theurl.bundle.application.command.BundleUpdateCommand; import io.theurl.bundle.application.contract.BundleApplicationService; import io.theurl.bundle.application.dto.BundleCreateDto; +import io.theurl.bundle.application.dto.BundleItemEditDto; import io.theurl.bundle.application.dto.BundleUpdateDto; import io.theurl.framework.application.BaseApplicationService; import io.theurl.framework.utility.ShortUniqueId; @@ -12,8 +16,10 @@ import org.springframework.web.context.annotation.RequestScope; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; @Service @RequestScope @@ -43,18 +49,90 @@ public CompletableFuture createAsync(BundleCreateDto data) { command.setName(data.getName()); command.setDescription(data.getDescription()); command.setImage(data.getImage()); + if (!data.isShared()) { + command.setOwnerId(currentUserId()); + command.setOwnerName(currentUsername()); + } + + return mediator.sendAsync(command, context -> { + context.subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Object item) { + if (item instanceof List events) { + events.parallelStream() + .forEach(event -> mediator.publishAsync((Event) event)); + } + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { - return mediator.sendAsync(command) + } + }); + }) .thenApply(_ -> command.getVanity()); } @Override public CompletableFuture updateAsync(String vanity, BundleUpdateDto data) { - return null; + var command = new BundleUpdateCommand(vanity); + mapper.map(data, command); + return mediator.sendAsync(command); } @Override public CompletableFuture deleteAsync(String vanity) { + var command = new BundleDeleteCommand(vanity); + return mediator.sendAsync(command, context -> { + context.subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Object item) { + if (item instanceof List events) { + events.parallelStream() + .forEach(event -> mediator.publishAsync((Event) event)); + } + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + + } + }); + }); + } + + @Override + public CompletableFuture appendItemAsync(String vanity, BundleItemEditDto data) { + return null; + } + + @Override + public CompletableFuture updateItemAsync(String vanity, long itemId, BundleItemEditDto data) { + return null; + } + + @Override + public CompletableFuture removeItemAsync(String vanity, long itemId) { return null; } } diff --git a/bundle/src/main/java/io/theurl/bundle/interfaces/controller/BundleController.java b/bundle/src/main/java/io/theurl/bundle/interfaces/controller/BundleController.java new file mode 100644 index 0000000..7dd7a8f --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/interfaces/controller/BundleController.java @@ -0,0 +1,120 @@ +package io.theurl.bundle.interfaces.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.theurl.bundle.application.contract.BundleApplicationService; +import io.theurl.bundle.application.dto.BundleCreateDto; +import io.theurl.bundle.application.dto.BundleItemEditDto; +import io.theurl.bundle.application.dto.BundleUpdateDto; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.*; + +import java.util.concurrent.CompletableFuture; + +@RestController +@RequestMapping("/api/bundles") +public class BundleController { + + private final BundleApplicationService service; + + public BundleController(BundleApplicationService service) { + this.service = service; + } + + /** + * Create a new bundle based on the provided data. + * This endpoint allows clients to create a new bundle by providing the necessary data in the request body. + * The server will process the request and, if the data is valid, will create a new bundle in the system. + * Upon successful creation, the server will return a response with a header containing the vanity URL of the newly created bundle, which can be used for future reference and access to the bundle. + * + * @param data The data for the new bundle. + * @param response The HTTP response. + * @return A CompletableFuture representing the asynchronous operation. + */ + @PostMapping + @Operation(summary = "Create a new bundle", security = @SecurityRequirement(name = "bearerAuth")) + public CompletableFuture create(@RequestBody BundleCreateDto data, HttpServletResponse response) { + return service.createAsync(data) + .thenAccept(vanity -> response.addHeader("x-vanity", vanity)); + } + + /** + * Update an existing bundle identified by the vanity URL with the provided data. + * This endpoint allows clients to update the details of an existing bundle by providing the vanity URL as a path variable and the updated data in the request body. + * The server will process the request and, if the vanity URL is valid and the data is acceptable, will update the corresponding bundle in the system. + * The server will return a response indicating the success or failure of the update operation. + * + * @param vanity The vanity URL of the bundle to be updated. + * @param data The updated data for the bundle. + * @return A CompletableFuture representing the asynchronous operation. + */ + @PutMapping("/{vanity}") + @Operation(summary = "Update an existing bundle", security = @SecurityRequirement(name = "bearerAuth")) + public CompletableFuture update(@PathVariable String vanity, @RequestBody BundleUpdateDto data) { + return service.updateAsync(vanity, data); + } + + /** + * Delete an existing bundle identified by the vanity URL. + * This endpoint allows clients to delete an existing bundle by providing the vanity URL as a path variable. + * The server will process the request and, if the vanity URL is valid, will remove the corresponding bundle from the system. + * The server will return a response indicating the success or failure of the delete operation. + * + * @param vanity The vanity URL of the bundle to be deleted. + * @return A CompletableFuture representing the asynchronous operation. + */ + @DeleteMapping("/{vanity}") + @Operation(summary = "Delete an existing bundle", security = @SecurityRequirement(name = "bearerAuth")) + public CompletableFuture delete(@PathVariable String vanity) { + return service.deleteAsync(vanity); + } + + /** + * Append an item to an existing bundle identified by the vanity URL with the provided data. + * This endpoint allows clients to add a new item to an existing bundle by providing the vanity URL as a path variable and the item data in the request body. + * The server will process the request and, if the vanity URL is valid and the item data is acceptable, will append the item to the corresponding bundle in the system. + * The server will return a response indicating the success or failure of the append operation. + * + * @param vanity The vanity URL of the bundle to which the item will be appended. + * @param data The data for the item to be appended. + * @return A CompletableFuture representing the asynchronous operation. + */ + @PostMapping("/{vanity}/append") + @Operation(summary = "Append items to an existing bundle", security = @SecurityRequirement(name = "bearerAuth")) + public CompletableFuture append(@PathVariable String vanity, @RequestBody BundleItemEditDto data) { + return service.appendItemAsync(vanity, data); + } + + /** + * Remove an item from an existing bundle identified by the vanity URL and item ID. + * This endpoint allows clients to remove an existing item from a bundle by providing the vanity URL as a path variable and the item ID as another path variable. + * The server will process the request and, if the vanity URL and item ID are valid, will remove the corresponding item from the bundle in the system. + * The server will return a response indicating the success or failure of the remove operation. + * + * @param vanity The vanity URL of the bundle from which the item will be removed. + * @param itemId The ID of the item to be removed. + * @return A CompletableFuture representing the asynchronous operation. + */ + @DeleteMapping("/{vanity}/{itemId}") + @Operation(summary = "Remove an item from an existing bundle", security = @SecurityRequirement(name = "bearerAuth")) + public CompletableFuture remove(@PathVariable String vanity, @PathVariable long itemId) { + return service.removeItemAsync(vanity, itemId); + } + + /** + * Update an item in an existing bundle identified by the vanity URL and item ID with the provided data. + * This endpoint allows clients to update the details of an existing item in a bundle by providing the vanity URL as a path variable, the item ID as another path variable, and the updated item data in the request body. + * The server will process the request and, if the vanity URL, item ID, and updated item data are valid, will update the corresponding item in the bundle in the system. + * The server will return a response indicating the success or failure of the update operation. + * + * @param vanity The vanity URL of the bundle containing the item to be updated. + * @param itemId The ID of the item to be updated. + * @param data The updated data for the item. + * @return A CompletableFuture representing the asynchronous operation. + */ + @PutMapping("/{vanity}/{itemId}") + @Operation(summary = "Update an item in an existing bundle", security = @SecurityRequirement(name = "bearerAuth")) + public CompletableFuture updateItem(@PathVariable String vanity, @PathVariable long itemId, @RequestBody BundleItemEditDto data) { + return service.updateItemAsync(vanity, itemId, data); + } +} diff --git a/bundle/src/main/java/io/theurl/bundle/interfaces/controller/package-info.java b/bundle/src/main/java/io/theurl/bundle/interfaces/controller/package-info.java new file mode 100644 index 0000000..407f74a --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/interfaces/controller/package-info.java @@ -0,0 +1,5 @@ +@OpenAPIDefinition(info = @Info(title = "Bundle API", version = "1.0", description = "API for managing bundles")) +package io.theurl.bundle.interfaces.controller; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; diff --git a/bundle/src/main/java/io/theurl/bundle/persistence/entity/Bundle.java b/bundle/src/main/java/io/theurl/bundle/persistence/entity/Bundle.java index 25369e2..8bd0d5c 100644 --- a/bundle/src/main/java/io/theurl/bundle/persistence/entity/Bundle.java +++ b/bundle/src/main/java/io/theurl/bundle/persistence/entity/Bundle.java @@ -1,6 +1,5 @@ package io.theurl.bundle.persistence.entity; -import io.theurl.bundle.domain.aggregate.BundleExtend; import jakarta.persistence.*; import lombok.Data; import org.jspecify.annotations.Nullable; @@ -68,15 +67,12 @@ public class Bundle implements Persistable { private Long deletedBy; @OneToMany(mappedBy = "bundle", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "bundle_id") private Collection items; @OneToMany(mappedBy = "bundle", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "bundle_id") private Collection comments; @OneToOne(mappedBy = "bundle", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "id") private BundleExtend extend; @Override diff --git a/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleComment.java b/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleComment.java index 7d0bea1..1196ed6 100644 --- a/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleComment.java +++ b/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleComment.java @@ -1,9 +1,6 @@ package io.theurl.bundle.persistence.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.Data; import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Persistable; @@ -38,6 +35,10 @@ public class BundleComment implements Persistable { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bundle_id", insertable = false, updatable = false) + private Bundle bundle; + @Override public @Nullable Long getId() { return id; diff --git a/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleExtend.java b/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleExtend.java index 345f0c5..758835c 100644 --- a/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleExtend.java +++ b/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleExtend.java @@ -1,9 +1,6 @@ package io.theurl.bundle.persistence.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.Data; import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Persistable; @@ -32,6 +29,10 @@ public class BundleExtend implements Persistable { @Column(name = "last_visited_at") private LocalDateTime lastVisitedAt; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "id") + private Bundle bundle; + @Override public @Nullable Long getId() { return id; diff --git a/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleItem.java b/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleItem.java index 2773ac9..9a77f68 100644 --- a/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleItem.java +++ b/bundle/src/main/java/io/theurl/bundle/persistence/entity/BundleItem.java @@ -34,6 +34,10 @@ public class BundleItem implements Persistable { @Column(name = "order", nullable = false) private int order; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bundle_id", insertable = false, updatable = false) + private Bundle bundle; + @Override public @Nullable Long getId() { return id; diff --git a/bundle/src/main/java/io/theurl/bundle/persistence/profile/BundleMapProfile.java b/bundle/src/main/java/io/theurl/bundle/persistence/profile/BundleMapProfile.java index 58e13c3..04a5868 100644 --- a/bundle/src/main/java/io/theurl/bundle/persistence/profile/BundleMapProfile.java +++ b/bundle/src/main/java/io/theurl/bundle/persistence/profile/BundleMapProfile.java @@ -37,30 +37,38 @@ public void configure() { mapper.createTypeMap(io.theurl.bundle.persistence.entity.Bundle.class, io.theurl.bundle.domain.aggregate.Bundle.class) .setProvider(provider) .addMappings(expression -> { - expression.map(Bundle::getType, (dest, value) -> setValue(dest, "type", value)); - expression.map(Bundle::getVanity, (dest, value) -> setValue(dest, "vanity", value)); - expression.map(Bundle::getOwnerId, (dest, value) -> setValue(dest, "ownerId", value)); - expression.map(Bundle::getOwnerName, (dest, value) -> setValue(dest, "ownerName", value)); +// expression.map(Bundle::getType, (dest, value) -> setValue(dest, "type", value)); +// expression.map(Bundle::getVanity, (dest, value) -> setValue(dest, "vanity", value)); +// expression.map(Bundle::getOwnerId, (dest, value) -> setValue(dest, "ownerId", value)); +// expression.map(Bundle::getOwnerName, (dest, value) -> setValue(dest, "ownerName", value)); expression.map(Bundle::getName, io.theurl.bundle.domain.aggregate.Bundle::setName); expression.map(Bundle::getDescription, io.theurl.bundle.domain.aggregate.Bundle::setDescription); expression.map(Bundle::getImage, io.theurl.bundle.domain.aggregate.Bundle::setImage); expression.map(Bundle::getOrder, io.theurl.bundle.domain.aggregate.Bundle::setOrder); - expression.map(Bundle::getItems, (dest, value) -> { - var items = dest.getItems(); - items.add(mapper.map(value, io.theurl.bundle.domain.aggregate.BundleItem.class)); - }); - expression.map(Bundle::getComments, (dest, value) -> { - var comments = dest.getComments(); - comments.add(mapper.map(value, io.theurl.bundle.domain.aggregate.BundleComment.class)); - }); - expression.map(Bundle::getExtend, (dest, value) -> { - var extend = (io.theurl.bundle.domain.aggregate.BundleExtend) value; - dest.getExtend().setItemCount(extend.getItemCount()); - dest.getExtend().setCommentCount(extend.getCommentCount()); - dest.getExtend().setFavoriteCount(extend.getFavoriteCount()); - dest.getExtend().setFavoriteCount(extend.getFavoriteCount()); - dest.getExtend().setLastVisitedAt(extend.getLastVisitedAt()); - }); +// expression.map(Bundle::getItems, (dest, value) -> { +// if(dest != null && value != null) { +// var items = dest.getItems(); +// items.add(mapper.map(value, io.theurl.bundle.domain.aggregate.BundleItem.class)); +// } +// }); +// expression.map(Bundle::getComments, (dest, value) -> { +// if (dest == null || value == null) { +// return; +// } +// var comments = dest.getComments(); +// comments.add(mapper.map(value, io.theurl.bundle.domain.aggregate.BundleComment.class)); +// }); +// expression.map(Bundle::getExtend, (dest, value) -> { +// if (dest == null || value == null) { +// return; +// } +// var extend = (io.theurl.bundle.domain.aggregate.BundleExtend) value; +// dest.getExtend().setItemCount(extend.getItemCount()); +// dest.getExtend().setCommentCount(extend.getCommentCount()); +// dest.getExtend().setFavoriteCount(extend.getFavoriteCount()); +// dest.getExtend().setFavoriteCount(extend.getFavoriteCount()); +// dest.getExtend().setLastVisitedAt(extend.getLastVisitedAt()); +// }); }); } @@ -74,7 +82,10 @@ public void configure() { */ private void setValue(io.theurl.bundle.domain.aggregate.Bundle bundle, String name, Object value) { try { - var field = bundle.getClass().getDeclaredField(name); + if (value == null || bundle == null) { + return; + } + var field = bundle.getClass().getSuperclass().getDeclaredField(name); field.setAccessible(true); field.set(bundle, value); } catch (NoSuchFieldException | IllegalAccessException e) { diff --git a/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java b/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java index be47b5c..f77104d 100644 --- a/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java +++ b/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java @@ -54,4 +54,12 @@ protected Long currentUserId() { } return Long.parseLong(principal.getName()); } + + protected String currentUsername() { + var principal = currentUser(); + if (principal == null || principal.getName() == null || principal.getName().isBlank()) { + throw new CredentialExpiredException(null, "Unauthenticated request."); + } + return principal.getName(); + } }