Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@
public abstract class BaseEntity {

@CreatedDate
@Column(updatable = false)
@Column(name = "create_at", updatable = false)
private LocalDateTime createAt;

@LastModifiedDate
@Column(name = "update_at")
private LocalDateTime updateAt;
Comment on lines +22 to 27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

DB 컬럼명 명시 변경에 따른 마이그레이션/쿼리 영향 점검 필요

@Column(name = "create_at"), @Column(name = "update_at")로 명시되면서, 과거에 네이밍 전략(default)로 매핑되던 컬럼명과 불일치할 수 있습니다. 기존 스키마/인덱스/뷰/네이티브 쿼리/ETL이 createAt 혹은 다른 스펠링을 참조하지 않는지 확인해 주세요. Flyway/Liquibase 마이그레이션도 함께 반영되어야 합니다.

다음 스크립트로 레포 내 잠재적 참조를 빠르게 점검할 수 있습니다:


🏁 Script executed:

#!/bin/bash
# create/update 컬럼 참조 탐지
rg -n -C2 -g '!**/build/**' -g '!**/target/**' \
  -e '\bcreateAt\b|\bupdateAt\b' \
  -e '\bcreate_at\b|\bupdate_at\b' \
  --type-add 'sql:*.sql'

Length of output: 6936


컬럼명 변경(createAt→create_at, updateAt→update_at) 반영 필수

BaseEntity에서 명시한 @Column(name = "create_at"), @Column(name = "update_at") 적용에 따라, 아래 항목들을 반드시 점검 및 수정해야 합니다.

  • 네이티브 SQL 쿼리

    • AiImagePermissionRepository.java (INSERT INTO ai_image_permissions … create_at, update_at)
    • FollowEntityRepository.java (INSERT INTO follows … create_at, update_at)
  • JPA 쿼리 및 메소드

    • PostReviewCommentRepository.java (c.createAt, ORDER BY c.createAt)
    • FollowEntityRepository.java (ORDER BY f.createAt)
  • DB 마이그레이션 스크립트

    • Flyway/Liquibase 스크립트에 컬럼명 변경(또는 alias) 반영
    • 기존 스키마·인덱스·뷰·ETL 파이프라인 등에서 createAt·updateAt 참조 여부 확인

위 변경 사항을 반영한 후, 로컬 및 CI 환경에서 컬럼명 불일치로 인한 쿼리 에러가 없는지 충분히 검증해 주세요.

🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/entity/BaseEntity.java around lines
22 to 27, the entity columns were renamed to create_at and update_at but related
code and SQL still reference camelCase createAt/updateAt; update all usages
accordingly: change native SQL INSERTs in AiImagePermissionRepository and
FollowEntityRepository to use create_at and update_at, revise JPA queries and
property references in PostReviewCommentRepository and FollowEntityRepository
(ORDER BY c.createAt / f.createAt) to match the mapped column names or use the
entity field names consistently, and update Flyway/Liquibase migration scripts
and any DB objects (indexes, views, ETL) to reflect the new column names (or add
aliases) and run local/CI validation to ensure no query failures remain.

}
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,12 @@ public enum ErrorCode {
AI_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "A-001", "해당 AI 요청을 찾을 수 없습니다."),
CREATED_AI_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "A-002", "생성된 AI 이미지를 찾을 수 없습니다."),
AI_IMAGE_DATA_INCONSISTENCY(HttpStatus.INTERNAL_SERVER_ERROR, "A-003", "AI 이미지 데이터 일관성 오류가 발생했습니다."),
AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.FORBIDDEN, "A-004", "AI 이미지 생성 권한이 없습니다."),
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "A-005", "티켓 처리에 실패했습니다."),
AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "AI-004", "AI 이미지 생성권을 구매한 이력이 없습니다."),
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "AI-005", "티켓 처리에 실패했습니다."),
AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION(HttpStatus.BAD_REQUEST, "AI-006", "티켓이 부족합니다."),
REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION(HttpStatus.BAD_REQUEST, "AI-007", "AI 이미지 생성권이 부족합니다."),
AI_IMAGE_PERMISSION_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "AI-008", "이미지 생성권 처리에 실패하였습니다."),
AI_IMAGE_PERMISSION_ALREADY_EXISTS_EXCEPTION(HttpStatus.CONFLICT, "AI-009", "이미 AI 이미지 생성권을 구매했습니다."),
;
Comment on lines +78 to 84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

