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
1 change: 1 addition & 0 deletions capturecat-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
implementation 'org.flywaydb:flyway-database-postgresql'
}
4 changes: 4 additions & 0 deletions capturecat-core/src/docs/asciidoc/error-codes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,7 @@ include::{snippets}/errorCode/deleteTag/error-codes.adoc[]
[[검색어-자동완성]]
=== 검색어 자동완성
include::{snippets}/errorCode/autocomplete/error-codes.adoc[]

[[유저-태그-생성]]
=== 유저 태그 생성
include::{snippets}/errorCode/createUserTag/error-codes.adoc[]
13 changes: 12 additions & 1 deletion capturecat-core/src/docs/asciidoc/user.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ operation::tutorialComplete[snippets='curl-request,http-request,request-headers,

<<error-codes#튜토리얼-완료-업데이트, 튜토리얼 완료 업데이트 API에서 발생할 수 있는 에러>>를 살펴보세요.


[[회원-탈퇴]]
=== 회원 탈퇴
소셜 서비스 연결 해제 후 회원 관련 데이터를 삭제 처리 합니다.
Expand All @@ -37,3 +36,15 @@ operation::userInfo[snippets='curl-request,http-request,request-headers,http-res
사용자 정보 조회가 실패했다면 HTTP 상태 코드와 함께 <<에러-객체-형식, 에러 객체>>가 돌아옵니다.

<<error-codes#사용자정보조회, 사용자 정보 조회 API에서 발생할 수 있는 에러>>를 살펴보세요.

[[유저-태그-생성]]
=== 유저 태그 생성
사용자의 태그를 생성합니다.

==== 성공
operation::createUserTag[snippets='curl-request,http-request,request-headers,query-parameters,http-response,response-fields']

==== 실패
유저 태그 생성이 실패했다면 HTTP 상태 코드와 함께 <<에러-객체-형식, 에러 객체>>가 돌아옵니다.

<<error-codes#유저-태그-생성, 유저 태그 생성 API에서 발생할 수 있는 에러>>를 살펴보세요.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.capturecat.core.api.user;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;

import com.capturecat.core.service.auth.LoginUser;
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.service.user.UserTagService;
import com.capturecat.core.support.response.ApiResponse;

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user-tags")
public class UserTagController {

private final UserTagService userTagService;

@PostMapping
public ApiResponse<TagResponse> create(@AuthenticationPrincipal LoginUser loginUser, @RequestParam String tagName) {
TagResponse tagResponse = userTagService.create(loginUser, tagName);

return ApiResponse.success(tagResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,15 @@ public List<Tag> registerTagsFor(List<String> tagNames) {
result.addAll(savedNewTags);
return result;
}

/**
* 등록되지 않은 태그는 등록하고, 이미 존재하는 태그는 그대로 반환합니다.
* @param tagName 태그 이름
* @return 조회되거나 새로 생성된 {@link Tag} 엔티티
*/
@Transactional
public Tag registerTagsFor(String tagName) {
return tagRepository.findByName(tagName)
.orElseGet(() -> tagRepository.save(new Tag(tagName)));
}
Comment on lines +48 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Make single-tag register resilient to concurrent inserts.
If another transaction inserts the same tag between find and save, re-read after the unique-constraint violation.

   @Transactional
   public Tag registerTagsFor(String tagName) {
-    return tagRepository.findByName(tagName)
-      .orElseGet(() -> tagRepository.save(new Tag(tagName)));
+    return tagRepository.findByName(tagName)
+      .orElseGet(() -> {
+        try {
+          return tagRepository.save(new Tag(tagName));
+        } catch (DataIntegrityViolationException e) {
+          // Another transaction likely created it; fetch and return.
+          return tagRepository.findByName(tagName).orElseThrow(() -> e);
+        }
+      });
   }

If not already present, ensure a unique index on tag.name (preferably case-insensitive) in Flyway migrations to make this effective.

To verify constraints exist in migrations, you can run:


🏁 Script executed:

#!/bin/bash
fd -t f -a 'V*__*.sql' capturecat-core/src/main/resources/db/migration
rg -nP 'create\s+table\s+tag|unique.*\(name\)|create\s+unique\s+index.*lower\(name\)' capturecat-core/src/main/resources/db/migration
rg -nP 'create\s+table\s+user_tag|unique.*\(user_id\s*,\s*tag_id\)|foreign\s+key' capturecat-core/src/main/resources/db/migration

Length of output: 475


Enforce unique tag.name constraint and make registerTagsFor concurrency-safe

  • In TagRegister.registerTagsFor, wrap tagRepository.save(new Tag(tagName)) in a try/catch for DataIntegrityViolationException and re-fetch the existing tag when a unique-constraint violation occurs (as in the suggested diff).
  • Add a Flyway migration to create a unique index on tag.name (preferably case-insensitive) to prevent duplicate inserts.
🤖 Prompt for AI Agents
In capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRegister.java
around lines 48 to 51, make registerTagsFor concurrency-safe by wrapping the
save(new Tag(tagName)) call in a try/catch for DataIntegrityViolationException:
if save throws that exception, re-query tagRepository.findByName(tagName) and
return the found Tag (or rethrow if still absent); this ensures concurrent
inserts return the single persisted entity. Also add a Flyway migration (SQL
file) that creates a unique index on the tag.name column (preferably
case-insensitive, e.g. using LOWER(name) or the DB-specific citext) so the
database enforces uniqueness and prevents duplicate inserts under race
conditions.

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.capturecat.core.domain.tag;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface TagRepository extends JpaRepository<Tag, Long>, TagCustomRepository {

List<Tag> findByNameIn(List<String> names);

Optional<Tag> findByName(String name);
Comment on lines 10 to +12
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enforce uniqueness on Tag.name (ideally case-insensitive) to make findByName reliable and avoid races.
Without a unique index, concurrent creates can produce duplicates and make findByName ambiguous.

Consider adding DB constraints (PostgreSQL examples):

-- strict case-sensitive uniqueness
ALTER TABLE tag ADD CONSTRAINT uk_tag_name UNIQUE (name);

-- or case-insensitive uniqueness
CREATE UNIQUE INDEX ux_tag_name_ci ON tag (lower(name));
🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRepository.java
around lines 10 to 12, the repository relies on findByName but the Tag.name
column lacks a uniqueness constraint which allows concurrent inserts to create
duplicates; add a database-level unique constraint (preferably case-insensitive,
e.g. unique index on lower(name)) via a new migration, update the Tag JPA entity
to reflect uniqueness if you use schema generation (or add a unique constraint
annotation), and handle potential race conditions by catching unique-constraint
violations in the create flow so duplicate insert attempts return the existing
Tag or retry/fail gracefully.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.capturecat.core.domain.user;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import com.capturecat.core.domain.BaseTimeEntity;
import com.capturecat.core.domain.tag.Tag;

@Entity
@Table(name = "user_tag",
uniqueConstraints = @UniqueConstraint(name = "uk_user_tag_user_tag", columnNames = {"user_id", "tag_id"})
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class UserTag extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
private Tag tag;

private UserTag(User user, Tag tag) {
this.user = user;
this.tag = tag;
}

public static UserTag create(User user, Tag tag) {
return new UserTag(user, tag);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.capturecat.core.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

import com.capturecat.core.domain.tag.Tag;

public interface UserTagRepository extends JpaRepository<UserTag, Long> {

boolean existsByUserAndTag(User user, Tag tag);

long countByUser(User user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.capturecat.core.service.user;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import com.capturecat.core.domain.tag.Tag;
import com.capturecat.core.domain.tag.TagRegister;
import com.capturecat.core.domain.user.User;
import com.capturecat.core.domain.user.UserRepository;
import com.capturecat.core.domain.user.UserTag;
import com.capturecat.core.domain.user.UserTagRepository;
import com.capturecat.core.service.auth.LoginUser;
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.support.error.CoreException;
import com.capturecat.core.support.error.ErrorType;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserTagService {

private static final int MAX_USER_TAG_COUNT = 30;

private final UserRepository userRepository;
private final UserTagRepository userTagRepository;
private final TagRegister tagRegister;

@Transactional
public TagResponse create(LoginUser loginUser, String tagName) {
try {
User user = userRepository.findByUsername(loginUser.getUsername())
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
Tag tag = tagRegister.registerTagsFor(tagName);

validate(user, tag);

userTagRepository.save(UserTag.create(user, tag));

return TagResponse.from(tag);
} catch (DataIntegrityViolationException ex) {
throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
}
Comment on lines +41 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Ensure DB constraints back these checks.

Validate that user_tag has (user_id, tag_id) unique + FKs and relevant indexes; also that tag.name is unique (TagRegister relies on it).


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "user_tag DDL:"
fd -a 'V*__*user_tag*.sql' capturecat-core/src/main/resources/db/migration | xargs -I{} sh -c 'echo "== {} =="; cat "{}"'
echo
echo "[Checks]"
rg -nP 'unique\s*\(\s*user_id\s*,\s*tag_id\s*\)' capturecat-core/src/main/resources/db/migration || echo "Missing composite UNIQUE (user_id, tag_id)"
rg -nP 'foreign key.*user_id' capturecat-core/src/main/resources/db/migration || echo "Missing FK for user_id"
rg -nP 'foreign key.*tag_id' capturecat-core/src/main/resources/db/migration || echo "Missing FK for tag_id"
rg -nP '(created_|last_modified_)' capturecat-core/src/main/resources/db/migration/*user_tag*.sql || echo "Missing auditing timestamps on user_tag"
rg -nP 'index.*user_id' capturecat-core/src/main/resources/db/migration/*user_tag*.sql || echo "Consider index on user_id"
rg -nP 'index.*tag_id' capturecat-core/src/main/resources/db/migration/*user_tag*.sql || echo "Consider index on tag_id"
echo
echo "tag DDL (checking unique name):"
fd -a 'V*__*.sql' capturecat-core/src/main/resources/db/migration | xargs rg -nP 'create\s+table.*tag|unique.*\(name\)' -n -C1

Length of output: 2614


Add UNIQUE constraint on tag.name
The tag table in V1__init.sql lacks a UNIQUE(name) constraint—TagRegister depends on name uniqueness. user_tag already has composite UNIQUE(user_id, tag_id), inline FKs, and auditing timestamps.

🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/service/user/UserTagService.java
around lines 41 to 46, the review flags that the tag table lacks a UNIQUE(name)
constraint required by TagRegister; update the DB migration so the tag.name
column is unique by either adding UNIQUE(name) in the tag table DDL in
V1__init.sql or creating a new migration that runs ALTER TABLE tag ADD
CONSTRAINT uq_tag_name UNIQUE(name); ensure the migration handles existing
duplicate tag rows (deduplicate or merge IDs and update referencing user_tag
rows) before adding the constraint and add/adjust tests to reflect the
uniqueness guarantee.

}
Comment on lines +32 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Catch DataIntegrityViolationException only around save().

Avoid mis-mapping integrity errors thrown by unrelated operations.

@@
-	@Transactional
-	public TagResponse create(LoginUser loginUser, String tagName) {
-		try {
-			User user = userRepository.findByUsername(loginUser.getUsername())
-				.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
-			Tag tag = tagRegister.registerTagsFor(tagName);
-
-			validate(user, tag);
-
-			userTagRepository.save(UserTag.create(user, tag));
-
-			return TagResponse.from(tag);
-		} catch (DataIntegrityViolationException ex) {
-			throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
-		}
-	}
+	@Transactional
+	public TagResponse create(LoginUser loginUser, String tagName) {
+		User user = userRepository.findByUsername(loginUser.getUsername())
+			.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
+		Tag tag = tagRegister.registerTagsFor(tagName);
+
+		validate(user, tag);
+
+		try {
+			userTagRepository.save(UserTag.create(user, tag));
+		} catch (DataIntegrityViolationException ex) {
+			throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
+		}
+
+		return TagResponse.from(tag);
+	}
📝 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
@Transactional
public TagResponse create(LoginUser loginUser, String tagName) {
try {
User user = userRepository.findByUsername(loginUser.getUsername())
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
Tag tag = tagRegister.registerTagsFor(tagName);
validate(user, tag);
userTagRepository.save(UserTag.create(user, tag));
return TagResponse.from(tag);
} catch (DataIntegrityViolationException ex) {
throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
}
}
@Transactional
public TagResponse create(LoginUser loginUser, String tagName) {
User user = userRepository.findByUsername(loginUser.getUsername())
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
Tag tag = tagRegister.registerTagsFor(tagName);
validate(user, tag);
try {
userTagRepository.save(UserTag.create(user, tag));
} catch (DataIntegrityViolationException ex) {
throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
}
return TagResponse.from(tag);
}
🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/service/user/UserTagService.java
around lines 32 to 47, the current try/catch wraps the entire create method
causing any DataIntegrityViolationException from unrelated calls to be mapped to
USER_TAG_ALREADY_EXISTS; narrow the try block so only the
userTagRepository.save(...) call is inside the try (or catch
DataIntegrityViolationException only around that single statement), leaving
repository lookups, tag registration and validation outside, and rethrow or let
other exceptions propagate unchanged.


private void validate(User user, Tag tag) {
validateDuplicateUserTag(user, tag);
validateUserTagCountLimit(user);
}

private void validateDuplicateUserTag(User user, Tag tag) {
if (userTagRepository.existsByUserAndTag(user, tag)) {
throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
}
}

private void validateUserTagCountLimit(User user) {
long userTagCount = userTagRepository.countByUser(user);

if (userTagCount >= MAX_USER_TAG_COUNT) {
throw new CoreException(ErrorType.TOO_MANY_USER_TAGS);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ public enum ErrorCode {
SOCIAL_API_ERROR("소셜 서비스 API 호출 결과 실패를 응답받았습니다."),
MISSING_PARAMETER("필수 파라미터 %s(이)가 누락되었습니다."),
INTERNAL_SERVER_ERROR("서버에서 오류가 발생했습니다."),
INVALID_LOGOUT_AUTH_TOKEN("ACCESS 토큰 또는 REFRESH 토큰이 유효하지 않습니다.");
INVALID_LOGOUT_AUTH_TOKEN("ACCESS 토큰 또는 REFRESH 토큰이 유효하지 않습니다."),
ALREADY_EXISTS_USER_TAG("이미 등록된 유저 태그입니다."),
EXCEED_MAX_USER_TAG_COUNT("태그는 한 계정당 최대 30개까지 추가할 수 있어요.");

private final String message;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ public enum ErrorType {
INVALID_LOGOUT_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, ErrorCode.INVALID_LOGOUT_AUTH_TOKEN, LogLevel.WARN),
BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND_BOOKMARK, LogLevel.WARN),
GENERATE_CLIENT_SECRET_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.GENERATE_CLIENT_SECRET_FAIL,
LogLevel.ERROR), UNLINK_SOCIAL_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.UNLINK_SOCIAL_FAIL, LogLevel.WARN),
LogLevel.ERROR),
UNLINK_SOCIAL_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.UNLINK_SOCIAL_FAIL, LogLevel.WARN),
FETCH_SOCIAL_TOKEN_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.FETCH_SOCIAL_TOKEN_FAIL, LogLevel.WARN),
SOCIAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.SOCIAL_API_ERROR, LogLevel.WARN),
MISSING_PARAMETER(HttpStatus.BAD_REQUEST, ErrorCode.MISSING_PARAMETER, LogLevel.WARN),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, LogLevel.ERROR);
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, LogLevel.ERROR),
USER_TAG_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, ErrorCode.ALREADY_EXISTS_USER_TAG, LogLevel.WARN),
TOO_MANY_USER_TAGS(HttpStatus.BAD_REQUEST, ErrorCode.EXCEED_MAX_USER_TAG_COUNT, LogLevel.WARN);

private final HttpStatus status;

Expand Down
87 changes: 87 additions & 0 deletions capturecat-core/src/main/resources/db/migration/V1__init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
create table if not exists users
(
id bigint not null primary key,
nickname varchar(50) not null,
email varchar(50) not null,
username varchar(50) not null unique,
password varchar(70),
role varchar(255) not null constraint users_role_check check (role in ('ADMIN', 'PREMIUM_USER', 'USER')),
tutorial_completed boolean not null,
provider varchar(255),
social_id varchar(255),
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null
);

create table if not exists user_social_account
(
id bigint not null primary key,
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null,
provider varchar(30) not null,
social_id varchar(100) not null,
unlink_key varchar(512),
user_id bigint not null constraint fk998rgv7jn090iyc77f8e1xsnq references users,
constraint uksj2lqxj8h0xuqf9v1dvtlkegt unique (provider, social_id)
);

create table if not exists refresh_token
(
id bigint not null primary key,
refresh_token_expiration bigint not null,
expiration varchar(255),
refresh_token varchar(255),
username varchar(255)
);
Comment on lines +28 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

refresh_token: ambiguous expirations and missing FK to user.

  • Two expiration columns conflict; keep one.
  • Add FK to users(username) to prevent orphans.
  • Consider identity PK.
-create table if not exists refresh_token
+create table if not exists refresh_token
 (
-    id                       bigint not null primary key,
-    refresh_token_expiration bigint not null,
-    expiration               varchar(255),
+    id                       bigint generated by default as identity primary key,
+    refresh_token_expiration bigint not null,
     refresh_token            varchar(255),
-    username                 varchar(255)
+    username                 varchar(50) not null,
+    constraint fk_refresh_token_user_username foreign key (username) references users(username) on delete cascade
 );

If you truly need two expirations, rename with clear semantics.

🤖 Prompt for AI Agents
In capturecat-core/src/main/resources/db/migration/V1__init.sql around lines
28–35, the refresh_token table currently has two ambiguous expiration columns
and lacks a foreign key to users; remove the redundant expiration varchar column
and keep a single clearly typed expiration (use refresh_token_expiration
bigint), change id to an identity/auto-increment column (e.g., BIGINT GENERATED
ALWAYS AS IDENTITY) instead of a plain bigint PK, ensure username is NOT NULL if
appropriate, and add an explicit foreign key constraint referencing
users(username) (optionally with ON DELETE CASCADE or RESTRICT based on desired
behavior); if you truly need two expirations, rename them to explicit semantic
names (e.g., issued_at_ts and expires_at_ts) instead of keeping generic
"expiration".


create table if not exists withdraw_log
(
id bigint generated by default as identity primary key,
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null,
image_cleanup_status varchar(255) not null constraint withdraw_log_image_cleanup_status_check check (image_cleanup_status in ('PENDING', 'DONE', 'FAILED')),
reason text,
user_id bigint not null
);
Comment on lines +37 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

withdraw_log: add FK for user_id.

Currently no FK; risks orphaned rows.

 create table if not exists withdraw_log
 (
     id                   bigint generated by default as identity primary key,
     created_date         timestamp(6) not null,
     last_modified_date   timestamp(6) not null,
     image_cleanup_status varchar(255) not null constraint withdraw_log_image_cleanup_status_check check (image_cleanup_status in ('PENDING', 'DONE', 'FAILED')),
     reason               text,
-    user_id              bigint       not null
+    user_id              bigint       not null constraint fk_withdraw_log_user references users
 );

Optionally add on delete cascade depending on business rules.

🤖 Prompt for AI Agents
In capturecat-core/src/main/resources/db/migration/V1__init.sql around lines 37
to 45, the withdraw_log table lacks a foreign key on user_id which can lead to
orphaned rows; add a foreign key constraint like "constraint
withdraw_log_user_id_fkey foreign key (user_id) references users(id)" and decide
whether to append "on delete cascade" per business rules (or use "on delete
restrict" if cascading deletes are undesired), ensuring the constraint name and
referenced table/column match your schema.


create index if not exists idx_withdraw_log_user_id on withdraw_log (user_id);

create index if not exists idx_withdraw_log_created_date on withdraw_log (created_date);

create index if not exists idx_withdraw_log_cleanup_status on withdraw_log (image_cleanup_status, created_date);

create table if not exists images
(
id bigint generated by default as identity primary key,
size bigint,
file_name varchar(255),
file_url varchar(255),
capture_date date,
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null,
user_id bigint constraint fk13ljqfrfwbyvnsdhihwta8cpr references users
);

create table if not exists tag
(
id bigint generated by default as identity primary key,
name varchar(255),
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null
);

create table if not exists image_tag
(
id bigint generated by default as identity primary key,
image_id bigint constraint fk6q9wuvp5j846qtqod6xu3gma1 references images,
tag_id bigint constraint fk28yowgjl7oksr7dc0wj7f5il references tag,
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null
);
Comment on lines +73 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

image_tag: prevent duplicates and add indexes.

  • Add unique (image_id, tag_id).
  • Index both FKs for joins.
  • Make FKs NOT NULL.
 create table if not exists image_tag
 (
     id                 bigint generated by default as identity primary key,
-    image_id           bigint constraint fk6q9wuvp5j846qtqod6xu3gma1 references images,
-    tag_id             bigint constraint fk28yowgjl7oksr7dc0wj7f5il references tag,
+    image_id           bigint not null constraint fk6q9wuvp5j846qtqod6xu3gma1 references images,
+    tag_id             bigint not null constraint fk28yowgjl7oksr7dc0wj7f5il references tag,
     created_date       timestamp(6) not null,
     last_modified_date timestamp(6) not null
 );
+
+create unique index if not exists uk_image_tag_image_tag on image_tag (image_id, tag_id);
+create index if not exists idx_image_tag_image_id on image_tag (image_id);
+create index if not exists idx_image_tag_tag_id on image_tag (tag_id);
📝 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
create table if not exists image_tag
(
id bigint generated by default as identity primary key,
image_id bigint constraint fk6q9wuvp5j846qtqod6xu3gma1 references images,
tag_id bigint constraint fk28yowgjl7oksr7dc0wj7f5il references tag,
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null
);
create table if not exists image_tag
(
id bigint generated by default as identity primary key,
image_id bigint not null constraint fk6q9wuvp5j846qtqod6xu3gma1 references images,
tag_id bigint not null constraint fk28yowgjl7oksr7dc0wj7f5il references tag,
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null
);
create unique index if not exists uk_image_tag_image_tag on image_tag (image_id, tag_id);
create index if not exists idx_image_tag_image_id on image_tag (image_id);
create index if not exists idx_image_tag_tag_id on image_tag (tag_id);
🤖 Prompt for AI Agents
In capturecat-core/src/main/resources/db/migration/V1__init.sql around lines 73
to 80, modify the image_tag table definition to make image_id and tag_id NOT
NULL, add a unique constraint on (image_id, tag_id) to prevent duplicate
mappings, and add separate indexes on image_id and tag_id to speed joins;
implement this by altering the CREATE TABLE to declare image_id and tag_id as
NOT NULL, adding UNIQUE(image_id, tag_id) (or a named constraint), and adding
CREATE INDEX statements for image_id and tag_id after the table creation.


create table if not exists bookmark
(
id bigint generated by default as identity primary key,
image_id bigint constraint fkpowbsxsu0qwcon1yoxbsqkw4w references images,
user_id bigint constraint fko4vbqvq5trl11d85bqu5kl870 references users
);
Comment on lines +82 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

bookmark: prevent duplicates and add indexes.

Enforce one bookmark per (user, image) and speed lookups.

 create table if not exists bookmark
 (
     id       bigint generated by default as identity primary key,
     image_id bigint constraint fkpowbsxsu0qwcon1yoxbsqkw4w references images,
     user_id  bigint constraint fko4vbqvq5trl11d85bqu5kl870 references users
 );
+
+create unique index if not exists uk_bookmark_user_image on bookmark (user_id, image_id);
+create index if not exists idx_bookmark_image_id on bookmark (image_id);
+create index if not exists idx_bookmark_user_id on bookmark (user_id);
🤖 Prompt for AI Agents
In capturecat-core/src/main/resources/db/migration/V1__init.sql around lines 82
to 87, the bookmark table allows duplicate (user_id, image_id) pairs and lacks
indexes for fast lookups; add a UNIQUE constraint on (user_id, image_id) to
enforce one bookmark per user-image and create B-tree indexes on user_id and
image_id (or a composite index on (user_id, image_id) if preferred) to speed
queries; update the migration by altering the CREATE TABLE to include the
UNIQUE(user_id, image_id) clause and add CREATE INDEX statements for user_id and
image_id (or a single composite index) after the table creation, ensuring
constraint and index names follow project naming conventions.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
create table if not exists user_tag
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid IF NOT EXISTS in versioned migrations.

Flyway migrations should be deterministic; IF NOT EXISTS can hide drift and let incompatible schemas slip through.

-create table if not exists user_tag
+create table user_tag

If the table might already exist in some envs, prefer a separate repeatable migration or conditional precheck script, not IF NOT EXISTS in V2.

📝 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
create table if not exists user_tag
create table user_tag
🤖 Prompt for AI Agents
In capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql
around line 1, the migration uses "IF NOT EXISTS" which hides schema drift and
breaks Flyway determinism; remove the "IF NOT EXISTS" clause so the CREATE TABLE
fails if the table already exists, and instead handle environments where the
table may already exist by adding either (a) a separate repeatable migration
that safely creates/maintains the table, or (b) a one-time precheck script that
conditionally creates the table before running versioned migrations; ensure the
V2 migration contains a plain CREATE TABLE statement and update migration
tracking accordingly.

(
id bigint generated by default as identity primary key,
user_id bigint constraint fk_user_tag_user references users,
tag_id bigint constraint fk_user_tag_tag references tag,
Comment on lines +4 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

FKs should be NOT NULL and consider ON DELETE CASCADE.

A user_tag without user/tag is invalid. Also consider cascading deletes to avoid orphans when a user/tag is removed.

Option A (edit V2 before merge):

-    user_id            bigint constraint fk_user_tag_user references users,
-    tag_id             bigint constraint fk_user_tag_tag references tag,
+    user_id            bigint not null
+                       constraint fk_user_tag_user references users(id) on delete cascade,
+    tag_id             bigint not null
+                       constraint fk_user_tag_tag references tag(id) on delete cascade,

If V2 may already be applied locally, create V3 with:

alter table user_tag
  alter column user_id set not null,
  alter column tag_id set not null;
alter table user_tag
  drop constraint if exists fk_user_tag_user,
  add constraint fk_user_tag_user foreign key (user_id) references users(id) on delete cascade;
alter table user_tag
  drop constraint if exists fk_user_tag_tag,
  add constraint fk_user_tag_tag foreign key (tag_id) references tag(id) on delete cascade;
🤖 Prompt for AI Agents
In capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql
around lines 4-5, the user_id and tag_id foreign keys are nullable and lack
cascade behavior; make both columns NOT NULL and update the fk_user_tag_user and
fk_user_tag_tag constraints to reference the correct parent id columns and
include ON DELETE CASCADE. If you can edit V2 before merging, change the column
definitions to NOT NULL and alter the FK definitions to add ON DELETE CASCADE;
if V2 may already be applied, add a new migration (V3) that alters the user_tag
table to set both columns NOT NULL and replaces the existing fk constraints with
versions that reference users(id) and tag(id) and include ON DELETE CASCADE.

created_date timestamp(6) not null,
last_modified_date timestamp(6) not null,
constraint uk_user_tag_user_tag unique (user_id, tag_id)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.capturecat.core.api.user;

import static com.capturecat.core.support.error.ErrorType.TOO_MANY_USER_TAGS;
import static com.capturecat.core.support.error.ErrorType.USER_NOT_FOUND;
import static com.capturecat.core.support.error.ErrorType.USER_TAG_ALREADY_EXISTS;

import java.util.List;

import org.junit.jupiter.api.Test;

import com.capturecat.core.api.error.ErrorCodeDocumentTest;
import com.capturecat.test.snippet.ErrorCodeDescriptor;

class UserErrorCodeControllerTest extends ErrorCodeDocumentTest {

@Test
void 유저_태그_생성_에러_코드_문서화() {
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(USER_TAG_ALREADY_EXISTS,
TOO_MANY_USER_TAGS, USER_NOT_FOUND);
generateErrorDocs("errorCode/createUserTag", errorCodeDescriptors);
}
}
Loading