Skip to content

Conversation

@leeedohyun
Copy link
Member

@leeedohyun leeedohyun commented Aug 30, 2025

📌 관련 이슈 (필수)

이 PR이 어떤 이슈를 해결하는지 작성해주세요. 예시: closes #123, resolves #456

📝 작업 내용 (필수)

이번 PR에서 작업한 내용을 간략히 설명해주세요.

  • 유저 태그 도메인을 설계 및 유저 태그 생성 API를 구현했습니다.
  • user_tag 테이블을 생성하기 위해 flyway를 도입했습니다.
  • 기존 테이블은 운영 DB에서 ddl을 추출해서 그대로 사용했는데, 혹시나 잘못된 부분이 있는지 확인부탁드립니다.

💬 리뷰 참고 사항 (선택)

리뷰어가 참고할 만한 사항이나 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요.

일단 개발계로 배포하는 깃허브 액션 워크플로우를 리뷰 끝나도 돌리려고 종료를 했습니다.

Summary by CodeRabbit

  • New Features

    • User tag management: create tags, associate tags to users, enforce uniqueness and a 30-tag per account limit.
    • Public POST endpoint to add a user tag; returns tag info on success.
  • Chores

    • Added DB migrations and initial schema plus Flyway PostgreSQL dependency; test migrations disabled.
  • Documentation

    • API and error-code docs for user-tag creation.
  • Tests

    • Unit and REST-doc tests for tag registration, controller, service, fixtures, and error-code documentation.

@leeedohyun leeedohyun self-assigned this Aug 30, 2025
@coderabbitai
Copy link

coderabbitai bot commented Aug 30, 2025

Walkthrough

Adds Flyway PostgreSQL dependency; introduces initial and user_tag DB migrations; implements UserTag domain (entity, repository), TagRegister single-tag registration, UserTagService, UserTagController, error codes/types, tests/fixtures, and API/docs; disables Flyway during tests.

Changes