AI 에러 코드(78~84) 추가/수정 전반은 타당. 다만 prefix/상태/중복 관점 점검 제안

  • 상태코드 매핑 적절성

    • 중복 구매: CONFLICT(409) → OK.
    • 권한 수량 부족: BAD_REQUEST(400) → 클라이언트 보정 대상이므로 OK.
    • 구매 이력 없음: NOT_FOUND(404) → 리소스 부재로 해석 가능하나, 기존 클라이언트가 403을 기대하던 영역인지 확인 바랍니다. 기존 계약과의 호환성 검증을 권합니다.
  • 코드 prefix 일관성

    • 상단에 A-001~A-003(예: AI_REQUEST_NOT_FOUND)이 남아있고, 신규는 AI-00x 패턴입니다. 도메인(“AI”) 기준으로 prefix 통일을 검토해 주세요. 광범위 영향이 있어선 롤링 변경(구 코드 유지 + 점진적 마이그레이션)을 권합니다.
  • 에러코드 중복 및 고유성 보장

    • 에러 코드 문자열의 전역 유일성 테스트를 추가하면 회귀를 방지할 수 있습니다. 아래 스크립트로 중복 여부를 빠르게 점검해 주세요.
  • 네이밍 컨벤션

    • 본 enum에는 ..._EXCEPTION 접미사 사용과 미사용이 혼재합니다(예: AI_REQUEST_NOT_FOUND vs USER_NOT_FOUND_EXCEPTION). 팀 컨벤션을 문서화해두면 추후 혼선을 줄일 수 있습니다.

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# 리포지토리 전역의 "대문자-세자리" 패턴 에러코드를 수집하고 중복을 출력
rg -nP --type=java -g '!**/build/**' -g '!**/out/**' -g '!**/target/**' '"[A-Z]+-\d{3}"' \
| sed -nE 's/.*"([A-Z]+-[0-9]{3})".*/\1/p' \
| sort | uniq -c | awk '$1>1 {printf("DUP\t%s\t(%d occurrences)\n",$2,$1)} END{if(NR==0) print "NO_CODES_FOUND"}'

Length of output: 423


글로벌 에러 코드 중복 발견 – 중복 해소 및 일관성 재검토가 필요합니다

  • 중복된 에러 코드 발견 (전역 "대문자-세자리" 패턴 기준)
    • A-001 (2회), A-002 (2회), C-001 (3회), C-002 (3회), F-001 (2회), U-002 (7회)
      → 각 코드를 유일하게 조정하고, CI 단계에서 회귀 방지를 위한 중복 검사(스크립트 자동화 등)를 반드시 도입하세요.
  • 상태 코드 매핑 검토 요청
    • AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "AI-004", ...)
      → 리소스 부재(404) 대신 권한 부족(403)을 기대하는 클라이언트가 있을 수 있으므로, 기존 계약과의 호환성을 검증 바랍니다.
  • 에러 코드 prefix 일관성
    • 기존 코드: A-001~A-003 (예: AI_REQUEST_NOT_FOUND)
    • 신규 코드: AI-004~AI-009
      → 도메인 기준(“AI”)으로 prefix 통일을 검토하고, 광범위 영향을 고려해 롤링 마이그레이션 전략을 권장합니다.
  • 네이밍 컨벤션 정리 (접미사 _EXCEPTION 사용 일관화)
    • 예: AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION vs REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION
      → 팀 차원의 문서화를 통해 앞으로 혼선을 방지하세요.
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/error/ErrorCode.java around lines
78-84, multiple global error-code collisions and naming/status inconsistencies
were reported: ensure each error code string is globally unique (resolve
duplicates A-001, A-002, C-001, C-002, F-001, U-002 by renumbering to unused
values), unify domain prefix to "AI-" for these new AI errors (or follow the
established domain-prefix convention across the enum), make status codes
consistent with contract (change AI_IMAGE_PERMISSION_NOT_FOUND from
HttpStatus.NOT_FOUND to HttpStatus.FORBIDDEN if the semantics are
permission-related—verify with API contract and update tests), standardize
constant names to use the chosen suffix convention (e.g., either include or
remove _EXCEPTION uniformly) and update any references, and add a CI check
(simple script to detect duplicate code strings) to prevent regressions.


private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package hanium.modic.backend.common.redis.distributedLock;

import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;

