Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ public enum ExceptionType {
// NOT_FOUND(404)
NOT_FOUND(HttpStatus.NOT_FOUND, "๋ฐ์ดํ„ฐ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ"),
NOT_FOUND_USER(HttpStatus.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"),

// Conflict(409)
CONFLICT_DUPLICATE(HttpStatus.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค"),
USER_ALREADY_WITHDRAWN(HttpStatus.CONFLICT, "์ด๋ฏธ ํƒˆํ‡ดํ•œ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค"),

// Internal Server Error(500)
SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์„œ๋ฒ„ ์—๋Ÿฌ"),
Expand Down
135 changes: 135 additions & 0 deletions src/main/java/swyp/dodream/common/snowflake/SnowflakeIdGenerator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package swyp.dodream.common.snowflake;

import org.springframework.stereotype.Component;

import java.time.Instant;

// Snowflake ID ์ƒ์„ฑ๊ธฐ
//
// Snowflake ์•Œ๊ณ ๋ฆฌ์ฆ˜ ๊ตฌ์กฐ:
// [1๋น„ํŠธ: ๋ถ€ํ˜ธ][41๋น„ํŠธ: ํƒ€์ž„์Šคํƒฌํ”„][10๋น„ํŠธ: ๋…ธ๋“œ ID][12๋น„ํŠธ: ์‹œํ€€์Šค ๋ฒˆํ˜ธ]
//
// - ํƒ€์ž„์Šคํƒฌํ”„: ์ˆœ์ฐจ์„ฑ ๋ณด์žฅ
// - ๋…ธ๋“œ ID: ๋ถ„์‚ฐ ํ™˜๊ฒฝ์—์„œ ๊ณ ์œ ์„ฑ ๋ณด์žฅ
// - ์‹œํ€€์Šค ๋ฒˆํ˜ธ: ๋™์ผ ๋ฐ€๋ฆฌ์ดˆ ๋‚ด ๊ณ ์œ ์„ฑ ๋ณด์žฅ
@Component
public class SnowflakeIdGenerator {

// Snowflake ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์ƒ์ˆ˜
private static final long EPOCH = 1735689600000L; // 2025-01-01 00:00:00 UTC
private static final long NODE_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;

// ์ตœ๋Œ€๊ฐ’ ๊ณ„์‚ฐ
private static final long MAX_NODE_ID = (1L << NODE_ID_BITS) - 1;
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;

// ๋น„ํŠธ ์‹œํ”„ํŠธ ๊ฐ’
private static final long NODE_ID_SHIFT = SEQUENCE_BITS;
private static final long TIMESTAMP_SHIFT = NODE_ID_BITS + SEQUENCE_BITS;

// ๋…ธ๋“œ ID (ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋˜๋Š” ์„ค์ •์—์„œ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์Œ)
private final long nodeId;

// ์‹œํ€€์Šค ๋ฒˆํ˜ธ์™€ ๋งˆ์ง€๋ง‰ ํƒ€์ž„์Šคํƒฌํ”„
private long sequence = 0L;
private long lastTimestamp = -1L;

public SnowflakeIdGenerator() {
// ๊ธฐ๋ณธ ๋…ธ๋“œ ID (์‹ค์ œ ํ™˜๊ฒฝ์—์„œ๋Š” ํ™˜๊ฒฝ๋ณ€์ˆ˜๋‚˜ ์„ค์ •์—์„œ ๊ฐ€์ ธ์™€์•ผ ํ•จ)
this.nodeId = 1L;

if (nodeId > MAX_NODE_ID || nodeId < 0) {
throw new IllegalArgumentException("Node ID must be between 0 and " + MAX_NODE_ID);
}
}

public SnowflakeIdGenerator(long nodeId) {
this.nodeId = nodeId;

if (nodeId > MAX_NODE_ID || nodeId < 0) {
throw new IllegalArgumentException("Node ID must be between 0 and " + MAX_NODE_ID);
}
}

// ๋‹ค์Œ Snowflake ID ์ƒ์„ฑ
public synchronized long nextId() {
long timestamp = getCurrentTimestamp();

// ์‹œ๊ณ„๊ฐ€ ๋’ค๋กœ ๊ฐ€๋Š” ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id for " +
(lastTimestamp - timestamp) + " milliseconds");
}

// ๊ฐ™์€ ๋ฐ€๋ฆฌ์ดˆ ๋‚ด์—์„œ ์‹œํ€€์Šค ์ฆ๊ฐ€
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0L) {
// ์‹œํ€€์Šค๊ฐ€ ์ตœ๋Œ€๊ฐ’์— ๋„๋‹ฌํ•˜๋ฉด ๋‹ค์Œ ๋ฐ€๋ฆฌ์ดˆ๊นŒ์ง€ ๋Œ€๊ธฐ
timestamp = waitNextMillis(lastTimestamp);
}
} else {
// ์ƒˆ๋กœ์šด ๋ฐ€๋ฆฌ์ดˆ์ด๋ฏ€๋กœ ์‹œํ€€์Šค ์ดˆ๊ธฐํ™”
sequence = 0L;
}

lastTimestamp = timestamp;

// Snowflake ID ์กฐํ•ฉ
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT) |
(nodeId << NODE_ID_SHIFT) |
sequence;
}