Cohort / File(s) Summary
Build: Flyway dependency
capturecat-core/build.gradle
Adds implementation dependency org.flywaydb:flyway-database-postgresql to enable Flyway DB migrations for PostgreSQL.
DB Migrations: Initial schema
capturecat-core/src/main/resources/db/migration/V1__init.sql
Adds initial schema: users, user_social_account, refresh_token, withdraw_log (with indexes), images, tag, image_tag, and bookmark with PKs, FKs, uniques, checks, and audit columns.
DB Migrations: User-Tag mapping
capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql
Adds user_tag table with identity id, user_id FK, tag_id FK, audit timestamps, and unique constraint on (user_id,tag_id).
Domain: UserTag entity
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java
New JPA entity UserTag linking User and Tag (LAZY ManyToOne), identity id, protected ctor and factory create(user, tag), table-level unique constraint.
Repository: UserTag & Tag
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTagRepository.java, capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRepository.java
Adds UserTagRepository extends JpaRepository with existsByUserAndTag and countByUser; adds Optional<Tag> findByName(String) to TagRepository.
Domain logic: TagRegister
capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRegister.java
Adds registerTagsFor(String tagName) to return existing Tag or persist a new one (transactional).
Service: UserTagService
capturecat-core/src/main/java/com/capturecat/core/service/user/UserTagService.java
New transactional service create(LoginUser, String) — fetches user, registers/fetches tag, validates duplicates and max (30), saves UserTag, maps DataIntegrityViolationException to USER_TAG_ALREADY_EXISTS.
API: Controller
capturecat-core/src/main/java/com/capturecat/core/api/user/UserTagController.java
New REST endpoint POST /v1/user-tags accepting tagName and returning ApiResponse<TagResponse> via UserTagService.
Error Definitions
capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorCode.java, .../ErrorType.java
Adds ALREADY_EXISTS_USER_TAG, EXCEED_MAX_USER_TAG_COUNT to ErrorCode; adds USER_TAG_ALREADY_EXISTS, TOO_MANY_USER_TAGS to ErrorType.
Tests: Unit & Docs
capturecat-core/src/test/.../UserTagServiceTest.java, .../UserTagControllerTest.java, .../UserErrorCodeControllerTest.java, .../TagRegisterTest.java, .../UserTagFixture.java
Adds service/controller tests, error-code doc test, TagRegister single-tag tests, and a UserTag fixture.
Docs: API & Error docs
capturecat-core/src/docs/asciidoc/user.adoc, .../error-codes.adoc, src/docs/asciidoc/createUserTag/*
Adds user-tag creation API docs and includes error-code documentation for user-tag creation.
Test config: disable Flyway
capturecat-core/src/test/resources/application.yml
Sets spring.flyway.enabled: false for tests to disable migrations during test runs.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client as Client
    participant Controller as UserTagController
    participant Service as UserTagService
    participant UserRepo as UserRepository
    participant TagRegister as TagRegister
    participant UserTagRepo as UserTagRepository
    participant DB as Database

    Client->>Controller: POST /v1/user-tags?tagName=java (Bearer)
    Controller->>Service: create(loginUser, "java")
    Service->>UserRepo: findByUsername(loginUser.username)
    UserRepo-->>Service: Optional<User>
    Service->>TagRegister: registerTagsFor("java")
    TagRegister->>DB: select/insert tag by name
    DB-->>TagRegister: Tag
    Service->>UserTagRepo: existsByUserAndTag(user, tag)
    UserTagRepo-->>Service: boolean
    alt not exists and count < 30
        Service->>UserTagRepo: save(new UserTag(user, tag))
        UserTagRepo->>DB: insert user_tag
        DB-->>UserTagRepo: persisted UserTag
        Service-->>Controller: TagResponse
    else duplicate or limit exceeded
        Service-->>Controller: throw CoreException (mapped ErrorType)
    end
    Controller-->>Client: ApiResponse<TagResponse>
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • jaelyangChoi

Poem

"I hopped through SQL and schema rows,
I nibbled tags where metadata grows.
Flyway fluttered, migrations sing,
Users and tags now join in spring.
Tests applauded — carrot code, hooray!" 🥕🐇


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e64fe37 and 9eb0a3e.

📒 Files selected for processing (1)
  • capturecat-core/build.gradle (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • capturecat-core/build.gradle
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build
  • GitHub Check: deploy-dev
✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/#120-user-tag

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
capturecat-core/src/main/resources/db/migration/V1__init.sql (5)

1-14: users.id should be identity for consistency and easier inserts.

Other tables mostly use identity. Recommend aligning users.id to identity to avoid manual key management.

Apply:

-    id                 bigint       not null primary key,
+    id                 bigint generated by default as identity primary key,

37-45: Add FK for withdraw_log.user_id.

Currently it’s an unbounded bigint. Add FK for referential integrity.

-    user_id              bigint       not null
+    user_id              bigint       not null constraint fk_withdraw_log_user references users

65-71: Enforce tag uniqueness.

Tags typically require unique, non-null names.

-    name               varchar(255),
+    name               varchar(255) not null,
+    constraint uk_tag_name unique (name),

73-80: Prevent duplicate image-tag pairs and enforce NOT NULL.

Join table should be constrained to unique pairs.

-    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
+    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,
+    constraint uk_image_tag unique (image_id, tag_id)

Optionally add indexes:

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);

82-87: Prevent duplicate bookmarks and enforce NOT NULL.

Same rationale as image_tag.

-    image_id bigint constraint fkpowbsxsu0qwcon1yoxbsqkw4w references images,
-    user_id  bigint constraint fko4vbqvq5trl11d85bqu5kl870 references users
+    image_id bigint not null constraint fkpowbsxsu0qwcon1yoxbsqkw4w references images,
+    user_id  bigint not null constraint fko4vbqvq5trl11d85bqu5kl870 references users,
+    constraint uk_bookmark unique (image_id, user_id)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 90b3c29 and f65b9d6.

📒 Files selected for processing (4)
  • capturecat-core/build.gradle (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java (1 hunks)
  • capturecat-core/src/main/resources/db/V2__create_user_tag.sql (1 hunks)
  • capturecat-core/src/main/resources/db/migration/V1__init.sql (1 hunks)
🔇 Additional comments (2)
capturecat-core/build.gradle (1)

32-32: Flyway dependency addition looks good.

Core dependency is sufficient for Spring Boot auto-migration at runtime.

capturecat-core/src/main/resources/db/V2__create_user_tag.sql (1)

1-8: Idempotency and environment safety.

Since Flyway runs scripts once, embedding the unique constraint in CREATE TABLE is fine. If you keep separate ALTERs in future, avoid IF NOT EXISTS on constraints (PostgreSQL lacks it); use CREATE TABLE-time constraints or a DO-block guard.

Do you want me to generate a guarded ALTER template for future migrations?

Comment on lines 16 to 29
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserTag extends BaseTimeEntity {

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

@ManyToOne(fetch = FetchType.LAZY)
private User user;

@ManyToOne(fetch = FetchType.LAZY)
private Tag tag;
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Map table/columns explicitly and enforce uniqueness at the JPA level.

Avoid reliance on naming strategies; add unique pair constraint and non-null FKs.

 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.ManyToOne;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;

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

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

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

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

-	@ManyToOne(fetch = FetchType.LAZY)
-	private User user;
+	@ManyToOne(fetch = FetchType.LAZY, optional = false)
+	@JoinColumn(name = "user_id", nullable = false)
+	private User user;

-	@ManyToOne(fetch = FetchType.LAZY)
-	private Tag tag;
+	@ManyToOne(fetch = FetchType.LAZY, optional = false)
+	@JoinColumn(name = "tag_id", nullable = false)
+	private Tag tag;
 }

Optional: add ON DELETE CASCADE in SQL (see V2 comment) to auto-clean rows on user/tag deletion.

📝 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
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserTag extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
private Tag tag;
}
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.ManyToOne;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.Getter;
import com.capturecat.core.domain.BaseTimeEntity;
import com.capturecat.core.domain.tag.Tag;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "user_tag",
uniqueConstraints = @UniqueConstraint(
name = "uk_user_tag_user_tag",
columnNames = {"user_id", "tag_id"}
)
)
@Getter
public class UserTag extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "tag_id", nullable = false)
private Tag tag;
}
🤖 Prompt for AI Agents
In capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java
around lines 16-29, the entity relies on implicit naming and lacks a JPA-level
uniqueness and non-null constraint for the user/tag relationship; update the
entity mapping to explicitly set the table and column names and enforce
uniqueness and non-null foreign keys by adding a @Table(name="user_tag",
uniqueConstraints=@UniqueConstraint(columnNames={"user_id","tag_id"})), annotate
both relations with @ManyToOne(fetch = FetchType.LAZY) and explicit
@JoinColumn(name="user_id", nullable=false,
foreignKey=@ForeignKey(name="fk_user_tag_user")), and @JoinColumn(name="tag_id",
nullable=false, foreignKey=@ForeignKey(name="fk_user_tag_tag")); if using
Hibernate and you want DB-level ON DELETE CASCADE behavior, add @OnDelete(action
= OnDeleteAction.CASCADE) on the associations or ensure your migration/DDL adds
ON DELETE CASCADE for these FKs.

Comment on lines 1 to 8
create table if not exists user_tag
(
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,
created_date timestamp(6) not null,
last_modified_date timestamp(6) not null,
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Place the migration under Flyway’s default location.

V1 is under db/migration, but V2 is under db/. Flyway (Spring Boot default) scans classpath:db/migration; V2 will be skipped.

Move file to:

  • capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql
🤖 Prompt for AI Agents
capturecat-core/src/main/resources/db/V2__create_user_tag.sql lines 1-8: the
migration is placed in db/ instead of Flyway’s default classpath location
db/migration so Flyway will skip V2; move the file to
capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql (keeping
filename intact) so Spring Boot/Flyway will detect and run it.

⚠️ Potential issue

Fix SQL syntax and add constraints/indexes.

There’s a missing comma after user_id and a trailing comma before the closing parenthesis. Also add NOT NULL, unique pair, and helpful indexes.

-create table if not exists user_tag
-(
-    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,
-    created_date       timestamp(6) not null,
-    last_modified_date timestamp(6) not null,
-);
+create table if not exists user_tag
+(
+    id                 bigint generated by default as identity primary key,
+    user_id            bigint not null constraint fk_user_tag_user references users on delete cascade,
+    tag_id             bigint not null constraint fk_user_tag_tag references tag 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)
+);
+
+create index if not exists idx_user_tag_user_id on user_tag (user_id);
+create index if not exists idx_user_tag_tag_id on user_tag (tag_id);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In capturecat-core/src/main/resources/db/V2__create_user_tag.sql around lines 1
to 8, fix the SQL syntax (add the missing comma after user_id and remove the
trailing comma before the closing parenthesis), mark user_id and tag_id NOT
NULL, add a UNIQUE constraint on (user_id, tag_id), explicitly declare foreign
key constraints referencing the correct parent tables, and add indexes for
user_id and tag_id to improve lookup performance.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
capturecat-core/src/test/resources/application.yml (1)

38-40: Tests won’t catch DDL drift; consider running Flyway in tests.

Disabling Flyway means tests use Hibernate DDL, not your migrations. Prefer running migrations so constraints/indexes match prod.

Option A (recommended): enable Flyway and stop Hibernate DDL.

 spring:
-  flyway:
-    enabled: false
+  flyway:
+    enabled: true
   jpa:
     hibernate:
-      ddl-auto: create-drop
+      ddl-auto: none

Option B: keep disabled, but add schema.sql aligned to migrations.

capturecat-core/src/main/resources/db/migration/V1__init.sql (5)

1-14: users: consider identity PK, unique email, longer password.

  • Identity PK eases inserts.
  • Email likely needs uniqueness.
  • Password 70 chars may be tight for Argon2/other encodings.
-create table if not exists users
+create table if not exists users
 (
-    id                 bigint       not null primary key,
+    id                 bigint generated by default as identity primary key,
     nickname           varchar(50)  not null,
-    email              varchar(50)  not null,
+    email              varchar(254) not null unique,
     username           varchar(50)  not null unique,
-    password           varchar(70),
+    password           varchar(255),
     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
 );

53-64: images: index/NOT NULL on user_id; longer file_url.

  • FK columns aren’t auto-indexed in Postgres; add index.
  • If every image belongs to a user, set NOT NULL.
  • 255 may be short for URLs; suggest 1024.
 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),
+    file_url           varchar(1024),
     capture_date       date,
     created_date       timestamp(6) not null,
     last_modified_date timestamp(6) not null,
-    user_id            bigint constraint fk13ljqfrfwbyvnsdhihwta8cpr references users
+    user_id            bigint not null constraint fk13ljqfrfwbyvnsdhihwta8cpr references users
 );
+
+create index if not exists idx_images_user_id on images (user_id);

65-71: tag: enforce unique (prefer case-insensitive) name.

Avoid duplicate tags differing by case.

create unique index if not exists uk_tag_name_lower on tag (lower(name));

1-88: Migrations: avoid IF NOT EXISTS in versioned scripts; use Flyway baseline.

Using IF NOT EXISTS can hide drift; Flyway already prevents re-execution. For existing prod schemas, prefer baselining (spring.flyway.baseline-on-migrate=true) over IF NOT EXISTS.


1-88: Timestamps: consider timestamptz.

If you need absolute instants across time zones, use timestamp with time zone.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f65b9d6 and 1702c6c.

📒 Files selected for processing (2)
  • capturecat-core/src/main/resources/db/migration/V1__init.sql (1 hunks)
  • capturecat-core/src/test/resources/application.yml (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build

Comment on lines +28 to +35
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)
);
Copy link

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".

Comment on lines +37 to +45
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
);
Copy link

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.

Comment on lines +73 to +80
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
);
Copy link

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.

Comment on lines +82 to +87
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
);
Copy link

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java (2)

13-24: Expose getters for persistence/domain use.

Without accessors, consumers must use reflection or package access. Add Lombok getters.

 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
+import lombok.Getter;
@@
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
-public class UserTag extends BaseTimeEntity {
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class UserTag extends BaseTimeEntity {

30-36: Make FKs non-null at JPA level and align with DB constraints.

Both relationships are optional by default and columns are nullable. Given this is a pure join entity, allow neither to be null to prevent bad rows and to match the migration you intend.

Apply:

 import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ForeignKey;
@@
-	@ManyToOne(fetch = FetchType.LAZY)
-	@JoinColumn(name = "user_id")
+	@ManyToOne(fetch = FetchType.LAZY, optional = false)
+	@JoinColumn(name = "user_id", nullable = false,
+		foreignKey = @ForeignKey(name = "fk_user_tag_user"))
 	private User user;
@@
-	@ManyToOne(fetch = FetchType.LAZY)
-	@JoinColumn(name = "tag_id")
+	@ManyToOne(fetch = FetchType.LAZY, optional = false)
+	@JoinColumn(name = "tag_id", nullable = false,
+		foreignKey = @ForeignKey(name = "fk_user_tag_tag"))
 	private Tag tag;
🧹 Nitpick comments (2)
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java (1)

26-36: Consider equals/hashCode for collections/Set semantics.

If you’ll store UserTag in Sets or compare instances, implement equals/hashCode (prefer id fallback, or business key (user, tag) before persist). If not needed now, skip.

capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql (1)

8-9: Add indexes for common lookups.

Typical queries filter by user_id or tag_id. Add supporting non-unique indexes.

     constraint uk_user_tag_user_tag unique (user_id, tag_id)
 );
+create index if not exists idx_user_tag_user_id on user_tag(user_id);
+create index if not exists idx_user_tag_tag_id on user_tag(tag_id);

If you remove IF NOT EXISTS globally, drop it here too and gate with separate migrations.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1702c6c and 25891a8.

📒 Files selected for processing (2)
  • capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java (1 hunks)
  • capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql (1 hunks)
🔇 Additional comments (2)
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java (1)

19-23: JPA uniqueness is good; ensure DB matches and add guard at service level.

The unique pair constraint is correct. Ensure service layer handles DuplicateKey/DataIntegrityViolationException to return 409 or idempotent behavior on duplicates.

capturecat-core/src/main/resources/db/migration/V2__create_user_tag.sql (1)

3-8: Referenced tables/PKs match base schema; timestamp(6) usage is consistent.

Comment on lines 1 to 18
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.NoArgsConstructor;

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

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

Confirm BaseTimeEntity column names match DDL (created_date/last_modified_date).

If BaseTimeEntity uses camelCase without @column overrides, naming strategy must map to snake_case. Otherwise inserts will fail.

Run:


🏁 Script executed:

#!/bin/bash
# Verify BaseTimeEntity column mappings
fd -t f -a 'BaseTimeEntity.java' | xargs -I{} sh -c '
  echo "== {} ==";
  rg -n "createdDate|lastModifiedDate|@Column" -n {} -C2
'
# Check application naming strategy
rg -n "spring.jpa.hibernate.naming" -C2 **/application*.yml **/application*.properties || true