import org.redisson.RedissonMultiLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import hanium.modic.backend.common.error.exception.LockException;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class DistributionLockExecutor {

private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;


private final LockOptions DEFAULT_OPTS =
LockOptions.builder().waitTime(3).leaseTime(10).timeUnit(TimeUnit.SECONDS).build();

/** 단일 키 락 */
public void withLock(String key, Runnable block) throws LockException {
withLock(key, DEFAULT_OPTS, block);
}

public void withLock(String key, LockOptions opts, Runnable block) throws LockException {
RLock lock = redissonClient.getLock(key);
executeWith(lock, key, opts, block);
}

/** 다중 키 락(데드락 방지 위해 키 정렬 권장) */
public void withMultiLock(Collection<String> keys, Runnable block) throws LockException {
withMultiLock(keys, DEFAULT_OPTS, block);
}

public void withMultiLock(Collection<String> keys, LockOptions opts, Runnable block) throws LockException {
List<String> sorted = keys.stream().sorted().toList();
RLock[] locks = sorted.stream().map(redissonClient::getLock).toArray(RLock[]::new);
RedissonMultiLock multiLock = new RedissonMultiLock(locks);
executeWith(multiLock, sorted.toString(), opts, block);
}

/** 공통 실행부: tryLock → 트랜잭션 내 block.run() → 안전 unlock */
private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException {
boolean acquired = false;
try {
acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit());
if (!acquired) {
throw new LockException(new InterruptedException("락 획득 실패: " + logKey));
}
aopForTransaction.proceed(block);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockException(e);
} finally {
try {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (IllegalMonitorStateException ignored) {
log.info("락이 이미 해제됨: {}", logKey);
}
}
}
Comment on lines +52 to +73
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

임차(lease) 만료로 인한 조기 해제 위험 제거 + requiresNewTx 옵션 실제 반영.

  • 현재는 tryLock(wait, lease, unit)을 고정 사용하여 Redisson watchdog(자동 연장)이 비활성화됩니다. 블록이 10초(기본값)보다 오래 걸리면 락이 작업 중에 풀릴 수 있습니다.
  • LockOptions.requiresNewTx 필드가 정의됐으나 실행 경로에서 사용되지 않습니다(요약과 불일치).

아래처럼 수정하면

  • leaseTime <= 0인 경우 2-인자 tryLock을 사용해 watchdog을 활성화하고,
  • requiresNewTx가 true일 때만 REQUIRES_NEW 트랜잭션을 사용합니다. 그 외에는 현재 호출 스레드의 트랜잭션(있다면)에 참여합니다.
@@
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
@@
-    private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException {
+    private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException {
         boolean acquired = false;
         try {
-            acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit());
+            // leaseTime <= 0 이면 watchdog(자동 연장) 사용
+            if (opts.getLeaseTime() <= 0) {
+                acquired = lock.tryLock(opts.getWaitTime(), opts.getTimeUnit());
+            } else {
+                acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit());
+            }
             if (!acquired) {
-                throw new LockException(new InterruptedException("락 획득 실패: " + logKey));
+                throw new LockException(new TimeoutException(
+                    "락 획득 실패(대기 " + opts.getWaitTime() + " " + opts.getTimeUnit() + "): " + logKey));
             }
-            aopForTransaction.proceed(block);
+            if (opts.isRequiresNewTx()) {
+                aopForTransaction.proceed(block); // REQUIRES_NEW
+            } else {
+                // 기존 트랜잭션에 참여(또는 비트랜잭션 수행)
+                block.run();
+            }
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
             throw new LockException(e);
         } finally {
             try {
                 if (acquired && lock.isHeldByCurrentThread()) {
                     lock.unlock();
                 }
             } catch (IllegalMonitorStateException ignored) {
                 log.info("락이 이미 해제됨: {}", logKey);
             }
         }
     }