/**
* ํ˜„์žฌ ํƒ€์ž„์Šคํƒฌํ”„ ๊ฐ€์ ธ์˜ค๊ธฐ
*/
private long getCurrentTimestamp() {
return Instant.now().toEpochMilli();
}

/**
* ๋‹ค์Œ ๋ฐ€๋ฆฌ์ดˆ๊นŒ์ง€ ๋Œ€๊ธฐ
*/
private long waitNextMillis(long lastTimestamp) {
long timestamp = getCurrentTimestamp();
while (timestamp <= lastTimestamp) {
timestamp = getCurrentTimestamp();
}
return timestamp;
}

/**
* Snowflake ID์—์„œ ํƒ€์ž„์Šคํƒฌํ”„ ์ถ”์ถœ
*/
public long extractTimestamp(long snowflakeId) {
return (snowflakeId >> TIMESTAMP_SHIFT) + EPOCH;
}

/**
* Snowflake ID์—์„œ ๋…ธ๋“œ ID ์ถ”์ถœ
*/
public long extractNodeId(long snowflakeId) {
return (snowflakeId >> NODE_ID_SHIFT) & MAX_NODE_ID;
}

/**
* Snowflake ID์—์„œ ์‹œํ€€์Šค ๋ฒˆํ˜ธ ์ถ”์ถœ
*/
public long extractSequence(long snowflakeId) {
return snowflakeId & MAX_SEQUENCE;
}