Length of output: 678


Explicitly define snake_case column names for audit fields
In BaseTimeEntity.java, annotate the fields with name overrides to match the DDL:

  • Line 21:
    @Column(name = "created_date", nullable = false, updatable = false)
  • Line 25:
    @Column(name = "last_modified_date", nullable = false)
🤖 Prompt for AI Agents
In capturecat-core/src/main/java/com/capturecat/core/domain/BaseTimeEntity.java
around lines 21 to 25, the audit timestamp fields must map to snake_case column
names; add @Column(name = "created_date", nullable = false, updatable = false)
to the createdDate field and @Column(name = "last_modified_date", nullable =
false) to the lastModifiedDate field, and ensure jakarta.persistence.Column is
imported so the JPA column name overrides compile.

@@ -0,0 +1,9 @@
create table if not exists user_tag
Copy link

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.

Comment on lines +4 to +5
user_id bigint constraint fk_user_tag_user references users,
tag_id bigint constraint fk_user_tag_tag references tag,
Copy link

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.

@leeedohyun leeedohyun changed the title feat: 유저 태그 도메인 설계 feat: 유저 태그 도메인 설계 및 유저 태그 생성 API Aug 31, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRegister.java (2)

27-39: Guard empty input, de-duplicate names, and avoid saveAll on empty.
Prevents unnecessary queries and duplicate inserts when input contains repeated names.

   @Transactional
   public List<Tag> registerTagsFor(List<String> tagNames) {
-    Map<String, Tag> existingTagsByName = tagRepository.findByNameIn(tagNames).stream()
+    if (tagNames == null || tagNames.isEmpty()) {
+      return List.of();
+    }
+    List<String> distinctTagNames = tagNames.stream().distinct().toList();
+
+    Map<String, Tag> existingTagsByName = tagRepository.findByNameIn(distinctTagNames).stream()
       .collect(Collectors.toMap(Tag::getName, Function.identity()));
 
-    List<Tag> newTags = tagNames.stream()
+    List<Tag> newTags = distinctTagNames.stream()
       .filter(tagName -> !existingTagsByName.containsKey(tagName))
       .map(Tag::new)
       .toList();
 
-    List<Tag> savedNewTags = tagRepository.saveAll(newTags);
+    List<Tag> savedNewTags = newTags.isEmpty() ? List.of() : tagRepository.saveAll(newTags);
     List<Tag> result = new ArrayList<>(existingTagsByName.values());
     result.addAll(savedNewTags);
     return result;
   }