참고: requiresNewTx=false 경로는 호출부가 이미 @transactional이라면 그 트랜잭션에 자연스럽게 참여합니다. 필요 시 AopForTransaction에 REQUIRED 전파 메서드를 추가하는 대안도 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** 공통 실행부: tryLock → 트랜잭션 내 block.run() → 안전 unlock */
private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException {
boolean acquired = false;
try {
acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit());
if (!acquired) {
throw new LockException(new InterruptedException("락 획득 실패: " + logKey));
}
aopForTransaction.proceed(block);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockException(e);
} finally {
try {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (IllegalMonitorStateException ignored) {
log.info("락이 이미 해제됨: {}", logKey);
}
}
}
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** 공통 실행부: tryLock → 트랜잭션 내 block.run() → 안전 unlock */
private void executeWith(RLock lock, String logKey, LockOptions opts, Runnable block) throws LockException {
boolean acquired = false;
try {
// leaseTime <= 0 이면 watchdog(자동 연장) 사용
if (opts.getLeaseTime() <= 0) {
acquired = lock.tryLock(opts.getWaitTime(), opts.getTimeUnit());
} else {
acquired = lock.tryLock(opts.getWaitTime(), opts.getLeaseTime(), opts.getTimeUnit());
}
if (!acquired) {
throw new LockException(new TimeoutException(
"락 획득 실패(대기 " + opts.getWaitTime() + " " + opts.getTimeUnit() + "): " + logKey));
}
if (opts.isRequiresNewTx()) {
aopForTransaction.proceed(block); // REQUIRES_NEW
} else {
// 기존 트랜잭션에 참여(또는 비트랜잭션 수행)
block.run();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockException(e);
} finally {
try {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (IllegalMonitorStateException ignored) {
log.info("락이 이미 해제됨: {}", logKey);
}
}
}

}
Original file line number Diff line number Diff line change
@@ -1,148 +1,56 @@
package hanium.modic.backend.common.redis.distributedLock;

import static org.springframework.transaction.annotation.Propagation.*;

import java.util.List;
import java.util.concurrent.TimeUnit;

import org.redisson.RedissonMultiLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import hanium.modic.backend.common.error.exception.LockException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* Service 계층에서 Lock 제어를 위한 객체
* 내부적으로 DistributionLockExecutor로 락을 제어하고 있음.
* 새로운 도메인에 대한 락이 필요하면 이 객체를 수정하여 도입
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LockManager {

private final AopForTransaction aopForTransaction;
private final RedissonClient redissonClient;

private static final TimeUnit timeUnit = TimeUnit.SECONDS; // 락 시간 단위
private static final long waitTime = 5L; // 락 획득 대기 시간(5초)
private static final long leaseTime = 3L; // 락 유지 시간(3초)
private static final long postLikeWaitTime = 2L; // 좋아요 락 대기 시간(2초)
private static final long postLikeLeaseTime = 1L; // 좋아요 락 유지 시간(1초)
private static final String REDISSON_USER_LOCK_PREFIX = "USER_LOCK:";
private static final String REDISSON_POST_LIKE_LOCK_PREFIX = "POST_LIKE:";
private static final String REDISSON_USER_TICKET_LOCK_PREFIX = "USER_TICKET_LOCK:";
private final DistributionLockExecutor exec;

// 유저 단일 락
public void userLock(
final long userId,
Runnable block
) throws LockException {
String key = REDISSON_USER_LOCK_PREFIX + userId;
RLock rLock = redissonClient.getLock(key);
private final LockOptions POST_LIKE_OPTS =
LockOptions.builder().waitTime(1).leaseTime(3).timeUnit(TimeUnit.SECONDS).build();

try {
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
if (!available) {
throw new LockException(new InterruptedException("단일 락 획득 실패"));
}
private final String USER_PREFIX = "lock:user:";
private final String USER_TICKET_PREFIX = "lock:user:ticket:";
private final String POST_LIKE_PREFIX = "lock:postlike:";
private final String AI_PERMISSION_PREFIX = "lock:ai:perm:";

aopForTransaction.proceed(block); // lock 범위 안에서 트랜잭션 적용 후 로직 처리
} catch (InterruptedException e) {
throw new LockException(e); // 락 획득 실패시 Exception 발생
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("분산 락이 이미 해제되었습니다. :key({})", key);
}
}
public void userLock(long userId, Runnable block) throws LockException {
exec.withLock(USER_PREFIX + userId, block);
}

// 여러 유저 동시 락
@Transactional(propagation = REQUIRES_NEW)
public void multipleUserLock(
final List<Long> userIds,
Runnable block
) throws LockException {
List<String> sortedKeys = userIds.stream()
.sorted() // 데드락 방지용 정렬
.map(id -> REDISSON_USER_LOCK_PREFIX + id)
.toList();

List<RLock> locks = sortedKeys.stream()
.map(redissonClient::getLock)
public void multipleUserLock(List<Long> userIds, Runnable block) throws LockException {
List<String> keys = userIds.stream()
.map(id -> USER_PREFIX + id)
.toList();

RedissonMultiLock multiLock = new RedissonMultiLock(locks.toArray(new RLock[0]));

try {
boolean available = multiLock.tryLock(waitTime, leaseTime, timeUnit);
if (!available) {
throw new LockException(new InterruptedException("멀티 락 획득 실패"));
}

aopForTransaction.proceed(block); // lock 범위 안에서 트랜잭션 적용 후 로직 처리
} catch (InterruptedException e) {
throw new LockException(e); // 락 획득 실패시 Exception 발생
} finally {
try {
multiLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("멀티 분산 락이 이미 해제되었습니다. : keys={}", sortedKeys);
}
}
exec.withMultiLock(keys, block);
}

// 게시글 좋아요 락
public void postLikeLock(
final Long userId,
final Long postId,
Runnable block
) throws LockException {
String key = REDISSON_POST_LIKE_LOCK_PREFIX + userId + ":" + postId;
RLock rLock = redissonClient.getLock(key);

try {
boolean available = rLock.tryLock(postLikeWaitTime, postLikeLeaseTime, timeUnit);
if (!available) {
throw new LockException(new InterruptedException("좋아요 락 획득 실패"));
}

aopForTransaction.proceed(block); // lock 범위 안에서 트랜잭션 적용 후 로직 처리
} catch (InterruptedException e) {
throw new LockException(e); // 락 획득 실패시 Exception 발생
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("좋아요 분산 락이 이미 해제되었습니다. :key({})", key);
}
}
public void postLikeLock(long userId, long postId, Runnable block) throws LockException {
exec.withLock(POST_LIKE_PREFIX + userId + ":" + postId,
POST_LIKE_OPTS,
block);
}

// AI 요청 티켓 락
public void aiRequestTicketLock(
final Long userId,
Runnable block
) throws LockException {
String key = REDISSON_USER_TICKET_LOCK_PREFIX + userId;
RLock rLock = redissonClient.getLock(key);

try {
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
if (!available) {
throw new LockException(new InterruptedException("유저 티켓 락 획득 실패"));
}
public void aiRequestTicketLock(long userId, Runnable block) throws LockException {
exec.withLock(USER_TICKET_PREFIX + userId, block);
}

aopForTransaction.proceed(block); // lock 범위 안에서 트랜잭션 적용 후 로직 처리
} catch (InterruptedException e) {
throw new LockException(e); // 락 획득 실패시 Exception 발생
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("유저 티켓 분산 락이 이미 해제되었습니다. :key({})", key);
}
}
public void aiImagePermissionLock(long userId, long postId, Runnable block) throws LockException {
exec.withLock(AI_PERMISSION_PREFIX + userId + ":" + postId, block);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package hanium.modic.backend.common.redis.distributedLock;

import java.util.concurrent.TimeUnit;

import lombok.Builder;
import lombok.Getter;

/** Lock에 대한 세부 설정을 도와주는 객체 */
@Getter
@Builder
public class LockOptions {
private final long waitTime;
private final long leaseTime;
private final TimeUnit timeUnit;
private final boolean requiresNewTx; // 필요 시 트랜잭션 분리 제어(선택)
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
package hanium.modic.backend.domain.ai.entity;
package hanium.modic.backend.domain.ai.domain;

import static hanium.modic.backend.common.error.ErrorCode.*;

import hanium.modic.backend.common.entity.BaseEntity;
import hanium.modic.backend.common.error.exception.AppException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(name = "ai_image_permissions")
@Table(
name = "ai_image_permissions",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_ai_image_permission_user_post", // 제약조건 이름
columnNames = {"user_id", "post_id"} // 유니크 컬럼 지정
)
})
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand All @@ -31,36 +42,22 @@ public class AiImagePermissionEntity extends BaseEntity {
@Column(name = "remaining_generations", nullable = false)
private Integer remainingGenerations;

@Column(name = "is_active", nullable = false)
private Boolean isActive = true;

@Builder
private AiImagePermissionEntity(Long userId, Long postId, Integer remainingGenerations, Boolean isActive) {
private AiImagePermissionEntity(Long userId, Long postId, Integer remainingGenerations) {
this.userId = userId;
this.postId = postId;
this.remainingGenerations = remainingGenerations;
this.isActive = isActive != null ? isActive : true;
}

public void decreaseRemainingGenerations() {
if (this.remainingGenerations > 0) {
public void decreaseRemainingGenerations() throws AppException {
if (hasRemainingGenerations()) {
this.remainingGenerations--;
} else {
throw new AppException(REMAINING_GENERATIONS_NOT_ENOUGH_EXCEPTION);
}
}

public void deactivate() {
this.isActive = false;
}

public void activate() {
this.isActive = true;
}

public boolean hasRemainingGenerations() {
return this.remainingGenerations > 0;
}

public boolean isPermissionValid() {
return this.isActive && hasRemainingGenerations();
}
}
Loading