From d32ceb8f11fcf3921d1cb8296faf3af06c88c29f Mon Sep 17 00:00:00 2001 From: BcKmini <151009045+BcKmini@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:33:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=9C=EC=9D=B8=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84(=ED=91=9C=EC=8B=9C=EC=9D=B4=EB=A6=84/=EC=97=B0?= =?UTF-8?q?=EB=9D=BD=EC=B2=98)=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API=20+=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client의 /profile 페이지가 개인 프로필 API가 없어서(#191) 화면 상태로만 반영되던 문제 해결. user_account에 phone 컬럼 추가하고, GET/PATCH /api/v1/auth/me/profile로 표시이름·연락처를 실제로 조회·수정 가능하게 함. 회원가입 시 전화번호(선택) 입력도 지원. UserAccount 도메인의 applyState()가 password만 갱신하던 것을 displayName/phone도 갱신하도록 일반화했고, changePassword()와 같은 낙관적 잠금(version+1) 패턴으로 updateProfile()을 추가함. Closes #168 Co-Authored-By: Claude Sonnet 5 --- .../server/auth/api/AuthController.java | 60 +++++++++++++++++++ .../server/auth/api/ProfileResponse.java | 19 ++++++ .../fowoco/server/auth/api/SignupRequest.java | 21 ++++++- .../server/auth/api/UpdateProfileRequest.java | 52 ++++++++++++++++ .../server/auth/application/AuthService.java | 15 +++++ .../auth/application/SignupCommand.java | 1 + .../auth/application/SignupService.java | 1 + .../server/auth/domain/UserAccount.java | 49 +++++++++++++++ .../persistence/UserAccountJpaEntity.java | 9 +++ .../seed/DemoAuthSeedRunner.java | 1 + .../migration/V47__add_user_account_phone.sql | 2 + .../auth/api/AuthOpenApiContractTest.java | 4 +- .../seed/DemoAuthSeedRunnerTest.java | 2 + 13 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/auth/api/ProfileResponse.java create mode 100644 src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java create mode 100644 src/main/resources/db/migration/V47__add_user_account_phone.sql diff --git a/src/main/java/com/fowoco/server/auth/api/AuthController.java b/src/main/java/com/fowoco/server/auth/api/AuthController.java index ab901abf..69e656de 100644 --- a/src/main/java/com/fowoco/server/auth/api/AuthController.java +++ b/src/main/java/com/fowoco/server/auth/api/AuthController.java @@ -30,6 +30,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -361,4 +362,63 @@ public ResponseEntity logout( public CurrentActorResponse me() { return CurrentActorResponse.from(actorContextProvider.requireCurrentActor()); } + + @Operation( + operationId = "getMyProfile", + summary = "내 프로필 조회", + description = "현재 로그인한 사용자의 표시 이름·연락처를 반환합니다.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "현재 사용자 프로필", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ProfileResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized") + }) + @GetMapping(path = "/me/profile", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public ProfileResponse getMyProfile() { + var actor = actorContextProvider.requireCurrentActor(); + return ProfileResponse.from(authService.currentProfile(actor.actorId(), actor.companyId())); + } + + @Operation( + operationId = "updateMyProfile", + summary = "내 프로필 수정", + description = "현재 로그인한 사용자의 표시 이름·연락처를 수정합니다.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "수정된 프로필", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ProfileResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType") + }) + @PatchMapping( + path = "/me/profile", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public ProfileResponse updateMyProfile(@Valid @RequestBody UpdateProfileRequest request) { + var actor = actorContextProvider.requireCurrentActor(); + return ProfileResponse.from(authService.updateProfile( + actor.actorId(), + actor.companyId(), + request.getDisplayName(), + request.getPhone() + )); + } } diff --git a/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java b/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java new file mode 100644 index 00000000..68d04fb8 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java @@ -0,0 +1,19 @@ +package com.fowoco.server.auth.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.auth.domain.UserAccount; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "ProfileResponse", description = "현재 사용자의 개인 프로필") +public record ProfileResponse( + @JsonProperty("display_name") + @Schema(name = "display_name", description = "화면 표시 이름") + String displayName, + @Schema(description = "연락처 (선택 입력, 미등록 시 null)", example = "010-1234-5678") + String phone +) { + + public static ProfileResponse from(UserAccount userAccount) { + return new ProfileResponse(userAccount.displayName(), userAccount.phone()); + } +} diff --git a/src/main/java/com/fowoco/server/auth/api/SignupRequest.java b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java index 6404204e..a42426bb 100644 --- a/src/main/java/com/fowoco/server/auth/api/SignupRequest.java +++ b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java @@ -41,6 +41,17 @@ public final class SignupRequest { @Pattern(regexp = "^[^\\p{Cc}]+$", message = "담당자 이름에 제어 문자를 사용할 수 없습니다.") private final String displayName; + @JsonProperty("phone") + @Schema( + description = "최초 담당자의 연락처 (선택)", + example = "010-1234-5678", + maxLength = 30, + requiredMode = Schema.RequiredMode.NOT_REQUIRED + ) + @Size(max = 30, message = "연락처는 30자 이하여야 합니다.") + @Pattern(regexp = "^[0-9+()\\-\\s]*$", message = "연락처 형식이 올바르지 않습니다.") + private final String phone; + @Schema( description = "로그인에 사용할 이메일. 앞뒤 공백 제거 후 소문자로 정규화합니다.", example = "name@company.com", @@ -76,12 +87,14 @@ public final class SignupRequest { public SignupRequest( @JsonProperty("company_name") String companyName, @JsonProperty("display_name") String displayName, + @JsonProperty("phone") String phone, @JsonProperty("email") String email, @JsonProperty("password") String password, @JsonProperty("agreements") SignupAgreementsRequest agreements ) { this.companyName = stripNullable(companyName); this.displayName = stripNullable(displayName); + this.phone = stripNullable(phone); this.email = stripNullable(email); this.password = password; this.agreements = agreements; @@ -95,6 +108,10 @@ public String getDisplayName() { return displayName; } + public String getPhone() { + return phone; + } + public String getEmail() { return email; } @@ -108,7 +125,9 @@ public SignupAgreementsRequest getAgreements() { } public SignupCommand toCommand(String requestId) { - return new SignupCommand(companyName, displayName, email, password, agreements.toAgreements(), requestId); + return new SignupCommand( + companyName, displayName, phone, email, password, agreements.toAgreements(), requestId + ); } private static String stripNullable(String value) { diff --git a/src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java b/src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java new file mode 100644 index 00000000..0cce6c15 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java @@ -0,0 +1,52 @@ +package com.fowoco.server.auth.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(name = "UpdateProfileRequest", description = "현재 사용자의 개인 프로필 수정 요청") +public final class UpdateProfileRequest { + + @JsonProperty("display_name") + @Schema( + name = "display_name", + description = "화면 표시 이름", + example = "김경민", + maxLength = 80, + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotBlank(message = "표시 이름을 입력해 주세요.") + @Size(max = 80, message = "표시 이름은 80자 이하여야 합니다.") + @Pattern(regexp = "^[^\\p{Cc}]+$", message = "표시 이름에 제어 문자를 사용할 수 없습니다.") + private final String displayName; + + @Schema( + description = "연락처 (선택, 빈 문자열이면 삭제)", + example = "010-1234-5678", + maxLength = 30, + requiredMode = Schema.RequiredMode.NOT_REQUIRED + ) + @Size(max = 30, message = "연락처는 30자 이하여야 합니다.") + @Pattern(regexp = "^[0-9+()\\-\\s]*$", message = "연락처 형식이 올바르지 않습니다.") + private final String phone; + + @JsonCreator + public UpdateProfileRequest( + @JsonProperty("display_name") String displayName, + @JsonProperty("phone") String phone + ) { + this.displayName = displayName == null ? null : displayName.strip(); + this.phone = phone == null ? null : phone.strip(); + } + + public String getDisplayName() { + return displayName; + } + + public String getPhone() { + return phone; + } +} diff --git a/src/main/java/com/fowoco/server/auth/application/AuthService.java b/src/main/java/com/fowoco/server/auth/application/AuthService.java index 427090b5..65fef7e9 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthService.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthService.java @@ -155,6 +155,21 @@ public RefreshResult refresh(String rawRefreshToken) { return outcome.result().orElseThrow(InvalidRefreshTokenException::new); } + @Transactional(readOnly = true) + public UserAccount currentProfile(UUID userId, UUID companyId) { + return userAccountRepository.findByUserIdAndCompanyId(userId, companyId) + .orElseThrow(() -> new IllegalStateException("authenticated user account was not found")); + } + + @Transactional + public UserAccount updateProfile(UUID userId, UUID companyId, String displayName, String phone) { + UserAccount current = userAccountRepository.findByUserIdAndCompanyIdWithLock(userId, companyId) + .orElseThrow(() -> new IllegalStateException("authenticated user account was not found")); + UserAccount updated = current.updateProfile(displayName, phone, clock.instant()); + userAccountRepository.update(updated); + return updated; + } + public void logout(String rawRefreshToken) { if (!RefreshTokenFormat.isValidRawValue(rawRefreshToken)) { authAuditPort.record(AuthAuditEvent.anonymous( diff --git a/src/main/java/com/fowoco/server/auth/application/SignupCommand.java b/src/main/java/com/fowoco/server/auth/application/SignupCommand.java index cbfa6474..0d22664a 100644 --- a/src/main/java/com/fowoco/server/auth/application/SignupCommand.java +++ b/src/main/java/com/fowoco/server/auth/application/SignupCommand.java @@ -5,6 +5,7 @@ public record SignupCommand( String companyName, String displayName, + String phone, String email, String password, SignupAgreements agreements, diff --git a/src/main/java/com/fowoco/server/auth/application/SignupService.java b/src/main/java/com/fowoco/server/auth/application/SignupService.java index 1c914d05..8dc0ff6d 100644 --- a/src/main/java/com/fowoco/server/auth/application/SignupService.java +++ b/src/main/java/com/fowoco/server/auth/application/SignupService.java @@ -92,6 +92,7 @@ public SignupResult signup(SignupCommand command) { uuidGenerator.generate(), company.companyId(), command.displayName(), + command.phone(), command.email(), passwordHasher.hash(command.password()), UserRole.ADMIN, diff --git a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java index 747c74de..7f6ce9e8 100644 --- a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java +++ b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java @@ -10,10 +10,12 @@ public final class UserAccount { private static final int MAX_EMAIL_LENGTH = 254; private static final int MAX_DISPLAY_NAME_LENGTH = 80; private static final int MAX_PASSWORD_HASH_LENGTH = 255; + private static final int MAX_PHONE_LENGTH = 30; private final UUID userId; private final UUID companyId; private final String displayName; + private final String phone; private final String email; private final String normalizedEmail; private final String passwordHash; @@ -27,6 +29,7 @@ public UserAccount( UUID userId, UUID companyId, String displayName, + String phone, String email, String normalizedEmail, String passwordHash, @@ -39,6 +42,7 @@ public UserAccount( this.userId = Objects.requireNonNull(userId, "userId must not be null"); this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); this.displayName = requireDisplayName(displayName); + this.phone = requirePhone(phone); this.email = requireEmail(email); String expectedNormalizedEmail = normalizeEmail(this.email); if (!expectedNormalizedEmail.equals(normalizedEmail)) { @@ -63,6 +67,7 @@ public static UserAccount create( UUID userId, UUID companyId, String displayName, + String phone, String email, String passwordHash, UserRole role, @@ -73,6 +78,7 @@ public static UserAccount create( userId, companyId, displayName, + phone, email, normalizeEmail(email), passwordHash, @@ -105,6 +111,7 @@ public UserAccount changePassword(String newPasswordHash, Instant now) { userId, companyId, displayName, + phone, email, normalizedEmail, newPasswordHash, @@ -116,6 +123,27 @@ public UserAccount changePassword(String newPasswordHash, Instant now) { ); } + public UserAccount updateProfile(String newDisplayName, String newPhone, Instant now) { + Objects.requireNonNull(now, "now must not be null"); + if (now.isBefore(updatedAt)) { + throw new IllegalArgumentException("now must not be before updatedAt"); + } + return new UserAccount( + userId, + companyId, + newDisplayName, + newPhone, + email, + normalizedEmail, + passwordHash, + role, + status, + createdAt, + now, + version + 1 + ); + } + public UUID userId() { return userId; } @@ -128,6 +156,10 @@ public String displayName() { return displayName; } + public String phone() { + return phone; + } + public String email() { return email; } @@ -187,6 +219,23 @@ private static String requireDisplayName(String displayName) { return normalized; } + private static String requirePhone(String phone) { + if (phone == null) { + return null; + } + String stripped = phone.strip(); + if (stripped.isEmpty()) { + return null; + } + if (stripped.length() > MAX_PHONE_LENGTH) { + throw new IllegalArgumentException("phone must not exceed " + MAX_PHONE_LENGTH + " characters"); + } + if (!stripped.matches("^[0-9+()\\-\\s]+$")) { + throw new IllegalArgumentException("phone format is invalid"); + } + return stripped; + } + private static String requirePasswordHash(String passwordHash) { if (passwordHash == null || passwordHash.isBlank()) { throw new IllegalArgumentException("passwordHash must not be blank"); diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java index bb8039bd..f3425733 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java @@ -41,6 +41,9 @@ public class UserAccountJpaEntity { @Column(name = "display_name", nullable = false, length = 80) private String displayName; + @Column(name = "phone", length = 30) + private String phone; + @Column(name = "email", nullable = false, length = 254) private String email; @@ -75,6 +78,7 @@ private UserAccountJpaEntity( UUID userId, UUID companyId, String displayName, + String phone, String email, String normalizedEmail, String passwordHash, @@ -87,6 +91,7 @@ private UserAccountJpaEntity( this.userId = userId; this.companyId = companyId; this.displayName = displayName; + this.phone = phone; this.email = email; this.normalizedEmail = normalizedEmail; this.passwordHash = passwordHash; @@ -103,6 +108,7 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { userAccount.userId(), userAccount.companyId(), userAccount.displayName(), + userAccount.phone(), userAccount.email(), userAccount.normalizedEmail(), userAccount.passwordHash(), @@ -119,6 +125,7 @@ public UserAccount toDomain() { userId, companyId, displayName, + phone, email, normalizedEmail, passwordHash, @@ -135,6 +142,8 @@ void applyState(UserAccount userAccount) { if (!userId.equals(userAccount.userId()) || version + 1 != userAccount.version()) { throw new IllegalArgumentException("user account version transition is invalid"); } + displayName = userAccount.displayName(); + phone = userAccount.phone(); passwordHash = userAccount.passwordHash(); updatedAt = userAccount.updatedAt(); } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java index 566a783b..dceb07f3 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java @@ -169,6 +169,7 @@ private void seedUser(UUID companyId, DemoUser demoUser, Instant now) { demoUser.userId(), companyId, demoUser.displayName(), + null, demoUser.email(), passwordEncoder.encode(properties.adminPassword()), demoUser.role(), diff --git a/src/main/resources/db/migration/V47__add_user_account_phone.sql b/src/main/resources/db/migration/V47__add_user_account_phone.sql new file mode 100644 index 00000000..b68287d6 --- /dev/null +++ b/src/main/resources/db/migration/V47__add_user_account_phone.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_account + ADD COLUMN phone VARCHAR(30); diff --git a/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java b/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java index 0dbc74e3..505f362c 100644 --- a/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java +++ b/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java @@ -95,7 +95,9 @@ void signupSchemasUseSnakeCaseAndDoNotAcceptAuthorityOrExposeSecrets() { .contains("company_name", "display_name", "email", "password", "agreements"); assertThat(requestProperties.properties()) .extracting(java.util.Map.Entry::getKey) - .containsExactlyInAnyOrder("company_name", "display_name", "email", "password", "agreements"); + .containsExactlyInAnyOrder( + "company_name", "display_name", "phone", "email", "password", "agreements" + ); assertThat(request.at("/properties/password/minLength").asInt()).isEqualTo(8); assertThat(request.at("/properties/password/maxLength").asInt()).isEqualTo(128); assertThat(request.at("/properties/password/writeOnly").asBoolean()).isTrue(); diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index eaa904d5..4300e8a6 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -139,6 +139,7 @@ void refusesToStartWhenAReservedUserIdBelongsToAnotherEmail() { ADMIN_USER_ID, COMPANY_ID, "ID collision", + null, "different@example.com", passwordEncoder.encode(ADMIN_PASSWORD), UserRole.ADMIN, @@ -165,6 +166,7 @@ void refusesToStartWhenAReservedEmailBelongsToAnotherAccount() { UUID.fromString("aaaaaaaa-0000-0000-0000-000000000001"), TEST_COMPANY_ID, "Email collision", + null, ADMIN_EMAIL, passwordEncoder.encode(ADMIN_PASSWORD), UserRole.HR,