9-14: Prepare to handle unique-constraint races when creating a single tag.
Catching DataIntegrityViolationException makes the “check-then-insert” path safe under concurrency.

+ import org.springframework.dao.DataIntegrityViolationException;
   import org.springframework.transaction.annotation.Transactional;
🧹 Nitpick comments (15)
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTagRepository.java (2)

9-11: Consider ID-based queries to avoid loading entities.

If callers often have IDs, adding these can cut one query per call:

 public interface UserTagRepository extends JpaRepository<UserTag, Long> {
   boolean existsByUserAndTag(User user, Tag tag);
   long countByUser(User user);
+  boolean existsByUserIdAndTagId(Long userId, Long tagId);
+  long countByUserId(Long userId);
 }

7-11: DB constraints and indexes: double-check the migration.

To backstop service-level checks and keep queries fast:

  • Unique index on (user_id, tag_id) in this order (supports countByUser).
  • Foreign keys: user_id, tag_id with ON DELETE CASCADE/RESTRICT per domain rule.
  • Secondary index on tag_id if you’ll query by tag.
  • created_date/last_modified_date nullability consistent with JPA auditing.
capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorCode.java (1)

42-44: Minor wording consistency (optional).

Project mixes “유저/사용자/회원” and punctuation across messages; consider standardizing later for UX polish.

capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorType.java (1)

47-48: Status choice check (optional).

400 is fine; consider 409 (duplicate) and 422 (limit exceeded) if you prefer more specific semantics.

capturecat-core/src/test/java/com/capturecat/core/api/user/UserTagControllerTest.java (1)

56-63: Clarify Authorization header format in REST Docs.
Explicitly indicate the Bearer prefix to reduce ambiguity for API consumers.

-               requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("유효한 Access 토큰")),
+               requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} 형식의 유효한 Access 토큰")),
capturecat-core/src/test/java/com/capturecat/core/domain/tag/TagRegisterTest.java (2)

86-87: Adjust expectation if saveAll is skipped when no new tags.
If you adopt the early-return/skip-empty change, this test should assert no call to saveAll.

-    verify(tagRepository, times(1)).saveAll(Collections.emptyList());
+    verify(tagRepository, never()).saveAll(anyList());

30-48: Add a test covering duplicate input names.
Ensures only one tag is saved when the request contains duplicates.

Example to add:

@Test
void 요청에_중복된_태그명이_포함된_경우_한번만_저장한다() {
    List<String> tagNames = List.of("dup", "dup", "new");
    given(tagRepository.findByNameIn(anyList())).willReturn(Collections.emptyList());
    given(tagRepository.saveAll(anyList()))
        .willAnswer(inv -> inv.getArgument(0)); // echo saved list

    List<Tag> result = tagRegister.registerTagsFor(tagNames);

    verify(tagRepository, times(1)).saveAll(argThat(list ->
        list.stream().map(Tag::getName).collect(Collectors.toSet()).size() == list.size()
    ));
    assertThat(result).extracting(Tag::getName).containsExactlyInAnyOrder("dup", "new");
}
capturecat-core/src/test/java/com/capturecat/core/domain/user/UserTagFixture.java (1)