/**
* Snowflake ID ์ •๋ณด ๋””๋ฒ„๊น…์šฉ
*/
public String parseSnowflakeId(long snowflakeId) {
long timestamp = extractTimestamp(snowflakeId);
long nodeId = extractNodeId(snowflakeId);
long sequence = extractSequence(snowflakeId);

return String.format("SnowflakeId[%d] -> Timestamp[%d] NodeId[%d] Sequence[%d]",
snowflakeId, timestamp, nodeId, sequence);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package swyp.dodream.common.snowflake;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

// Snowflake ID ์„œ๋น„์Šค
// ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ Snowflake ID๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๋Š” ์„œ๋น„์Šค
@Service
@RequiredArgsConstructor
public class SnowflakeIdService {

private final SnowflakeIdGenerator snowflakeIdGenerator;

// ์ƒˆ๋กœ์šด Snowflake ID ์ƒ์„ฑ
public Long generateId() {
return snowflakeIdGenerator.nextId();
}

// Snowflake ID ์ •๋ณด ํŒŒ์‹ฑ (๋””๋ฒ„๊น…์šฉ)
public String parseId(Long id) {
if (id == null) {
return "null";
}
return snowflakeIdGenerator.parseSnowflakeId(id);
}

// Snowflake ID์—์„œ ํƒ€์ž„์Šคํƒฌํ”„ ์ถ”์ถœ
public Long extractTimestamp(Long id) {
if (id == null) {
return null;
}
return snowflakeIdGenerator.extractTimestamp(id);
}

// Snowflake ID์—์„œ ๋…ธ๋“œ ID ์ถ”์ถœ
public Long extractNodeId(Long id) {
if (id == null) {
return null;
}
return snowflakeIdGenerator.extractNodeId(id);
}

// Snowflake ID์—์„œ ์‹œํ€€์Šค ๋ฒˆํ˜ธ ์ถ”์ถœ
public Long extractSequence(Long id) {
if (id == null) {
return null;
}
return snowflakeIdGenerator.extractSequence(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package swyp.dodream.domain.profile.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import swyp.dodream.domain.profile.dto.ProfileCreateRequest;
import swyp.dodream.domain.profile.dto.ProfileResponse;
import swyp.dodream.domain.profile.dto.ProfileUpdateRequest;
import swyp.dodream.domain.profile.service.ProfileService;
import swyp.dodream.jwt.dto.UserPrincipal;

@RestController
@RequestMapping("/api/profiles")
@RequiredArgsConstructor
@Tag(name = "Profile", description = "ํ”„๋กœํ•„ ๊ด€๋ฆฌ API")
public class ProfileController {

private final ProfileService profileService;

@Operation(summary = "ํ”„๋กœํ•„ ์ƒ์„ฑ", description = "์˜จ๋ณด๋”ฉ์„ ํ†ตํ•ด ํ”„๋กœํ•„์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค")
@PostMapping
public ResponseEntity<ProfileResponse> createProfile(
Authentication authentication,
@RequestBody ProfileCreateRequest request) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Long userId = userPrincipal.getUserId();

ProfileResponse response = profileService.createProfile(userId, request);
return ResponseEntity.ok(response);
}

@Operation(summary = "ํ”„๋กœํ•„ ์กฐํšŒ", description = "ํ˜„์žฌ ์‚ฌ์šฉ์ž์˜ ํ”„๋กœํ•„์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค")
@GetMapping
public ResponseEntity<ProfileResponse> getProfile(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Long userId = userPrincipal.getUserId();

ProfileResponse response = profileService.getProfile(userId);
return ResponseEntity.ok(response);
}

@Operation(summary = "ํ”„๋กœํ•„ ์ˆ˜์ •", description = "ํ”„๋กœํ•„ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค")
@PutMapping
public ResponseEntity<ProfileResponse> updateProfile(
Authentication authentication,
@RequestBody ProfileUpdateRequest request) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Long userId = userPrincipal.getUserId();

ProfileResponse response = profileService.updateProfile(userId, request);
return ResponseEntity.ok(response);
}

@Operation(summary = "ํ”„๋กœํ•„ ์‚ญ์ œ", description = "ํ”„๋กœํ•„์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค")
@DeleteMapping
public ResponseEntity<String> deleteProfile(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Long userId = userPrincipal.getUserId();

profileService.deleteProfile(userId);
return ResponseEntity.ok("ํ”„๋กœํ•„์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค");
}
}
108 changes: 108 additions & 0 deletions src/main/java/swyp/dodream/domain/profile/domain/Profile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package swyp.dodream.domain.profile.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import swyp.dodream.domain.profile.enums.*;
import swyp.dodream.domain.url.domain.ProfileUrl;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "profiles")
@EntityListeners(AuditingEntityListener.class)
@Getter
@NoArgsConstructor
public class Profile {

@Id
private Long id;

@Column(name = "user_id", nullable = false, unique = true)
private Long userId;

@Column(nullable = false, unique = true, length = 10)
private String nickname;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Gender gender = Gender.์„ ํƒ์•ˆํ•จ;

@Enumerated(EnumType.STRING)
@Column(name = "age_band", nullable = false)
private AgeBand ageBand = AgeBand.์„ ํƒ์•ˆํ•จ;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Experience experience;

@Enumerated(EnumType.STRING)
@Column(name = "activity_mode", nullable = false)
private ActivityMode activityMode;

@Column(name = "intro_text", length = 200)
private String introText;

@Column(name = "intro_is_ai", nullable = false)
private Boolean introIsAi = false;

@Column(name = "is_public", nullable = false)
private Boolean isPublic = true;

@OneToMany(mappedBy = "profile", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<ProfileUrl> profileUrls = new ArrayList<>();

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;

public Profile(Long userId, String nickname, Experience experience, ActivityMode activityMode) {
this.userId = userId;
this.nickname = nickname;
this.experience = experience;
this.activityMode = activityMode;
}

// Snowflake ID๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์ƒ์„ฑ์ž
public Profile(Long id, Long userId, String nickname, Experience experience, ActivityMode activityMode) {
this.id = id;
this.userId = userId;
this.nickname = nickname;
this.experience = experience;
this.activityMode = activityMode;
}

// ์ „์ฒด ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ
public void updateProfile(String nickname, Gender gender, AgeBand ageBand,
Experience experience, ActivityMode activityMode,
String introText, Boolean introIsAi, Boolean isPublic) {
this.nickname = nickname;
this.gender = gender;
this.ageBand = ageBand;
this.experience = experience;
this.activityMode = activityMode;
this.introText = introText;
this.introIsAi = introIsAi;
this.isPublic = isPublic;
}

// ProfileUrl ๊ด€๋ฆฌ ๋ฉ”์„œ๋“œ๋“ค
public void addProfileUrl(ProfileUrl profileUrl) {
this.profileUrls.add(profileUrl);
profileUrl.setProfile(this);
}

public void removeProfileUrl(ProfileUrl profileUrl) {
this.profileUrls.remove(profileUrl);
profileUrl.setProfile(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package swyp.dodream.domain.profile.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import swyp.dodream.domain.profile.enums.*;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ProfileCreateRequest {
private String nickname;
private Gender gender;
private AgeBand ageBand;
private Experience experience;
private ActivityMode activityMode;
private String introText;
private Boolean introIsAi;
private Boolean isPublic;
}
Loading