7-14: Make the fixture class non-instantiable.
Minor test hygiene for utility classes.

-public class UserTagFixture {
+public final class UserTagFixture {
+  private UserTagFixture() {}
 
   public static UserTag createUserTag(Long id, User user, Tag tag) {
     UserTag userTag = UserTag.create(user, tag);
     ReflectionTestUtils.setField(userTag, "id", id);
     return userTag;
   }
 }
capturecat-core/src/main/java/com/capturecat/core/api/user/UserTagController.java (3)

9-14: Enforce input validation for tagName (reject blanks).

Add validation to prevent empty/blank tag names at the boundary.

@@
-import lombok.RequiredArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import jakarta.validation.constraints.NotBlank;
+import org.springframework.validation.annotation.Validated;
@@
 @RestController
 @RequiredArgsConstructor
+@Validated
 @RequestMapping("/v1/user-tags")
 public class UserTagController {
@@
-  public ApiResponse<TagResponse> create(@AuthenticationPrincipal LoginUser loginUser, @RequestParam String tagName) {
+  public ApiResponse<TagResponse> create(@AuthenticationPrincipal LoginUser loginUser,
+                                         @RequestParam @NotBlank String tagName) {

Also applies to: 16-19, 24-28


3-7: Guard the endpoint explicitly.

Ensure auth is enforced at the method. If already covered by WebSecurity config, ignore.

@@
-import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.access.prepost.PreAuthorize;
@@
-  @PostMapping
+  @PostMapping
+  @PreAuthorize("isAuthenticated()")
   public ApiResponse<TagResponse> create(…

Also applies to: 23-26


11-14: Decouple TagResponse from image package.

Importing TagResponse from service.image is leaky. Consider relocating TagResponse to a tag/user-tag API package and update this import.

capturecat-core/src/test/java/com/capturecat/core/service/user/UserTagServiceTest.java (3)

68-79: Also assert no tag lookup when user missing.

Avoid unnecessary downstream calls when user isn’t found.

@@
 		// when & then
 		assertThatThrownBy(() -> userTagService.create(new LoginUser(DummyObject.newUser("test")), "java"))
 			.isInstanceOf(CoreException.class)
 			.hasMessage(ErrorType.USER_NOT_FOUND.getCode().getMessage());
 
 		verify(userTagRepository, never()).save(any());
+		verify(tagRegister, never()).registerTagsFor(anyString());

81-97: Ensure count check isn’t hit on duplicate.

Short-circuit after duplicate detection.

@@
 		assertThatThrownBy(() -> userTagService.create(new LoginUser(DummyObject.newUser("test")), "java"))
 			.isInstanceOf(CoreException.class)
 			.hasMessage(ErrorType.USER_TAG_ALREADY_EXISTS.getCode().getMessage());
 
 		verify(userTagRepository, never()).save(any());
+		verify(userTagRepository, never()).countByUser(any());

1-20: Add a test for DB unique-constraint conflicts → mapped error.

Covers the DataIntegrityViolationException path.

@@
 package com.capturecat.core.service.user;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
@@
 }
 
@@
 	}
 
+	@Test
+	void 유저_태그_생성_시_DB_유니크_제약조건_충돌을_에러로_매핑한다() {
+		// given
+		var user = DummyObject.newUser("test");
+		var tag = TagFixture.createTag(1L, "java");
+
+		given(userRepository.findByUsername(anyString())).willReturn(Optional.of(user));
+		given(tagRegister.registerTagsFor(anyString())).willReturn(tag);
+		given(userTagRepository.existsByUserAndTag(eq(user), eq(tag))).willReturn(false);
+		given(userTagRepository.countByUser(eq(user))).willReturn(0L);
+		given(userTagRepository.save(any())).willThrow(new DataIntegrityViolationException("duplicate key"));
+
+		// when & then
+		assertThatThrownBy(() -> userTagService.create(new LoginUser(user), "java"))
+			.isInstanceOf(CoreException.class)
+			.hasMessage(ErrorType.USER_TAG_ALREADY_EXISTS.getCode().getMessage());
+	}

Also applies to: 116-116

capturecat-core/src/main/java/com/capturecat/core/service/user/UserTagService.java (1)

3-9: Make the max tag count configurable (no magic number).

Keeps limits flexible per environment.

@@
-import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.beans.factory.annotation.Value;
@@
-	private static final int MAX_USER_TAG_COUNT = 30;
+	@Value("${capturecat.user-tag.max-count:30}")
+	private int maxUserTagCount;
@@
-		if (userTagCount >= MAX_USER_TAG_COUNT) {
+		if (userTagCount >= maxUserTagCount) {
 			throw new CoreException(ErrorType.TOO_MANY_USER_TAGS);
 		}

Also applies to: 26-27, 60-66

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 25891a8 and bbe6383.

📒 Files selected for processing (15)
  • capturecat-core/src/docs/asciidoc/error-codes.adoc (1 hunks)
  • capturecat-core/src/docs/asciidoc/user.adoc (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/api/user/UserTagController.java (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRegister.java (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRepository.java (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTagRepository.java (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/service/user/UserTagService.java (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorCode.java (1 hunks)
  • capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorType.java (1 hunks)
  • capturecat-core/src/test/java/com/capturecat/core/api/user/UserErrorCodeControllerTest.java (1 hunks)
  • capturecat-core/src/test/java/com/capturecat/core/api/user/UserTagControllerTest.java (1 hunks)
  • capturecat-core/src/test/java/com/capturecat/core/domain/tag/TagRegisterTest.java (2 hunks)
  • capturecat-core/src/test/java/com/capturecat/core/domain/user/UserTagFixture.java (1 hunks)
  • capturecat-core/src/test/java/com/capturecat/core/service/user/UserTagServiceTest.java (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • capturecat-core/src/docs/asciidoc/error-codes.adoc
🚧 Files skipped from review as they are similar to previous changes (1)
  • capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTag.java
🧰 Additional context used
🧬 Code graph analysis (4)
capturecat-core/src/test/java/com/capturecat/core/api/user/UserTagControllerTest.java (1)
capturecat-tests/api-docs/src/main/java/com/capturecat/test/api/RestDocsUtil.java (1)
  • RestDocsUtil (7-22)
capturecat-core/src/test/java/com/capturecat/core/service/user/UserTagServiceTest.java (3)
capturecat-core/src/test/java/com/capturecat/core/DummyObject.java (1)
  • DummyObject (18-100)
capturecat-core/src/test/java/com/capturecat/core/domain/tag/TagFixture.java (1)
  • TagFixture (5-16)
capturecat-core/src/test/java/com/capturecat/core/domain/user/UserTagFixture.java (1)
  • UserTagFixture (7-14)
capturecat-core/src/test/java/com/capturecat/core/api/user/UserErrorCodeControllerTest.java (1)
capturecat-core/src/test/java/com/capturecat/core/api/error/ErrorCodeDocumentTest.java (1)
  • ErrorCodeDocumentTest (18-40)
capturecat-core/src/test/java/com/capturecat/core/domain/tag/TagRegisterTest.java (1)
capturecat-core/src/test/java/com/capturecat/core/domain/tag/TagFixture.java (1)
  • TagFixture (5-16)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (11)
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTagRepository.java (1)

7-11: Repository API looks correct and aligns with JPA conventions.

The derived queries match the intended uniqueness (user, tag) and limit-check use cases.

capturecat-core/src/docs/asciidoc/user.adoc (2)

45-45: Verify snippet type matches the controller.

Docs use query-parameters; if the endpoint consumes JSON, switch to request-fields to avoid empty snippets.


50-50: Confirm the error-codes anchor/path.

Ensure the included anchor id and snippet location for 유저-태그-생성 actually exist to prevent a broken cross-ref.

capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorCode.java (1)

42-44: New error codes look good.

Messages are clear and user-facing.

capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorType.java (1)

41-43: Enum formatting change is fine.

Trailing comma/line break keeps style consistent.

capturecat-core/src/test/java/com/capturecat/core/api/user/UserErrorCodeControllerTest.java (2)

18-20: LGTM: coverage of expected error types.

The three documented errors are appropriate for create flow.


16-21: Docs identifier/path verified; no action required.

capturecat-core/src/main/java/com/capturecat/core/domain/tag/TagRepository.java (1)

12-12: LGTM: single-tag lookup addition is appropriate.
Optional<Tag> findByName(String name) is a useful complement to the bulk lookup.

capturecat-core/src/test/java/com/capturecat/core/api/user/UserTagControllerTest.java (2)

26-27: Verify TagResponse import package.
It’s unusual for a DTO to live under service.image. If TagResponse is under com.capturecat.core.dto, update the import.

Apply if applicable:

- import com.capturecat.core.service.image.TagResponse;
+ import com.capturecat.core.dto.TagResponse;

50-56: Double-check RestAssured status assertion.
Depending on the module, status(HttpStatus.OK) may not compile; fallback is statusCode(HttpStatus.OK.value()).

If needed:

-           .then().status(HttpStatus.OK)
+           .then().statusCode(HttpStatus.OK.value())
capturecat-core/src/main/java/com/capturecat/core/service/user/UserTagService.java (1)

35-37: Verify username immutability or include user ID for lookups
LoginUser doesn’t carry a user ID, so you’re relying on username stability; if usernames can change, extend LoginUser to include the user’s ID and switch to findById(loginUser.getId()) here.

Comment on lines +48 to +51
public Tag registerTagsFor(String tagName) {
return tagRepository.findByName(tagName)
.orElseGet(() -> tagRepository.save(new Tag(tagName)));
}
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

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.

Comment on lines 10 to +12
List<Tag> findByNameIn(List<String> names);

Optional<Tag> findByName(String name);
Copy link

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.

Comment on lines +32 to +47
@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);
}
}
Copy link

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.

Comment on lines +41 to +46
userTagRepository.save(UserTag.create(user, tag));

return TagResponse.from(tag);
} catch (DataIntegrityViolationException ex) {
throw new CoreException(ErrorType.USER_TAG_ALREADY_EXISTS);
}
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

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.

@jaelyangChoi
Copy link
Collaborator

궁금한게 있는데, flyway를 도입한 이유는 무엇인가요?

Copy link
Member Author

@leeedohyun leeedohyun left a comment

Choose a reason for hiding this comment

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

user_tag 테이블이 추가되어서 도입했습니다. 아니면 콘솔에서 바로 ddl 실행시켜서 테이블 생성할까요? 사실 변화가 많을지도 의문이기도 하고

@jaelyangChoi
Copy link
Collaborator

써보는 것도 좋을 것 같아요~

@leeedohyun leeedohyun force-pushed the feature/#120-user-tag branch from e64fe37 to 9eb0a3e Compare September 2, 2025 12:41
@leeedohyun leeedohyun merged commit 615d623 into develop Sep 2, 2025
6 checks passed
@leeedohyun leeedohyun deleted the feature/#120-user-tag branch September 2, 2025 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

유저 태그 도메인 설계

3 participants