diff --git a/.env.template b/.env.template index 49b4a29..ecb5ec9 100644 --- a/.env.template +++ b/.env.template @@ -7,3 +7,7 @@ SERVER_PORT=8080 CORS_ALLOWED_ORIGINS=http://localhost:3000 SWAGGER_USER=ppotto SWAGGER_PASSWORD=ppotto +GCS_BUCKET= +GCS_CREDENTIALS_PATH=./secrets/gcs-service-account.json +GCS_UPLOAD_SIGNED_URL_EXPIRATION_MINUTES=15 +GCS_TIMEOUT_MILLIS=5000 diff --git a/.gitignore b/.gitignore index 20449f4..9ff2936 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ out/ .env .env.* !.env.template +/secrets/ diff --git a/AGENTS.md b/AGENTS.md index 17afb05..dbf369d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,11 @@ photo/ - `@ConfigurationProperties` data classes get `@Validated` + jakarta validation annotations. - Validation annotations on data class constructor properties always use the `@field:` use-site (`@field:NotBlank val title: String`); without it Hibernate Validator may not see them. Controllers take `@Valid @RequestBody`. - Never write a fully-qualified name (FQN) inline. Import the short name whenever there's no naming conflict. +- API versioning: `X-API-Version` 요청 헤더로 버전을 지정한다(Spring Framework 7 네이티브 API 버저닝, `WebMvcConfigurer.configureApiVersioning`, 설정은 `global/config/WebMvcConfig.kt`). 헤더가 없으면 기본값 `1`로 처리한다. URL 경로에는 버전을 포함하지 않으며 `/api` 프리픽스도 쓰지 않는다(예: `/analysis`). 각 컨트롤러의 클래스 레벨 `@RequestMapping`에 `version = "N"`을 명시한다. ## Planned Conventions - Primary keys for new tables: `uuid primary key default uuidv7()` (Postgres 18 built-in, time-ordered). -- API versioning: adopt Spring Framework 7 native API versioning when the first public API ships. ## DB Workflow diff --git a/build.gradle.kts b/build.gradle.kts index 2ad1955..ff6c79f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(platform(libs.spring.cloud.dependencies)) implementation(libs.flyway.database.postgresql) + implementation(libs.google.cloud.storage) implementation(libs.jackson.module.kotlin) implementation(libs.jooq.kotlin) implementation(libs.kotlin.reflect) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32f90f4..5992504 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ spring-dependency-management = "1.1.7" spring-cloud = "2025.1.2" jooq = "3.21.6" flyway = "12.4.0" +google-cloud-storage = "2.70.0" postgresql = "42.7.11" kotest = "6.2.3" spring-dotenv = "5.1.0" @@ -31,6 +32,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } jackson-module-kotlin = { module = "tools.jackson.module:jackson-module-kotlin" } jooq-kotlin = { module = "org.jooq:jooq-kotlin", version.ref = "jooq" } flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" } +google-cloud-storage = { module = "com.google.cloud:google-cloud-storage", version.ref = "google-cloud-storage" } postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } spring-dotenv = { module = "me.paulschwarz:springboot4-dotenv", version.ref = "spring-dotenv" } testcontainers-junit-jupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" } diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/DefaultCatalog.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/DefaultCatalog.kt index c9c8683..b7402dc 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/DefaultCatalog.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/DefaultCatalog.kt @@ -25,7 +25,7 @@ open class DefaultCatalog : CatalogImpl("") { } /** - * standard public schema + * The schema public. */ val PUBLIC: Public get(): Public = Public.PUBLIC diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/Public.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/Public.kt index 7b0e563..7b7eaec 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/Public.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/Public.kt @@ -4,8 +4,9 @@ package com.github.nexters.ppotto.jooq +import com.github.nexters.ppotto.jooq.tables.Analysis import com.github.nexters.ppotto.jooq.tables.Boards -import com.github.nexters.ppotto.jooq.tables.Images +import com.github.nexters.ppotto.jooq.tables.Photos import com.github.nexters.ppotto.jooq.tables.Users import kotlin.collections.List @@ -17,10 +18,10 @@ import org.jooq.impl.SchemaImpl /** - * standard public schema + * This class is generated by jOOQ. */ @Suppress("warnings") -open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALOG, DSL.comment("standard public schema")) { +open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALOG, DSL.comment("")) { companion object { /** @@ -29,15 +30,20 @@ open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALO val PUBLIC: Public = Public() } + /** + * The table public.analysis. + */ + val ANALYSIS: Analysis get() = Analysis.ANALYSIS + /** * The table public.boards. */ val BOARDS: Boards get() = Boards.BOARDS /** - * The table public.images. + * The table public.photos. */ - val IMAGES: Images get() = Images.IMAGES + val PHOTOS: Photos get() = Photos.PHOTOS /** * The table public.users. @@ -47,8 +53,9 @@ open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALO override fun getCatalog(): Catalog = DefaultCatalog.DEFAULT_CATALOG override fun getTables(): List> = listOf( + Analysis.ANALYSIS, Boards.BOARDS, - Images.IMAGES, + Photos.PHOTOS, Users.USERS ) } diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/indexes/Indexes.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/indexes/Indexes.kt index 53b2524..be4e552 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/indexes/Indexes.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/indexes/Indexes.kt @@ -5,8 +5,9 @@ package com.github.nexters.ppotto.jooq.indexes +import com.github.nexters.ppotto.jooq.tables.Analysis import com.github.nexters.ppotto.jooq.tables.Boards -import com.github.nexters.ppotto.jooq.tables.Images +import com.github.nexters.ppotto.jooq.tables.Photos import org.jooq.Index import org.jooq.impl.DSL @@ -18,6 +19,8 @@ import org.jooq.impl.Internal // INDEX definitions // ------------------------------------------------------------------------- +val IDX_ANALYSIS_BOARD_ID: Index = Internal.createIndex(DSL.name("idx_analysis_board_id"), Analysis.ANALYSIS, arrayOf(Analysis.ANALYSIS.BOARD_ID), false) +val IDX_ANALYSIS_USER_ID: Index = Internal.createIndex(DSL.name("idx_analysis_user_id"), Analysis.ANALYSIS, arrayOf(Analysis.ANALYSIS.USER_ID), false) val IDX_BOARDS_USER_ID: Index = Internal.createIndex(DSL.name("idx_boards_user_id"), Boards.BOARDS, arrayOf(Boards.BOARDS.USER_ID), false) -val IDX_IMAGES_BOARD_ID: Index = Internal.createIndex(DSL.name("idx_images_board_id"), Images.IMAGES, arrayOf(Images.IMAGES.BOARD_ID), false) -val IDX_IMAGES_UPLOAD_SESSION_ID: Index = Internal.createIndex(DSL.name("idx_images_upload_session_id"), Images.IMAGES, arrayOf(Images.IMAGES.UPLOAD_SESSION_ID), false) +val IDX_PHOTOS_ANALYSIS_ID: Index = Internal.createIndex(DSL.name("idx_photos_analysis_id"), Photos.PHOTOS, arrayOf(Photos.PHOTOS.ANALYSIS_ID), false) +val IDX_PHOTOS_BOARD_ID: Index = Internal.createIndex(DSL.name("idx_photos_board_id"), Photos.PHOTOS, arrayOf(Photos.PHOTOS.BOARD_ID), false) diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/keys/Keys.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/keys/Keys.kt index 2fe8730..32abadb 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/keys/Keys.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/keys/Keys.kt @@ -5,11 +5,13 @@ package com.github.nexters.ppotto.jooq.keys +import com.github.nexters.ppotto.jooq.tables.Analysis import com.github.nexters.ppotto.jooq.tables.Boards -import com.github.nexters.ppotto.jooq.tables.Images +import com.github.nexters.ppotto.jooq.tables.Photos import com.github.nexters.ppotto.jooq.tables.Users +import com.github.nexters.ppotto.jooq.tables.records.AnalysisRecord import com.github.nexters.ppotto.jooq.tables.records.BoardsRecord -import com.github.nexters.ppotto.jooq.tables.records.ImagesRecord +import com.github.nexters.ppotto.jooq.tables.records.PhotosRecord import com.github.nexters.ppotto.jooq.tables.records.UsersRecord import org.jooq.UniqueKey @@ -22,6 +24,7 @@ import org.jooq.impl.Internal // UNIQUE and PRIMARY KEY definitions // ------------------------------------------------------------------------- +val ANALYSIS_PKEY: UniqueKey = Internal.createUniqueKey(Analysis.ANALYSIS, DSL.name("analysis_pkey"), arrayOf(Analysis.ANALYSIS.ID), true) val BOARDS_PKEY: UniqueKey = Internal.createUniqueKey(Boards.BOARDS, DSL.name("boards_pkey"), arrayOf(Boards.BOARDS.ID), true) -val IMAGES_PKEY: UniqueKey = Internal.createUniqueKey(Images.IMAGES, DSL.name("images_pkey"), arrayOf(Images.IMAGES.ID), true) +val PHOTOS_PKEY: UniqueKey = Internal.createUniqueKey(Photos.PHOTOS, DSL.name("photos_pkey"), arrayOf(Photos.PHOTOS.ID), true) val USERS_PKEY: UniqueKey = Internal.createUniqueKey(Users.USERS, DSL.name("users_pkey"), arrayOf(Users.USERS.ID), true) diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Analysis.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Analysis.kt new file mode 100644 index 0000000..a1e923d --- /dev/null +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Analysis.kt @@ -0,0 +1,219 @@ +/* + * This file is generated by jOOQ. + */ +package com.github.nexters.ppotto.jooq.tables + + +import com.github.nexters.ppotto.global.jooq.OffsetDateTimeInstantConverter +import com.github.nexters.ppotto.jooq.Public +import com.github.nexters.ppotto.jooq.indexes.IDX_ANALYSIS_BOARD_ID +import com.github.nexters.ppotto.jooq.indexes.IDX_ANALYSIS_USER_ID +import com.github.nexters.ppotto.jooq.keys.ANALYSIS_PKEY +import com.github.nexters.ppotto.jooq.tables.records.AnalysisRecord + +import java.time.Instant +import java.util.UUID + +import kotlin.collections.Collection +import kotlin.collections.List + +import org.jooq.Condition +import org.jooq.Field +import org.jooq.ForeignKey +import org.jooq.Index +import org.jooq.InverseForeignKey +import org.jooq.Name +import org.jooq.PlainSQL +import org.jooq.QueryPart +import org.jooq.Record +import org.jooq.SQL +import org.jooq.Schema +import org.jooq.Stringly +import org.jooq.Table +import org.jooq.TableField +import org.jooq.TableLike +import org.jooq.TableOptions +import org.jooq.UniqueKey +import org.jooq.impl.DSL +import org.jooq.impl.Internal +import org.jooq.impl.SQLDataType +import org.jooq.impl.TableImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +open class Analysis( + alias: Name, + path: Table?, + childPath: ForeignKey?, + parentPath: InverseForeignKey?, + aliased: Table?, + parameters: Array?>?, + where: Condition? +): TableImpl( + alias, + Public.PUBLIC, + path, + childPath, + parentPath, + aliased, + parameters, + DSL.comment(""), + TableOptions.table(), + where, +) { + companion object { + + /** + * The reference instance of public.analysis + */ + val ANALYSIS: Analysis = Analysis() + } + + /** + * The class holding records for this type + */ + override fun getRecordType(): Class = AnalysisRecord::class.java + + /** + * The column public.analysis.id. + */ + val ID: TableField = createField(DSL.name("id"), SQLDataType.UUID.nullable(false).defaultValue(DSL.field(DSL.raw("uuidv7()"), SQLDataType.UUID)), this, "") + + /** + * The column public.analysis.user_id. + */ + val USER_ID: TableField = createField(DSL.name("user_id"), SQLDataType.UUID.nullable(false), this, "") + + /** + * The column public.analysis.board_id. + */ + val BOARD_ID: TableField = createField(DSL.name("board_id"), SQLDataType.UUID.nullable(false), this, "") + + /** + * The column public.analysis.status. + */ + val STATUS: TableField = createField(DSL.name("status"), SQLDataType.VARCHAR(50).nullable(false), this, "") + + /** + * The column public.analysis.progress. + */ + val PROGRESS: TableField = createField(DSL.name("progress"), SQLDataType.INTEGER.nullable(false).defaultValue(DSL.field(DSL.raw("0"), SQLDataType.INTEGER)), this, "") + + /** + * The column public.analysis.failed_reason. + */ + val FAILED_REASON: TableField = createField(DSL.name("failed_reason"), SQLDataType.CLOB, this, "") + + /** + * The column public.analysis.started_at. + */ + val STARTED_AT: TableField = createField(DSL.name("started_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, "", OffsetDateTimeInstantConverter()) + + /** + * The column public.analysis.completed_at. + */ + val COMPLETED_AT: TableField = createField(DSL.name("completed_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, "", OffsetDateTimeInstantConverter()) + + /** + * The column public.analysis.created_at. + */ + val CREATED_AT: TableField = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("now()"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "", OffsetDateTimeInstantConverter()) + + /** + * The column public.analysis.updated_at. + */ + val UPDATED_AT: TableField = createField(DSL.name("updated_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("now()"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "", OffsetDateTimeInstantConverter()) + + private constructor(alias: Name, aliased: Table?): this(alias, null, null, null, aliased, null, null) + private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, null, aliased, parameters, null) + private constructor(alias: Name, aliased: Table?, where: Condition?): this(alias, null, null, null, aliased, null, where) + + /** + * Create an aliased public.analysis table reference + */ + constructor(alias: String): this(DSL.name(alias)) + + /** + * Create an aliased public.analysis table reference + */ + constructor(alias: Name): this(alias, null) + + /** + * Create a public.analysis table reference + */ + constructor(): this(DSL.name("analysis"), null) + override fun getSchema(): Schema? = if (aliased()) null else Public.PUBLIC + override fun getIndexes(): List = listOf(IDX_ANALYSIS_BOARD_ID, IDX_ANALYSIS_USER_ID) + override fun getPrimaryKey(): UniqueKey = ANALYSIS_PKEY + override fun `as`(alias: String): Analysis = Analysis(DSL.name(alias), this) + override fun `as`(alias: Name): Analysis = Analysis(alias, this) + override fun `as`(alias: Table<*>): Analysis = Analysis(alias.qualifiedName, this) + + /** + * Rename this table + */ + override fun rename(name: String): Analysis = Analysis(DSL.name(name), null) + + /** + * Rename this table + */ + override fun rename(name: Name): Analysis = Analysis(name, null) + + /** + * Rename this table + */ + override fun rename(name: Table<*>): Analysis = Analysis(name.qualifiedName, null) + + /** + * Create an inline derived table from this table + */ + override fun where(condition: Condition?): Analysis = Analysis(qualifiedName, if (aliased()) this else null, Internal.condition(this, condition)) + + /** + * Create an inline derived table from this table + */ + override fun where(conditions: Collection): Analysis = where(DSL.and(conditions)) + + /** + * Create an inline derived table from this table + */ + override fun where(vararg conditions: Condition?): Analysis = where(DSL.and(*conditions)) + + /** + * Create an inline derived table from this table + */ + override fun where(condition: Field?): Analysis = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(condition: SQL): Analysis = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String): Analysis = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg binds: Any?): Analysis = where(DSL.condition(condition, *binds)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg parts: QueryPart): Analysis = where(DSL.condition(condition, *parts)) + + /** + * Create an inline derived table from this table + */ + override fun whereExists(select: TableLike<*>): Analysis = where(DSL.exists(select)) + + /** + * Create an inline derived table from this table + */ + override fun whereNotExists(select: TableLike<*>): Analysis = where(DSL.notExists(select)) +} diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Images.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Photos.kt similarity index 52% rename from src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Images.kt rename to src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Photos.kt index a298780..6a485ba 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Images.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/Photos.kt @@ -6,10 +6,10 @@ package com.github.nexters.ppotto.jooq.tables import com.github.nexters.ppotto.global.jooq.OffsetDateTimeInstantConverter import com.github.nexters.ppotto.jooq.Public -import com.github.nexters.ppotto.jooq.indexes.IDX_IMAGES_BOARD_ID -import com.github.nexters.ppotto.jooq.indexes.IDX_IMAGES_UPLOAD_SESSION_ID -import com.github.nexters.ppotto.jooq.keys.IMAGES_PKEY -import com.github.nexters.ppotto.jooq.tables.records.ImagesRecord +import com.github.nexters.ppotto.jooq.indexes.IDX_PHOTOS_ANALYSIS_ID +import com.github.nexters.ppotto.jooq.indexes.IDX_PHOTOS_BOARD_ID +import com.github.nexters.ppotto.jooq.keys.PHOTOS_PKEY +import com.github.nexters.ppotto.jooq.tables.records.PhotosRecord import java.time.Instant import java.util.UUID @@ -44,15 +44,15 @@ import org.jooq.impl.TableImpl * This class is generated by jOOQ. */ @Suppress("warnings") -open class Images( +open class Photos( alias: Name, path: Table?, - childPath: ForeignKey?, - parentPath: InverseForeignKey?, - aliased: Table?, + childPath: ForeignKey?, + parentPath: InverseForeignKey?, + aliased: Table?, parameters: Array?>?, where: Condition? -): TableImpl( +): TableImpl( alias, Public.PUBLIC, path, @@ -67,133 +67,148 @@ open class Images( companion object { /** - * The reference instance of public.images + * The reference instance of public.photos */ - val IMAGES: Images = Images() + val PHOTOS: Photos = Photos() } /** * The class holding records for this type */ - override fun getRecordType(): Class = ImagesRecord::class.java + override fun getRecordType(): Class = PhotosRecord::class.java /** - * The column public.images.id. + * The column public.photos.id. */ - val ID: TableField = createField(DSL.name("id"), SQLDataType.UUID.nullable(false).defaultValue(DSL.field(DSL.raw("uuidv7()"), SQLDataType.UUID)), this, "") + val ID: TableField = createField(DSL.name("id"), SQLDataType.UUID.nullable(false).defaultValue(DSL.field(DSL.raw("uuidv7()"), SQLDataType.UUID)), this, "") /** - * The column public.images.board_id. + * The column public.photos.analysis_id. */ - val BOARD_ID: TableField = createField(DSL.name("board_id"), SQLDataType.UUID.nullable(false), this, "") + val ANALYSIS_ID: TableField = createField(DSL.name("analysis_id"), SQLDataType.UUID.nullable(false), this, "") /** - * The column public.images.upload_status. + * The column public.photos.board_id. */ - val UPLOAD_STATUS: TableField = createField(DSL.name("upload_status"), SQLDataType.VARCHAR(20).nullable(false).defaultValue(DSL.field(DSL.raw("'PENDING'::character varying"), SQLDataType.VARCHAR)), this, "") + val BOARD_ID: TableField = createField(DSL.name("board_id"), SQLDataType.UUID.nullable(false), this, "") /** - * The column public.images.upload_session_id. + * The column public.photos.content_type. */ - val UPLOAD_SESSION_ID: TableField = createField(DSL.name("upload_session_id"), SQLDataType.UUID.nullable(false), this, "") + val CONTENT_TYPE: TableField = createField(DSL.name("content_type"), SQLDataType.VARCHAR(50).nullable(false), this, "") /** - * The column public.images.created_at. + * The column public.photos.upload_status. */ - val CREATED_AT: TableField = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("now()"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "", OffsetDateTimeInstantConverter()) + val UPLOAD_STATUS: TableField = createField(DSL.name("upload_status"), SQLDataType.VARCHAR(20).nullable(false).defaultValue(DSL.field(DSL.raw("'PENDING'::character varying"), SQLDataType.VARCHAR)), this, "") /** - * The column public.images.updated_at. + * The column public.photos.uploaded_at. */ - val UPDATED_AT: TableField = createField(DSL.name("updated_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("now()"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "", OffsetDateTimeInstantConverter()) + val UPLOADED_AT: TableField = createField(DSL.name("uploaded_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, "", OffsetDateTimeInstantConverter()) - private constructor(alias: Name, aliased: Table?): this(alias, null, null, null, aliased, null, null) - private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, null, aliased, parameters, null) - private constructor(alias: Name, aliased: Table?, where: Condition?): this(alias, null, null, null, aliased, null, where) + /** + * The column public.photos.taken_at. + */ + val TAKEN_AT: TableField = createField(DSL.name("taken_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, "", OffsetDateTimeInstantConverter()) + + /** + * The column public.photos.created_at. + */ + val CREATED_AT: TableField = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("now()"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "", OffsetDateTimeInstantConverter()) + + /** + * The column public.photos.updated_at. + */ + val UPDATED_AT: TableField = createField(DSL.name("updated_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("now()"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "", OffsetDateTimeInstantConverter()) + + private constructor(alias: Name, aliased: Table?): this(alias, null, null, null, aliased, null, null) + private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, null, aliased, parameters, null) + private constructor(alias: Name, aliased: Table?, where: Condition?): this(alias, null, null, null, aliased, null, where) /** - * Create an aliased public.images table reference + * Create an aliased public.photos table reference */ constructor(alias: String): this(DSL.name(alias)) /** - * Create an aliased public.images table reference + * Create an aliased public.photos table reference */ constructor(alias: Name): this(alias, null) /** - * Create a public.images table reference + * Create a public.photos table reference */ - constructor(): this(DSL.name("images"), null) + constructor(): this(DSL.name("photos"), null) override fun getSchema(): Schema? = if (aliased()) null else Public.PUBLIC - override fun getIndexes(): List = listOf(IDX_IMAGES_BOARD_ID, IDX_IMAGES_UPLOAD_SESSION_ID) - override fun getPrimaryKey(): UniqueKey = IMAGES_PKEY - override fun `as`(alias: String): Images = Images(DSL.name(alias), this) - override fun `as`(alias: Name): Images = Images(alias, this) - override fun `as`(alias: Table<*>): Images = Images(alias.qualifiedName, this) + override fun getIndexes(): List = listOf(IDX_PHOTOS_ANALYSIS_ID, IDX_PHOTOS_BOARD_ID) + override fun getPrimaryKey(): UniqueKey = PHOTOS_PKEY + override fun `as`(alias: String): Photos = Photos(DSL.name(alias), this) + override fun `as`(alias: Name): Photos = Photos(alias, this) + override fun `as`(alias: Table<*>): Photos = Photos(alias.qualifiedName, this) /** * Rename this table */ - override fun rename(name: String): Images = Images(DSL.name(name), null) + override fun rename(name: String): Photos = Photos(DSL.name(name), null) /** * Rename this table */ - override fun rename(name: Name): Images = Images(name, null) + override fun rename(name: Name): Photos = Photos(name, null) /** * Rename this table */ - override fun rename(name: Table<*>): Images = Images(name.qualifiedName, null) + override fun rename(name: Table<*>): Photos = Photos(name.qualifiedName, null) /** * Create an inline derived table from this table */ - override fun where(condition: Condition?): Images = Images(qualifiedName, if (aliased()) this else null, Internal.condition(this, condition)) + override fun where(condition: Condition?): Photos = Photos(qualifiedName, if (aliased()) this else null, Internal.condition(this, condition)) /** * Create an inline derived table from this table */ - override fun where(conditions: Collection): Images = where(DSL.and(conditions)) + override fun where(conditions: Collection): Photos = where(DSL.and(conditions)) /** * Create an inline derived table from this table */ - override fun where(vararg conditions: Condition?): Images = where(DSL.and(*conditions)) + override fun where(vararg conditions: Condition?): Photos = where(DSL.and(*conditions)) /** * Create an inline derived table from this table */ - override fun where(condition: Field?): Images = where(DSL.condition(condition)) + override fun where(condition: Field?): Photos = where(DSL.condition(condition)) /** * Create an inline derived table from this table */ - @PlainSQL override fun where(condition: SQL): Images = where(DSL.condition(condition)) + @PlainSQL override fun where(condition: SQL): Photos = where(DSL.condition(condition)) /** * Create an inline derived table from this table */ - @PlainSQL override fun where(@Stringly.SQL condition: String): Images = where(DSL.condition(condition)) + @PlainSQL override fun where(@Stringly.SQL condition: String): Photos = where(DSL.condition(condition)) /** * Create an inline derived table from this table */ - @PlainSQL override fun where(@Stringly.SQL condition: String, vararg binds: Any?): Images = where(DSL.condition(condition, *binds)) + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg binds: Any?): Photos = where(DSL.condition(condition, *binds)) /** * Create an inline derived table from this table */ - @PlainSQL override fun where(@Stringly.SQL condition: String, vararg parts: QueryPart): Images = where(DSL.condition(condition, *parts)) + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg parts: QueryPart): Photos = where(DSL.condition(condition, *parts)) /** * Create an inline derived table from this table */ - override fun whereExists(select: TableLike<*>): Images = where(DSL.exists(select)) + override fun whereExists(select: TableLike<*>): Photos = where(DSL.exists(select)) /** * Create an inline derived table from this table */ - override fun whereNotExists(select: TableLike<*>): Images = where(DSL.notExists(select)) + override fun whereNotExists(select: TableLike<*>): Photos = where(DSL.notExists(select)) } diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Analysis.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Analysis.kt new file mode 100644 index 0000000..743b194 --- /dev/null +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Analysis.kt @@ -0,0 +1,121 @@ +/* + * This file is generated by jOOQ. + */ +package com.github.nexters.ppotto.jooq.tables.pojos + + +import java.io.Serializable +import java.time.Instant +import java.util.UUID + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +data class Analysis( + val id: UUID? = null, + val userId: UUID, + val boardId: UUID, + val status: String, + val progress: Int? = null, + val failedReason: String? = null, + val startedAt: Instant? = null, + val completedAt: Instant? = null, + val createdAt: Instant? = null, + val updatedAt: Instant? = null +): Serializable { + + override fun equals(other: Any?): Boolean { + if (this === other) + return true + if (other == null) + return false + if (this::class != other::class) + return false + val o: Analysis = other as Analysis + if (this.id == null) { + if (o.id != null) + return false + } + else if (this.id != o.id) + return false + if (this.userId != o.userId) + return false + if (this.boardId != o.boardId) + return false + if (this.status != o.status) + return false + if (this.progress == null) { + if (o.progress != null) + return false + } + else if (this.progress != o.progress) + return false + if (this.failedReason == null) { + if (o.failedReason != null) + return false + } + else if (this.failedReason != o.failedReason) + return false + if (this.startedAt == null) { + if (o.startedAt != null) + return false + } + else if (this.startedAt != o.startedAt) + return false + if (this.completedAt == null) { + if (o.completedAt != null) + return false + } + else if (this.completedAt != o.completedAt) + return false + if (this.createdAt == null) { + if (o.createdAt != null) + return false + } + else if (this.createdAt != o.createdAt) + return false + if (this.updatedAt == null) { + if (o.updatedAt != null) + return false + } + else if (this.updatedAt != o.updatedAt) + return false + return true + } + + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + (if (this.id == null) 0 else this.id.hashCode()) + result = prime * result + this.userId.hashCode() + result = prime * result + this.boardId.hashCode() + result = prime * result + this.status.hashCode() + result = prime * result + (if (this.progress == null) 0 else this.progress.hashCode()) + result = prime * result + (if (this.failedReason == null) 0 else this.failedReason.hashCode()) + result = prime * result + (if (this.startedAt == null) 0 else this.startedAt.hashCode()) + result = prime * result + (if (this.completedAt == null) 0 else this.completedAt.hashCode()) + result = prime * result + (if (this.createdAt == null) 0 else this.createdAt.hashCode()) + result = prime * result + (if (this.updatedAt == null) 0 else this.updatedAt.hashCode()) + return result + } + + override fun toString(): String { + val sb = StringBuilder("Analysis (") + + sb.append(id) + sb.append(", ").append(userId) + sb.append(", ").append(boardId) + sb.append(", ").append(status) + sb.append(", ").append(progress) + sb.append(", ").append(failedReason) + sb.append(", ").append(startedAt) + sb.append(", ").append(completedAt) + sb.append(", ").append(createdAt) + sb.append(", ").append(updatedAt) + + sb.append(")") + return sb.toString() + } +} diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Images.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Photos.kt similarity index 65% rename from src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Images.kt rename to src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Photos.kt index 3abb0a1..b8da276 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Images.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/pojos/Photos.kt @@ -13,11 +13,14 @@ import java.util.UUID * This class is generated by jOOQ. */ @Suppress("warnings") -data class Images( +data class Photos( val id: UUID? = null, + val analysisId: UUID, val boardId: UUID, + val contentType: String, val uploadStatus: String? = null, - val uploadSessionId: UUID, + val uploadedAt: Instant? = null, + val takenAt: Instant? = null, val createdAt: Instant? = null, val updatedAt: Instant? = null ): Serializable { @@ -29,22 +32,36 @@ data class Images( return false if (this::class != other::class) return false - val o: Images = other as Images + val o: Photos = other as Photos if (this.id == null) { if (o.id != null) return false } else if (this.id != o.id) return false + if (this.analysisId != o.analysisId) + return false if (this.boardId != o.boardId) return false + if (this.contentType != o.contentType) + return false if (this.uploadStatus == null) { if (o.uploadStatus != null) return false } else if (this.uploadStatus != o.uploadStatus) return false - if (this.uploadSessionId != o.uploadSessionId) + if (this.uploadedAt == null) { + if (o.uploadedAt != null) + return false + } + else if (this.uploadedAt != o.uploadedAt) + return false + if (this.takenAt == null) { + if (o.takenAt != null) + return false + } + else if (this.takenAt != o.takenAt) return false if (this.createdAt == null) { if (o.createdAt != null) @@ -65,21 +82,27 @@ data class Images( val prime = 31 var result = 1 result = prime * result + (if (this.id == null) 0 else this.id.hashCode()) + result = prime * result + this.analysisId.hashCode() result = prime * result + this.boardId.hashCode() + result = prime * result + this.contentType.hashCode() result = prime * result + (if (this.uploadStatus == null) 0 else this.uploadStatus.hashCode()) - result = prime * result + this.uploadSessionId.hashCode() + result = prime * result + (if (this.uploadedAt == null) 0 else this.uploadedAt.hashCode()) + result = prime * result + (if (this.takenAt == null) 0 else this.takenAt.hashCode()) result = prime * result + (if (this.createdAt == null) 0 else this.createdAt.hashCode()) result = prime * result + (if (this.updatedAt == null) 0 else this.updatedAt.hashCode()) return result } override fun toString(): String { - val sb = StringBuilder("Images (") + val sb = StringBuilder("Photos (") sb.append(id) + sb.append(", ").append(analysisId) sb.append(", ").append(boardId) + sb.append(", ").append(contentType) sb.append(", ").append(uploadStatus) - sb.append(", ").append(uploadSessionId) + sb.append(", ").append(uploadedAt) + sb.append(", ").append(takenAt) sb.append(", ").append(createdAt) sb.append(", ").append(updatedAt) diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/AnalysisRecord.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/AnalysisRecord.kt new file mode 100644 index 0000000..2b2464d --- /dev/null +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/AnalysisRecord.kt @@ -0,0 +1,103 @@ +/* + * This file is generated by jOOQ. + */ +package com.github.nexters.ppotto.jooq.tables.records + + +import com.github.nexters.ppotto.jooq.tables.Analysis + +import java.time.Instant +import java.util.UUID + +import org.jooq.Record1 +import org.jooq.impl.UpdatableRecordImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +open class AnalysisRecord private constructor() : UpdatableRecordImpl(Analysis.ANALYSIS) { + + open var id: UUID? + set(value): Unit = set(0, value) + get(): UUID? = get(0) as UUID? + + open var userId: UUID + set(value): Unit = set(1, value) + get(): UUID = get(1) as UUID + + open var boardId: UUID + set(value): Unit = set(2, value) + get(): UUID = get(2) as UUID + + open var status: String + set(value): Unit = set(3, value) + get(): String = get(3) as String + + open var progress: Int? + set(value): Unit = set(4, value) + get(): Int? = get(4) as Int? + + open var failedReason: String? + set(value): Unit = set(5, value) + get(): String? = get(5) as String? + + open var startedAt: Instant? + set(value): Unit = set(6, value) + get(): Instant? = get(6) as Instant? + + open var completedAt: Instant? + set(value): Unit = set(7, value) + get(): Instant? = get(7) as Instant? + + open var createdAt: Instant? + set(value): Unit = set(8, value) + get(): Instant? = get(8) as Instant? + + open var updatedAt: Instant? + set(value): Unit = set(9, value) + get(): Instant? = get(9) as Instant? + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + override fun key(): Record1 = super.key() as Record1 + + /** + * Create a detached, initialised AnalysisRecord + */ + constructor(id: UUID? = null, userId: UUID, boardId: UUID, status: String, progress: Int? = null, failedReason: String? = null, startedAt: Instant? = null, completedAt: Instant? = null, createdAt: Instant? = null, updatedAt: Instant? = null): this() { + this.id = id + this.userId = userId + this.boardId = boardId + this.status = status + this.progress = progress + this.failedReason = failedReason + this.startedAt = startedAt + this.completedAt = completedAt + this.createdAt = createdAt + this.updatedAt = updatedAt + resetTouchedOnNotNull() + } + + /** + * Create a detached, initialised AnalysisRecord + */ + constructor(value: com.github.nexters.ppotto.jooq.tables.pojos.Analysis?): this() { + if (value != null) { + this.id = value.id + this.userId = value.userId + this.boardId = value.boardId + this.status = value.status + this.progress = value.progress + this.failedReason = value.failedReason + this.startedAt = value.startedAt + this.completedAt = value.completedAt + this.createdAt = value.createdAt + this.updatedAt = value.updatedAt + resetTouchedOnNotNull() + } + } +} diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/ImagesRecord.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/PhotosRecord.kt similarity index 55% rename from src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/ImagesRecord.kt rename to src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/PhotosRecord.kt index d581976..44d669d 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/ImagesRecord.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/records/PhotosRecord.kt @@ -4,7 +4,7 @@ package com.github.nexters.ppotto.jooq.tables.records -import com.github.nexters.ppotto.jooq.tables.Images +import com.github.nexters.ppotto.jooq.tables.Photos import java.time.Instant import java.util.UUID @@ -17,32 +17,44 @@ import org.jooq.impl.UpdatableRecordImpl * This class is generated by jOOQ. */ @Suppress("warnings") -open class ImagesRecord private constructor() : UpdatableRecordImpl(Images.IMAGES) { +open class PhotosRecord private constructor() : UpdatableRecordImpl(Photos.PHOTOS) { open var id: UUID? set(value): Unit = set(0, value) get(): UUID? = get(0) as UUID? - open var boardId: UUID + open var analysisId: UUID set(value): Unit = set(1, value) get(): UUID = get(1) as UUID - open var uploadStatus: String? + open var boardId: UUID set(value): Unit = set(2, value) - get(): String? = get(2) as String? + get(): UUID = get(2) as UUID - open var uploadSessionId: UUID + open var contentType: String set(value): Unit = set(3, value) - get(): UUID = get(3) as UUID + get(): String = get(3) as String - open var createdAt: Instant? + open var uploadStatus: String? set(value): Unit = set(4, value) - get(): Instant? = get(4) as Instant? + get(): String? = get(4) as String? - open var updatedAt: Instant? + open var uploadedAt: Instant? set(value): Unit = set(5, value) get(): Instant? = get(5) as Instant? + open var takenAt: Instant? + set(value): Unit = set(6, value) + get(): Instant? = get(6) as Instant? + + open var createdAt: Instant? + set(value): Unit = set(7, value) + get(): Instant? = get(7) as Instant? + + open var updatedAt: Instant? + set(value): Unit = set(8, value) + get(): Instant? = get(8) as Instant? + // ------------------------------------------------------------------------- // Primary key information // ------------------------------------------------------------------------- @@ -50,27 +62,33 @@ open class ImagesRecord private constructor() : UpdatableRecordImpl = super.key() as Record1 /** - * Create a detached, initialised ImagesRecord + * Create a detached, initialised PhotosRecord */ - constructor(id: UUID? = null, boardId: UUID, uploadStatus: String? = null, uploadSessionId: UUID, createdAt: Instant? = null, updatedAt: Instant? = null): this() { + constructor(id: UUID? = null, analysisId: UUID, boardId: UUID, contentType: String, uploadStatus: String? = null, uploadedAt: Instant? = null, takenAt: Instant? = null, createdAt: Instant? = null, updatedAt: Instant? = null): this() { this.id = id + this.analysisId = analysisId this.boardId = boardId + this.contentType = contentType this.uploadStatus = uploadStatus - this.uploadSessionId = uploadSessionId + this.uploadedAt = uploadedAt + this.takenAt = takenAt this.createdAt = createdAt this.updatedAt = updatedAt resetTouchedOnNotNull() } /** - * Create a detached, initialised ImagesRecord + * Create a detached, initialised PhotosRecord */ - constructor(value: com.github.nexters.ppotto.jooq.tables.pojos.Images?): this() { + constructor(value: com.github.nexters.ppotto.jooq.tables.pojos.Photos?): this() { if (value != null) { this.id = value.id + this.analysisId = value.analysisId this.boardId = value.boardId + this.contentType = value.contentType this.uploadStatus = value.uploadStatus - this.uploadSessionId = value.uploadSessionId + this.uploadedAt = value.uploadedAt + this.takenAt = value.takenAt this.createdAt = value.createdAt this.updatedAt = value.updatedAt resetTouchedOnNotNull() diff --git a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/references/Tables.kt b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/references/Tables.kt index 0360936..8635f8b 100644 --- a/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/references/Tables.kt +++ b/src/generated/jooq/com/github/nexters/ppotto/jooq/tables/references/Tables.kt @@ -5,21 +5,27 @@ package com.github.nexters.ppotto.jooq.tables.references +import com.github.nexters.ppotto.jooq.tables.Analysis import com.github.nexters.ppotto.jooq.tables.Boards -import com.github.nexters.ppotto.jooq.tables.Images +import com.github.nexters.ppotto.jooq.tables.Photos import com.github.nexters.ppotto.jooq.tables.Users +/** + * The table public.analysis. + */ +val ANALYSIS: Analysis = Analysis.ANALYSIS + /** * The table public.boards. */ val BOARDS: Boards = Boards.BOARDS /** - * The table public.images. + * The table public.photos. */ -val IMAGES: Images = Images.IMAGES +val PHOTOS: Photos = Photos.PHOTOS /** * The table public.users. diff --git a/src/main/kotlin/com/github/nexters/ppotto/AGENTS.md b/src/main/kotlin/com/github/nexters/ppotto/AGENTS.md index 92d2f58..86274b6 100644 --- a/src/main/kotlin/com/github/nexters/ppotto/AGENTS.md +++ b/src/main/kotlin/com/github/nexters/ppotto/AGENTS.md @@ -10,7 +10,7 @@ Application root package. One top-level subpackage = one domain. | `global/` | Shared module: config, error, logging, response (see `global/AGENTS.md`) | | `user/` | User domain: `domain`/`infrastructure` only so far, no `presentation`/`application` yet (see `user/AGENTS.md`) | | `board/` | Board domain, 1:N with User: `domain`/`infrastructure` only so far (see `board/AGENTS.md`) | -| `image/` | Image domain, 1:N with Board: `domain`/`infrastructure` only so far (see `image/AGENTS.md`) | +| `analysis/` | Analysis domain, owns `Analysis` and `Photo` (1:N with Board): full DDD-lite layout (see `analysis/AGENTS.md`) | ## Adding a new domain diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/AGENTS.md b/src/main/kotlin/com/github/nexters/ppotto/analysis/AGENTS.md new file mode 100644 index 0000000..79d3d59 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/AGENTS.md @@ -0,0 +1,34 @@ + + +# analysis + +Analysis domain. Owns both `Analysis` and `Photo` — a photo only ever exists as part of an analysis (`Analysis : Photo = 1:N` via `photos.analysis_id`), so they share one domain package instead of being split into `analysis`/`photo`. No DB-level FK constraints (same reasoning as `board/`). The 90–100 photo count validation is implemented via `@field:Size(min=90, max=100)` on `CreateAnalysisRequest.photos` and enforced at bean-validation time. However, analysis-lifecycle business rules (one-active-analysis-per-user limit, daily quota, and actually starting the AI pipeline) are intentionally not implemented yet — see Rules below. + +| Directory | Description | +|-----------|---------| +| `domain/Analysis.kt` | Pure Kotlin model: `id`, `userId`, `boardId`, `status`, `progress`, `failedReason`, `startedAt`, `completedAt`, `createdAt`, `updatedAt` | +| `domain/AnalysisStatus.kt` | `UPLOADING` / `ANALYZING` / `GENERATING` / `COMPLETED` / `FAILED` — pure Kotlin enum, not the jOOQ-generated type | +| `domain/Photo.kt` | Pure Kotlin model: `id`, `analysisId`, `boardId`, `contentType: PhotoContentType`, `uploadStatus`, `uploadedAt`, `takenAt`, `createdAt`, `updatedAt` | +| `domain/UploadStatus.kt` | `PENDING` / `COMPLETED` / `FAILED` | +| `domain/PhotoContentType.kt` | `JPEG`/`PNG`/`HEIC` enum, each carrying `mimeType` (`"image/jpeg"` etc., stored in `photos.content_type` and sent to GCS as the `Content-Type` header) and `extension` (`"jpg"` etc., used for the object key). This is the single source of truth for which content types are accepted. `fromMimeType(String)` throws `InvalidInputException` (→ 400 `COMMON-001` via `GlobalExceptionHandler`'s `BusinessException` handler) for an unsupported value — the request DTO field itself stays a plain `String?`; conversion/validation happens in `AnalysisService`, not at the Jackson/bean-validation boundary | +| `domain/PhotoStorage.kt` | Port interface: `issueUploadUrls(targets): List` (batch, same order as input), `existingObjects(prefix): Map` (`BlobMeta(size, createdAt)` — carries the GCS object's real size/creation time so the caller doesn't have to re-derive `uploaded_at` or trust bare key existence). No Spring/GCS SDK imports — the adapter is `infrastructure/GcsPhotoStorage.kt` | +| `domain/AnalysisErrorCode.kt` | `ALREADY_STARTED_OR_FINISHED` (`ANALYSIS-003`, 409) — thrown by `startUpload` when `analysis.status != UPLOADING` | +| `infrastructure/PhotoObjectKeys.kt` | `object` (stateless, no DI needed) wrapping the shared `global/storage/ObjectKeyGenerator` with this domain's own convention: namespace `"photos"`, prefix `photos/{analysisId}/`, key `photos/{analysisId}/{photoId}.{PhotoContentType.extension}`. The `analysisId` segment lets `GcsPhotoStorage.existingObjects(prefix)` list all of one analysis's objects in a single GCS call. Originally lived in `domain/` as a `@Component`, which violated this domain's "no Spring imports" rule — moved here and converted to a plain `object` since it has no state to inject | +| `infrastructure/AnalysisRepository.kt` | DSLContext-based persistence: `save(userId, boardId)` (always inserts `status = UPLOADING`), `findById()`, `findByIdForUpdate()` — same as `findById` but with `SELECT ... FOR UPDATE`; used by `startUpload` to serialize concurrent calls for the same analysis | +| `infrastructure/PhotoRepository.kt` | `saveAll(analysisId, boardId, items)` — single multi-row `INSERT ... RETURNING`, relies on Postgres preserving input order for plain multi-VALUES inserts so the response can zip photos back to the request order. `findPendingByAnalysisId()`, `findAllByAnalysisId()` (status-agnostic, used to compute `startUpload`'s final aggregate). `updateStatusBatch(updates: Map, expectedStatus, newStatus)` — each photo can have a different `uploadedAt` (the GCS object's real creation time), so this loops per id issuing the same optimistic conditional `UPDATE ... WHERE id = ? AND upload_status = expectedStatus` + `RETURNING`; empty `updates` short-circuits without a query. `toDomain()`'s DB value mapping: `content_type` is mapped via `PhotoContentType.entries.find + error(...)` (not `fromMimeType`, which is for request validation and throws 400) — DB inconsistency is a server problem (500), not client input error (400) | +| `infrastructure/GcsPhotoStorage.kt` | GCS SDK implementation of `PhotoStorage`. `issueUploadUrls` loops internally (GCS V4 signing has no batch API) but is called once per request from the service; each signed URL also signs `x-goog-content-length-range: 0,{MAX_PHOTO_SIZE_BYTES}` (15MB, per spec) as an ext header, so GCS rejects oversized PUT bodies with 400 — the client must send the identical header value or GCS returns 403 `SignatureDoesNotMatch` instead, and a web client additionally needs this header allow-listed in the bucket's CORS config (both are tracked outside this repo, not yet done). `existingObjects` uses `Storage.list(bucket, prefix(...))` — one GCS call instead of one `get()` per photo — and maps each `Blob` to `BlobMeta(size, createTimeOffsetDateTime)` instead of discarding everything but the name | +| `application/AnalysisService.kt` | `createAnalysis(boardId, photos)`: validates the board exists (`BoardQueryService`) → derives `userId` from `board.userId` (no auth wired up yet, see Rules) → converts each raw `contentType` string to `PhotoContentType` via `fromMimeType` (`null` defaults to `PhotoContentType.DEFAULT` without validation) → creates the `Analysis` row → batch-inserts `Photo` rows → batch-issues signed upload URLs → `check()`s the returned URL count matches the photo count (the `PhotoStorage.issueUploadUrls` contract doesn't enforce this at the type level, and a silent mismatch would make `zip` drop trailing photos without any URL) → returns them zipped with `photoId`, in request order. `startUpload(analysisId)`: verifies the analysis exists and is still `UPLOADING` (409 `ANALYSIS-003` via `ConflictException` otherwise) → lists this analysis's existing GCS objects once (outside any transaction, so the slow network call never holds a DB connection), keyed by object name with `BlobMeta(size, createdAt)` → for each `PENDING` photo, confirms upload only when its object key exists **and** `size > 0` (a GCS object can legitimately exist with 0 bytes if the client PUTs an empty body — the `x-goog-content-length-range` signed header doesn't block that since 0 is a valid value in `0,{MAX_PHOTO_SIZE_BYTES}`) — a 0-byte object is treated the same as a missing one and stays `PENDING`, not confirmed `FAILED` → opens a short `TransactionTemplate` block that: locks the `analysis` row (`findByIdForUpdate`, serializes concurrent `/start` calls for the same analysis), updates the confirmed ids to `COMPLETED` with each photo's own `uploadedAt` taken from its `BlobMeta.createdAt` (the GCS object's real creation time, not the time `/start` happened to run), then re-reads **all** of this analysis's photos (`findAllByAnalysisId`) and builds the response from that final aggregate — never from the pre-lock candidate list — so the counts always match what's actually committed even under concurrent calls. Missing/0-byte photos stay `PENDING` so a later `/start` call can pick them up once the client finishes uploading; they're reported as `failedPhotoIds`. Does **not** touch `Analysis.status`/`progress`/`startedAt` — starting the actual pipeline is out of scope for this pass. Intentionally **not** `@Transactional` at the method level (same-bean self-invocation wouldn't get proxied anyway) — only the lock+update+re-read section runs inside a transaction, via `TransactionTemplate`, kept as short as possible so the GCS call never happens while a DB connection is held | +| `application/AnalysisResults.kt` | Result types returned by the service (kept separate from the presentation dto): `AnalysisCreationResult`, `PhotoUploadUrlItem`, `PhotoUploadItemRequest` (`contentType: String?` — raw, unvalidated), `UploadVerificationResult` | +| `presentation/AnalysisController.kt` | `POST /analysis` (version 1, 200), `POST /analysis/{analysisId}/start` (version 1, `@ResponseStatus(HttpStatus.ACCEPTED)` → 202, per spec) | +| `presentation/dto/` | `CreateAnalysisRequest` (`@field:Size(min=90, max=100)` on `photos` enforces spec-mandated photo count), `PhotoUploadItem` (`contentType: String? = null`, no bean-validation annotation — validity is enforced by `AnalysisService` via `PhotoContentType.fromMimeType`), `CreateAnalysisResponse`, `StartUploadResponse` | + +## Rules + +- This tracks the client's real OpenAPI spec (`analysis` tag) for `POST /analysis` and `POST /analysis/{analysisId}/start` only. Implemented: the 90–100 photo count check (`ANALYSIS-001`), validated at bean-validation time via `@field:Size(min=90, max=100)` and mapped to 400 `COMMON-001`. Not implemented yet: `GET /analysis/active`, `GET /analysis/{id}`, `DELETE /analysis/{id}`, `POST /analysis/{id}/reissue`, the one-active-analysis-per-user conflict (`ANALYSIS-002`), the daily quota (`ANALYSIS-006`), and actually kicking off the analysis pipeline from `start`. `AnalysisErrorCode.ALREADY_STARTED_OR_FINISHED` (`ANALYSIS-003`) is the only domain-specific error code so far; not-found cases still use the generic `NotFoundException()` (`COMMON-002`). +- No authentication is wired up in this codebase yet (`SecurityConfig` is `permitAll` everywhere, no current-user resolver). `Analysis.userId` is derived from `board.userId` as a stand-in for real ownership until auth lands — revisit once it does. +- `startUpload` only touches photos currently `PENDING`. Once a photo is confirmed `COMPLETED` it's excluded from future calls; missing **or 0-byte** photos stay `PENDING` (not `FAILED`) so retrying after the client finishes uploading picks them up again. Calling it again once every photo is `COMPLETED` is a no-op (`uploadedCount = 0, failedCount = 0`). +- `upload_status`/`status`/`content_type` are DB `VARCHAR`, not Postgres `ENUM`s — value-set validation lives only in the Kotlin enums; repositories map `String ↔ enum` at the boundary (`PhotoContentType.fromMimeType(...)` / `.mimeType`, same pattern as `UploadStatus.valueOf(...)` / `.name`). +- Any new test that invokes `AnalysisService` must `@Import` `analysis/support/AnalysisTestConfig` so the `@Primary` fake (`FakePhotoStorage`) replaces the real GCS adapter, same mechanism the former `image` domain used. +- The old `images` table / `image` domain (file-name-based, per-image signed URL issuance and client-reported upload completion) was fully replaced by this domain in the same change — see git history if you need the old shape. + +Update this file when layers are added to this domain. diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisResults.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisResults.kt new file mode 100644 index 0000000..ba3cb92 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisResults.kt @@ -0,0 +1,25 @@ +package com.github.nexters.ppotto.analysis.application + +import java.time.Instant +import java.util.UUID + +data class AnalysisCreationResult( + val analysisId: UUID, + val uploads: List, +) + +data class PhotoUploadUrlItem( + val photoId: UUID, + val uploadUrl: String, +) + +data class PhotoUploadItemRequest( + val takenAt: Instant, + val contentType: String?, +) + +data class UploadVerificationResult( + val uploadedCount: Int, + val failedCount: Int, + val failedPhotoIds: List, +) diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisService.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisService.kt new file mode 100644 index 0000000..093311d --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisService.kt @@ -0,0 +1,93 @@ +package com.github.nexters.ppotto.analysis.application + +import com.github.nexters.ppotto.analysis.domain.AnalysisErrorCode +import com.github.nexters.ppotto.analysis.domain.AnalysisStatus +import com.github.nexters.ppotto.analysis.domain.PhotoContentType +import com.github.nexters.ppotto.analysis.domain.PhotoStorage +import com.github.nexters.ppotto.analysis.domain.PhotoUploadTarget +import com.github.nexters.ppotto.analysis.domain.UploadStatus +import com.github.nexters.ppotto.analysis.infrastructure.AnalysisRepository +import com.github.nexters.ppotto.analysis.infrastructure.PhotoCreate +import com.github.nexters.ppotto.analysis.infrastructure.PhotoObjectKeys +import com.github.nexters.ppotto.analysis.infrastructure.PhotoRepository +import com.github.nexters.ppotto.board.application.BoardQueryService +import com.github.nexters.ppotto.global.error.ConflictException +import com.github.nexters.ppotto.global.error.NotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID + +@Service +class AnalysisService( + private val analysisRepository: AnalysisRepository, + private val photoRepository: PhotoRepository, + private val boardQueryService: BoardQueryService, + private val photoStorage: PhotoStorage, + private val transactionTemplate: TransactionTemplate, +) { + @Transactional + fun createAnalysis( + boardId: UUID, + photos: List, + ): AnalysisCreationResult { + val board = boardQueryService.getById(boardId) + val analysis = analysisRepository.save(userId = board.userId, boardId = boardId) + + val savedPhotos = + photoRepository.saveAll( + analysisId = analysis.id, + boardId = boardId, + items = + photos.map { + val contentType = it.contentType?.let { raw -> PhotoContentType.fromMimeType(raw) } ?: PhotoContentType.DEFAULT + PhotoCreate(contentType, it.takenAt) + }, + ) + + val targets = + savedPhotos.map { + PhotoUploadTarget(PhotoObjectKeys.keyFor(analysis.id, it.id, it.contentType), it.contentType.mimeType) + } + val uploadUrls = photoStorage.issueUploadUrls(targets) + check(uploadUrls.size == savedPhotos.size) { + "issueUploadUrls가 요청한 개수(${savedPhotos.size})와 다른 개수(${uploadUrls.size})의 URL을 반환했습니다." + } + + val uploads = savedPhotos.zip(uploadUrls) { photo, url -> PhotoUploadUrlItem(photo.id, url) } + return AnalysisCreationResult(analysis.id, uploads) + } + + fun startUpload(analysisId: UUID): UploadVerificationResult { + val analysis = analysisRepository.findById(analysisId) ?: throw NotFoundException() + if (analysis.status != AnalysisStatus.UPLOADING) { + throw ConflictException(AnalysisErrorCode.ALREADY_STARTED_OR_FINISHED) + } + + val pendingPhotos = photoRepository.findPendingByAnalysisId(analysisId) + if (pendingPhotos.isEmpty()) return UploadVerificationResult(0, 0, emptyList()) + + val existingObjects = photoStorage.existingObjects(PhotoObjectKeys.prefixFor(analysisId)) + val completedUpdates = + pendingPhotos + .mapNotNull { photo -> + val meta = existingObjects[PhotoObjectKeys.keyFor(analysisId, photo.id, photo.contentType)] + if (meta != null && meta.size > 0) photo.id to meta.createdAt else null + }.toMap() + + return transactionTemplate.execute { + analysisRepository.findByIdForUpdate(analysisId) + + if (completedUpdates.isNotEmpty()) { + photoRepository.updateStatusBatch(completedUpdates, UploadStatus.PENDING, UploadStatus.COMPLETED) + } + + val (completed, pending) = + photoRepository + .findAllByAnalysisId(analysisId) + .partition { it.uploadStatus == UploadStatus.COMPLETED } + + UploadVerificationResult(completed.size, pending.size, pending.map { it.id }) + } + } +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/Analysis.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/Analysis.kt new file mode 100644 index 0000000..1d33bbd --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/Analysis.kt @@ -0,0 +1,17 @@ +package com.github.nexters.ppotto.analysis.domain + +import java.time.Instant +import java.util.UUID + +class Analysis( + val id: UUID, + val userId: UUID, + val boardId: UUID, + val status: AnalysisStatus, + val progress: Int, + val failedReason: String?, + val startedAt: Instant?, + val completedAt: Instant?, + val createdAt: Instant, + val updatedAt: Instant, +) diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/AnalysisErrorCode.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/AnalysisErrorCode.kt new file mode 100644 index 0000000..1b30482 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/AnalysisErrorCode.kt @@ -0,0 +1,12 @@ +package com.github.nexters.ppotto.analysis.domain + +import com.github.nexters.ppotto.global.error.ErrorCode +import org.springframework.http.HttpStatus + +enum class AnalysisErrorCode( + override val status: HttpStatus, + override val code: String, + override val message: String, +) : ErrorCode { + ALREADY_STARTED_OR_FINISHED(HttpStatus.CONFLICT, "ANALYSIS-003", "이미 시작되었거나 종료된 분석입니다."), +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/AnalysisStatus.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/AnalysisStatus.kt new file mode 100644 index 0000000..4af57aa --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/AnalysisStatus.kt @@ -0,0 +1,9 @@ +package com.github.nexters.ppotto.analysis.domain + +enum class AnalysisStatus { + UPLOADING, + ANALYZING, + GENERATING, + COMPLETED, + FAILED, +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/Photo.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/Photo.kt new file mode 100644 index 0000000..339a8c3 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/Photo.kt @@ -0,0 +1,16 @@ +package com.github.nexters.ppotto.analysis.domain + +import java.time.Instant +import java.util.UUID + +class Photo( + val id: UUID, + val analysisId: UUID, + val boardId: UUID, + val contentType: PhotoContentType, + val uploadStatus: UploadStatus, + val uploadedAt: Instant?, + val takenAt: Instant?, + val createdAt: Instant, + val updatedAt: Instant, +) diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoContentType.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoContentType.kt new file mode 100644 index 0000000..0f779aa --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoContentType.kt @@ -0,0 +1,19 @@ +package com.github.nexters.ppotto.analysis.domain + +import com.github.nexters.ppotto.global.error.InvalidInputException + +enum class PhotoContentType( + val mimeType: String, + val extension: String, +) { + JPEG("image/jpeg", "jpg"), + PNG("image/png", "png"), + HEIC("image/heic", "heic"), + ; + + companion object { + val DEFAULT = JPEG + + fun fromMimeType(mimeType: String): PhotoContentType = entries.find { it.mimeType == mimeType } ?: throw InvalidInputException() + } +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoStorage.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoStorage.kt new file mode 100644 index 0000000..e894b2d --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoStorage.kt @@ -0,0 +1,19 @@ +package com.github.nexters.ppotto.analysis.domain + +import java.time.Instant + +interface PhotoStorage { + fun issueUploadUrls(targets: List): List + + fun existingObjects(prefix: String): Map +} + +data class PhotoUploadTarget( + val objectKey: String, + val contentType: String, +) + +data class BlobMeta( + val size: Long, + val createdAt: Instant, +) diff --git a/src/main/kotlin/com/github/nexters/ppotto/image/domain/UploadStatus.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/UploadStatus.kt similarity index 57% rename from src/main/kotlin/com/github/nexters/ppotto/image/domain/UploadStatus.kt rename to src/main/kotlin/com/github/nexters/ppotto/analysis/domain/UploadStatus.kt index 2e28d93..07ca672 100644 --- a/src/main/kotlin/com/github/nexters/ppotto/image/domain/UploadStatus.kt +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/domain/UploadStatus.kt @@ -1,4 +1,4 @@ -package com.github.nexters.ppotto.image.domain +package com.github.nexters.ppotto.analysis.domain enum class UploadStatus { PENDING, diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/AnalysisRepository.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/AnalysisRepository.kt new file mode 100644 index 0000000..b2d0e0f --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/AnalysisRepository.kt @@ -0,0 +1,54 @@ +package com.github.nexters.ppotto.analysis.infrastructure + +import com.github.nexters.ppotto.analysis.domain.Analysis +import com.github.nexters.ppotto.analysis.domain.AnalysisStatus +import com.github.nexters.ppotto.jooq.tables.records.AnalysisRecord +import com.github.nexters.ppotto.jooq.tables.references.ANALYSIS +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +class AnalysisRepository( + private val dslContext: DSLContext, +) { + fun save( + userId: UUID, + boardId: UUID, + ): Analysis = + dslContext + .insertInto(ANALYSIS, ANALYSIS.USER_ID, ANALYSIS.BOARD_ID, ANALYSIS.STATUS) + .values(userId, boardId, AnalysisStatus.UPLOADING.name) + .returning() + .fetchOne()!! + .toDomain() + + fun findById(id: UUID): Analysis? = + dslContext + .selectFrom(ANALYSIS) + .where(ANALYSIS.ID.eq(id)) + .fetchOne() + ?.toDomain() + + fun findByIdForUpdate(id: UUID): Analysis? = + dslContext + .selectFrom(ANALYSIS) + .where(ANALYSIS.ID.eq(id)) + .forUpdate() + .fetchOne() + ?.toDomain() + + private fun AnalysisRecord.toDomain() = + Analysis( + id = id!!, + userId = userId, + boardId = boardId, + status = AnalysisStatus.valueOf(status), + progress = progress!!, + failedReason = failedReason, + startedAt = startedAt, + completedAt = completedAt, + createdAt = createdAt!!, + updatedAt = updatedAt!!, + ) +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/GcsPhotoStorage.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/GcsPhotoStorage.kt new file mode 100644 index 0000000..9ebd4f2 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/GcsPhotoStorage.kt @@ -0,0 +1,59 @@ +package com.github.nexters.ppotto.analysis.infrastructure + +import com.github.nexters.ppotto.analysis.domain.BlobMeta +import com.github.nexters.ppotto.analysis.domain.PhotoStorage +import com.github.nexters.ppotto.analysis.domain.PhotoUploadTarget +import com.github.nexters.ppotto.global.config.GcsProperties +import com.google.cloud.storage.BlobId +import com.google.cloud.storage.BlobInfo +import com.google.cloud.storage.HttpMethod +import com.google.cloud.storage.Storage +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class GcsPhotoStorage( + private val storage: Storage, + private val gcsProperties: GcsProperties, +) : PhotoStorage { + companion object { + private const val MAX_PHOTO_SIZE_BYTES = 15_728_640 + } + + override fun issueUploadUrls(targets: List): List = + // GCS V4 서명은 로컬 크립토 연산으로, 배치 서명 API가 없다. + // 반복은 이 구현 내부로 캡슐화하고 서비스 레이어는 이 메서드를 한 번만 호출한다. + targets.map { signUrl(it.objectKey, it.contentType) } + + override fun existingObjects(prefix: String): Map = + storage + .list(gcsProperties.bucket, Storage.BlobListOption.prefix(prefix)) + .iterateAll() + .associate { it.name to BlobMeta(it.size, it.createTimeOffsetDateTime.toInstant()) } + + private fun signUrl( + objectKey: String, + contentType: String, + ): String { + val blobInfo = + BlobInfo + .newBuilder(BlobId.of(gcsProperties.bucket, objectKey)) + .setContentType(contentType) + .build() + + return storage + .signUrl( + blobInfo, + gcsProperties.uploadSignedUrlExpirationMinutes, + TimeUnit.MINUTES, + Storage.SignUrlOption.httpMethod(HttpMethod.PUT), + Storage.SignUrlOption.withV4Signature(), + Storage.SignUrlOption.withExtHeaders( + mapOf( + "Content-Type" to contentType, + "x-goog-content-length-range" to "0,$MAX_PHOTO_SIZE_BYTES", + ), + ), + ).toString() + } +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoObjectKeys.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoObjectKeys.kt new file mode 100644 index 0000000..fb03bd9 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoObjectKeys.kt @@ -0,0 +1,18 @@ +package com.github.nexters.ppotto.analysis.infrastructure + +import com.github.nexters.ppotto.analysis.domain.PhotoContentType +import com.github.nexters.ppotto.global.storage.ObjectKeyGenerator +import java.util.UUID + +object PhotoObjectKeys { + private const val NAMESPACE = "photos" + private val objectKeyGenerator = ObjectKeyGenerator() + + fun prefixFor(analysisId: UUID): String = objectKeyGenerator.prefix(NAMESPACE, analysisId.toString()) + + fun keyFor( + analysisId: UUID, + photoId: UUID, + contentType: PhotoContentType, + ): String = objectKeyGenerator.generate(NAMESPACE, analysisId.toString(), id = photoId, extension = contentType.extension) +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoRepository.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoRepository.kt new file mode 100644 index 0000000..a4ae55c --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoRepository.kt @@ -0,0 +1,90 @@ +package com.github.nexters.ppotto.analysis.infrastructure + +import com.github.nexters.ppotto.analysis.domain.Photo +import com.github.nexters.ppotto.analysis.domain.PhotoContentType +import com.github.nexters.ppotto.analysis.domain.UploadStatus +import com.github.nexters.ppotto.jooq.tables.records.PhotosRecord +import com.github.nexters.ppotto.jooq.tables.references.PHOTOS +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.Instant +import java.util.UUID + +data class PhotoCreate( + val contentType: PhotoContentType, + val takenAt: Instant, +) + +@Repository +class PhotoRepository( + private val dslContext: DSLContext, +) { + fun saveAll( + analysisId: UUID, + boardId: UUID, + items: List, + ): List { + if (items.isEmpty()) return emptyList() + + var insert = + dslContext + .insertInto(PHOTOS, PHOTOS.ANALYSIS_ID, PHOTOS.BOARD_ID, PHOTOS.CONTENT_TYPE, PHOTOS.TAKEN_AT) + items.forEach { item -> + insert = insert.values(analysisId, boardId, item.contentType.mimeType, item.takenAt) + } + return insert + .returning() + .fetch() + .map { it.toDomain() } + } + + fun findPendingByAnalysisId(analysisId: UUID): List = + dslContext + .selectFrom(PHOTOS) + .where(PHOTOS.ANALYSIS_ID.eq(analysisId)) + .and(PHOTOS.UPLOAD_STATUS.eq(UploadStatus.PENDING.name)) + .fetch() + .map { it.toDomain() } + + fun findAllByAnalysisId(analysisId: UUID): List = + dslContext + .selectFrom(PHOTOS) + .where(PHOTOS.ANALYSIS_ID.eq(analysisId)) + .fetch() + .map { it.toDomain() } + + fun updateStatusBatch( + updates: Map, + expectedStatus: UploadStatus, + newStatus: UploadStatus, + ): List { + if (updates.isEmpty()) return emptyList() + + return updates.mapNotNull { (id, uploadedAt) -> + dslContext + .update(PHOTOS) + .set(PHOTOS.UPLOAD_STATUS, newStatus.name) + .set(PHOTOS.UPLOADED_AT, uploadedAt) + .where(PHOTOS.ID.eq(id)) + .and(PHOTOS.UPLOAD_STATUS.eq(expectedStatus.name)) + .returning() + .fetchOne() + ?.toDomain() + } + } + + private fun PhotosRecord.toDomain() = + Photo( + id = id!!, + analysisId = analysisId, + boardId = boardId, + contentType = + PhotoContentType.entries.find { it.mimeType == contentType } + ?: error("unknown photos.content_type: $contentType"), + uploadStatus = UploadStatus.valueOf(uploadStatus!!), + uploadedAt = uploadedAt, + takenAt = takenAt, + createdAt = createdAt!!, + updatedAt = updatedAt!!, + ) +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/AnalysisController.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/AnalysisController.kt new file mode 100644 index 0000000..c057a85 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/AnalysisController.kt @@ -0,0 +1,39 @@ +package com.github.nexters.ppotto.analysis.presentation + +import com.github.nexters.ppotto.analysis.application.AnalysisService +import com.github.nexters.ppotto.analysis.presentation.dto.CreateAnalysisRequest +import com.github.nexters.ppotto.analysis.presentation.dto.CreateAnalysisResponse +import com.github.nexters.ppotto.analysis.presentation.dto.StartUploadResponse +import com.github.nexters.ppotto.global.response.ApiResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/analysis", version = "1") +class AnalysisController( + private val analysisService: AnalysisService, +) { + @PostMapping + fun create( + @Valid @RequestBody request: CreateAnalysisRequest, + ): ApiResponse { + val result = analysisService.createAnalysis(request.boardId, request.photos.map { it.toServiceRequest() }) + return ApiResponse.success(CreateAnalysisResponse.from(result)) + } + + @PostMapping("/{analysisId}/start") + @ResponseStatus(HttpStatus.ACCEPTED) + fun start( + @PathVariable analysisId: UUID, + ): ApiResponse { + val result = analysisService.startUpload(analysisId) + return ApiResponse.success(StartUploadResponse.from(result)) + } +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/CreateAnalysisRequest.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/CreateAnalysisRequest.kt new file mode 100644 index 0000000..d64e67b --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/CreateAnalysisRequest.kt @@ -0,0 +1,25 @@ +package com.github.nexters.ppotto.analysis.presentation.dto + +import com.github.nexters.ppotto.analysis.application.PhotoUploadItemRequest +import jakarta.validation.Valid +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import java.time.Instant +import java.util.UUID + +data class CreateAnalysisRequest( + @field:NotNull + val boardId: UUID, + @field:NotEmpty + @field:Size(min = 90, max = 100) + val photos: List<@Valid PhotoUploadItem>, +) + +data class PhotoUploadItem( + @field:NotNull + val takenAt: Instant, + val contentType: String? = null, +) { + fun toServiceRequest() = PhotoUploadItemRequest(takenAt, contentType) +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/CreateAnalysisResponse.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/CreateAnalysisResponse.kt new file mode 100644 index 0000000..a1c7abc --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/CreateAnalysisResponse.kt @@ -0,0 +1,22 @@ +package com.github.nexters.ppotto.analysis.presentation.dto + +import com.github.nexters.ppotto.analysis.application.AnalysisCreationResult +import java.util.UUID + +data class CreateAnalysisResponse( + val analysisId: UUID, + val uploads: List, +) { + companion object { + fun from(result: AnalysisCreationResult): CreateAnalysisResponse = + CreateAnalysisResponse( + analysisId = result.analysisId, + uploads = result.uploads.map { PhotoUploadUrlItem(photoId = it.photoId, uploadUrl = it.uploadUrl) }, + ) + } +} + +data class PhotoUploadUrlItem( + val photoId: UUID, + val uploadUrl: String, +) diff --git a/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/StartUploadResponse.kt b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/StartUploadResponse.kt new file mode 100644 index 0000000..865841e --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/analysis/presentation/dto/StartUploadResponse.kt @@ -0,0 +1,15 @@ +package com.github.nexters.ppotto.analysis.presentation.dto + +import com.github.nexters.ppotto.analysis.application.UploadVerificationResult +import java.util.UUID + +data class StartUploadResponse( + val uploadedCount: Int, + val failedCount: Int, + val failedPhotoIds: List, +) { + companion object { + fun from(result: UploadVerificationResult): StartUploadResponse = + StartUploadResponse(result.uploadedCount, result.failedCount, result.failedPhotoIds) + } +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/board/AGENTS.md b/src/main/kotlin/com/github/nexters/ppotto/board/AGENTS.md index d5bcb3c..a08c670 100644 --- a/src/main/kotlin/com/github/nexters/ppotto/board/AGENTS.md +++ b/src/main/kotlin/com/github/nexters/ppotto/board/AGENTS.md @@ -8,11 +8,12 @@ Board domain. `User : Board = 1:N` via `boards.user_id`, kept as a plain column |-----------|---------| | `domain/Board.kt` | Pure Kotlin model: `id`, `userId`, `createdAt`, `updatedAt` | | `infrastructure/BoardRepository.kt` | DSLContext-based persistence: `save(userId)`, `findById()`, `findByUserId()` | +| `application/BoardQueryService.kt` | `getById(id)` — throws `NotFoundException` if not found. The `analysis` domain accesses this only through this Service when validating board existence (cross-domain access must never go through the Repository directly) | ## Rules -- No `presentation`/`application` layer yet. +- No `presentation` layer yet. - Same DB-generated column convention as `user/`: `id`/`createdAt`/`updatedAt` come back via `RETURNING`, never set client-side. -- `user_id` has no FK constraint — do not assume the DB will reject an orphaned `user_id`; validate existence at the application layer once one exists. +- `user_id` has no FK constraint — do not assume the DB will reject an orphaned `user_id`; `BoardQueryService.getById` is responsible for existence validation. Update this file when layers are added to this domain. diff --git a/src/main/kotlin/com/github/nexters/ppotto/board/application/BoardQueryService.kt b/src/main/kotlin/com/github/nexters/ppotto/board/application/BoardQueryService.kt new file mode 100644 index 0000000..f66b98d --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/board/application/BoardQueryService.kt @@ -0,0 +1,14 @@ +package com.github.nexters.ppotto.board.application + +import com.github.nexters.ppotto.board.domain.Board +import com.github.nexters.ppotto.board.infrastructure.BoardRepository +import com.github.nexters.ppotto.global.error.NotFoundException +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class BoardQueryService( + private val boardRepository: BoardRepository, +) { + fun getById(id: UUID): Board = boardRepository.findById(id) ?: throw NotFoundException() +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/global/AGENTS.md b/src/main/kotlin/com/github/nexters/ppotto/global/AGENTS.md index 5f9c1f1..66f0c16 100644 --- a/src/main/kotlin/com/github/nexters/ppotto/global/AGENTS.md +++ b/src/main/kotlin/com/github/nexters/ppotto/global/AGENTS.md @@ -11,6 +11,7 @@ Shared module used by all domains. Contains no business logic. | `jooq/` | Custom jOOQ converters used by codegen `forcedType` | | `logging/` | Request logging filter (see `logging/AGENTS.md`) | | `response/` | Response envelope models (see `response/AGENTS.md`) | +| `storage/` | `ObjectKeyGenerator` — domain-agnostic GCS object key/prefix builder shared across domains (see `storage/AGENTS.md`) | ## Rules diff --git a/src/main/kotlin/com/github/nexters/ppotto/global/config/AGENTS.md b/src/main/kotlin/com/github/nexters/ppotto/global/config/AGENTS.md index 86423e0..4d08e54 100644 --- a/src/main/kotlin/com/github/nexters/ppotto/global/config/AGENTS.md +++ b/src/main/kotlin/com/github/nexters/ppotto/global/config/AGENTS.md @@ -6,14 +6,18 @@ Spring configuration beans. | File | Description | |------|-------------| -| `SecurityConfig.kt` | Two scoped filter chains: swagger chain (`@Order(0)`, prod only, Basic auth on swagger paths) and permit-all catch-all. `API_CHAIN_ORDER = 100` is reserved for a future JWT Bearer chain on `/api/**`. Also defines `CorsConfigurationSource` (security-level CORS, from `CorsProperties`) | +| `SecurityConfig.kt` | Two scoped filter chains: swagger chain (`@Order(0)`, prod only, Basic auth on swagger paths) and permit-all catch-all. `API_CHAIN_ORDER = 100` is reserved for a future JWT Bearer chain scoping authenticated endpoints (no `/api` path prefix exists — API versioning is via the `X-API-Version` header, see `WebMvcConfig.kt`). Also defines `CorsConfigurationSource` (security-level CORS, from `CorsProperties`) | | `CorsProperties.kt` | `@ConfigurationProperties("cors")` + `@Validated`. Bound from `CORS_ALLOWED_ORIGINS` | | `OpenApiConfig.kt` | Swagger metadata: title "뽀또 API", response envelope contract, common error code table | +| `WebMvcConfig.kt` | `WebMvcConfigurer.configureApiVersioning`: `X-API-Version` request header, default version `1` (Spring Framework 7 native API versioning) | +| `GcsProperties.kt` | `@ConfigurationProperties("gcs")` + `@Validated`. `bucket`, `credentialsPath`, `uploadSignedUrlExpirationMinutes` (15 min per spec — named `upload*` since read/GET signed URLs, when added, need a different 1-hour expiration and can't share this property), `timeoutMillis` — bound from `GCS_*` env vars | +| `GcsConfig.kt` | Defines the `Storage` bean. Authenticates by reading the service account key file (`gcsProperties.credentialsPath`) via `ServiceAccountCredentials.fromStream` — not the `GOOGLE_APPLICATION_CREDENTIALS` ambient method. Sets connect/read timeout (`gcsProperties.timeoutMillis`) via `HttpTransportOptions` so a slow/hanging GCS call can't block indefinitely | ## Rules - New `XxxProperties` classes follow the CorsProperties pattern: data class, constructor binding, `@Validated` + jakarta constraints. - When adding the JWT chain, insert it as a new bean at `API_CHAIN_ORDER`; do not modify the existing chains. - 401/403 from the security filter chain are not wrapped in `ApiResponse`; align them via AuthenticationEntryPoint/AccessDeniedHandler when JWT lands. +- Don't mark beans that read local files/external credentials (e.g. `GcsConfig.storage`) `@Lazy`. Every integration test in this repo (extending `IntegrationTest`) boots the **entire** application context via `@SpringBootTest`, so instead of scattering `@Lazy` through production code to dodge test startup, provide a real, parseable dummy credentials file in tests (`src/test/resources/dummy-gcs-key.json`) so the bean can be created eagerly as normal. Building the `Storage` bean and V4-signing are local credential parsing / RSA signing operations with no network call, so a dummy key is enough. Follow this same pattern (keep the real bean as-is, add a dummy test fixture) for any new bean that reads local files or external credentials. Update this file when configuration beans change. diff --git a/src/main/kotlin/com/github/nexters/ppotto/global/config/GcsConfig.kt b/src/main/kotlin/com/github/nexters/ppotto/global/config/GcsConfig.kt new file mode 100644 index 0000000..fcc2b06 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/global/config/GcsConfig.kt @@ -0,0 +1,27 @@ +package com.github.nexters.ppotto.global.config + +import com.google.auth.oauth2.ServiceAccountCredentials +import com.google.cloud.http.HttpTransportOptions +import com.google.cloud.storage.Storage +import com.google.cloud.storage.StorageOptions +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.io.FileInputStream + +@Configuration +class GcsConfig { + @Bean + fun storage(gcsProperties: GcsProperties): Storage = + StorageOptions + .newBuilder() + .setCredentials( + FileInputStream(gcsProperties.credentialsPath).use { ServiceAccountCredentials.fromStream(it) }, + ).setTransportOptions( + HttpTransportOptions + .newBuilder() + .setConnectTimeout(gcsProperties.timeoutMillis.toInt()) + .setReadTimeout(gcsProperties.timeoutMillis.toInt()) + .build(), + ).build() + .service +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/global/config/GcsProperties.kt b/src/main/kotlin/com/github/nexters/ppotto/global/config/GcsProperties.kt new file mode 100644 index 0000000..6e5fd16 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/global/config/GcsProperties.kt @@ -0,0 +1,26 @@ +package com.github.nexters.ppotto.global.config + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@Validated +@ConfigurationProperties(prefix = "gcs") +data class GcsProperties( + @field:NotBlank + val bucket: String, + @field:NotBlank + val credentialsPath: String, + @field:Positive + @field:Max(SIGNED_URL_MAX_EXPIRATION_MINUTES) + val uploadSignedUrlExpirationMinutes: Long, + @field:Positive + val timeoutMillis: Long, +) + +// GCS V4 signed URL의 실제 프로토콜 상한은 7일이지만, URL 유출 시 악용 가능 기간을 줄이기 위해 서비스 +// 정책으로 1일을 상한으로 둔다. SDK는 이 상한을 검증하지 않고 실제 서명된 URL 사용 시점에만 서버가 +// 거부하므로, 잘못된 설정을 기동 시점에 잡아내기 위해 애플리케이션 레벨에서 직접 검증한다. +private const val SIGNED_URL_MAX_EXPIRATION_MINUTES = 24L * 60 diff --git a/src/main/kotlin/com/github/nexters/ppotto/global/config/WebMvcConfig.kt b/src/main/kotlin/com/github/nexters/ppotto/global/config/WebMvcConfig.kt new file mode 100644 index 0000000..4b25b54 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/global/config/WebMvcConfig.kt @@ -0,0 +1,14 @@ +package com.github.nexters.ppotto.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun configureApiVersioning(configurer: ApiVersionConfigurer) { + configurer + .useRequestHeader("X-API-Version") + .setDefaultVersion("1") + } +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/global/storage/AGENTS.md b/src/main/kotlin/com/github/nexters/ppotto/global/storage/AGENTS.md new file mode 100644 index 0000000..52cd837 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/global/storage/AGENTS.md @@ -0,0 +1,16 @@ + + +# storage + +Domain-agnostic GCS object key naming utility. No business knowledge of any specific domain (photo, sticker, ...) lives here. + +| File | Description | +|------|-------------| +| `ObjectKeyGenerator.kt` | `prefix(vararg pathSegments)` joins segments into a `"a/b/"`-style prefix. `generate(vararg pathSegments, id, extension)` appends `{id}.{extension}` to that prefix | + +## Rules + +- Pure, stateless `@Component` — no I/O, no domain imports, no content-type/extension mapping knowledge. It only joins path segments; resolving which extension a given content type maps to is a domain concern (e.g. `analysis`'s `PhotoContentType` enum), not this class's. +- Any domain that needs a GCS object key (currently `analysis`'s `PhotoObjectKeys`) wraps this class with its own namespace/segment convention and passes the already-resolved `extension` string. + +Update this file when the key-generation rules change. diff --git a/src/main/kotlin/com/github/nexters/ppotto/global/storage/ObjectKeyGenerator.kt b/src/main/kotlin/com/github/nexters/ppotto/global/storage/ObjectKeyGenerator.kt new file mode 100644 index 0000000..0b1fda6 --- /dev/null +++ b/src/main/kotlin/com/github/nexters/ppotto/global/storage/ObjectKeyGenerator.kt @@ -0,0 +1,15 @@ +package com.github.nexters.ppotto.global.storage + +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class ObjectKeyGenerator { + fun prefix(vararg pathSegments: String): String = pathSegments.joinToString("/", postfix = "/") + + fun generate( + vararg pathSegments: String, + id: UUID, + extension: String, + ): String = "${prefix(*pathSegments)}$id.$extension" +} diff --git a/src/main/kotlin/com/github/nexters/ppotto/image/AGENTS.md b/src/main/kotlin/com/github/nexters/ppotto/image/AGENTS.md deleted file mode 100644 index b2d5fd9..0000000 --- a/src/main/kotlin/com/github/nexters/ppotto/image/AGENTS.md +++ /dev/null @@ -1,20 +0,0 @@ - - -# image - -Image domain. `Board : Image = 1:N` via `images.board_id`, no DB-level FK constraint (same reasoning as `board/`). Backs the upcoming GCP presigned-url upload flow (separate PR) — this PR only sets up the entity. - -| Directory | Description | -|-----------|---------| -| `domain/Image.kt` | Pure Kotlin model: `id`, `boardId`, `uploadStatus`, `uploadSessionId`, `createdAt` | -| `domain/UploadStatus.kt` | `PENDING` / `COMPLETED` / `FAILED` — pure Kotlin enum, not the jOOQ-generated type | -| `infrastructure/ImageRepository.kt` | DSLContext-based persistence: `save(boardId, uploadSessionId)`, `findById()`, `findByBoardId()` | - -## Rules - -- No `presentation`/`application` layer yet — presigned URL issuance/callback (which will transition `upload_status`) lands in a later PR. -- `upload_status` is a DB `VARCHAR`, not a Postgres `ENUM` type — adding a new status later is a plain data change, no `ALTER TYPE` migration needed. Validation of the value set lives only in `UploadStatus`; the repository maps `String ↔ UploadStatus` (`UploadStatus.valueOf(...)` / `.name`) at the boundary. -- `board_id` has no FK constraint, same caveat as `board.user_id` in `board/AGENTS.md`. -- No `updated_at` on this table (unlike `user`/`board`) — only `created_at`. If `upload_status` transitions need to be timestamped later, add the column explicitly rather than assuming `created_at` reflects it. - -Update this file when layers are added to this domain. diff --git a/src/main/kotlin/com/github/nexters/ppotto/image/domain/Image.kt b/src/main/kotlin/com/github/nexters/ppotto/image/domain/Image.kt deleted file mode 100644 index e5936c2..0000000 --- a/src/main/kotlin/com/github/nexters/ppotto/image/domain/Image.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.nexters.ppotto.image.domain - -import java.time.Instant -import java.util.UUID - -class Image( - val id: UUID, - val boardId: UUID, - val uploadStatus: UploadStatus, - val uploadSessionId: UUID, - val createdAt: Instant, - val updatedAt: Instant, -) diff --git a/src/main/kotlin/com/github/nexters/ppotto/image/infrastructure/ImageRepository.kt b/src/main/kotlin/com/github/nexters/ppotto/image/infrastructure/ImageRepository.kt deleted file mode 100644 index 8915687..0000000 --- a/src/main/kotlin/com/github/nexters/ppotto/image/infrastructure/ImageRepository.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.nexters.ppotto.image.infrastructure - -import com.github.nexters.ppotto.image.domain.Image -import com.github.nexters.ppotto.image.domain.UploadStatus -import com.github.nexters.ppotto.jooq.tables.records.ImagesRecord -import com.github.nexters.ppotto.jooq.tables.references.IMAGES -import org.jooq.DSLContext -import org.springframework.stereotype.Repository -import java.util.UUID - -@Repository -class ImageRepository( - private val dslContext: DSLContext, -) { - fun save( - boardId: UUID, - uploadSessionId: UUID, - ): Image = - dslContext - .insertInto(IMAGES, IMAGES.BOARD_ID, IMAGES.UPLOAD_SESSION_ID) - .values(boardId, uploadSessionId) - .returning() - .fetchOne()!! - .toDomain() - - fun findById(id: UUID): Image? = - dslContext - .selectFrom(IMAGES) - .where(IMAGES.ID.eq(id)) - .fetchOne() - ?.toDomain() - - fun findByBoardId(boardId: UUID): List = - dslContext - .selectFrom(IMAGES) - .where(IMAGES.BOARD_ID.eq(boardId)) - .fetch() - .map { it.toDomain() } - - private fun ImagesRecord.toDomain() = - Image( - id = id!!, - boardId = boardId, - uploadStatus = UploadStatus.valueOf(uploadStatus!!), - uploadSessionId = uploadSessionId, - createdAt = createdAt!!, - updatedAt = updatedAt!!, - ) -} diff --git a/src/main/resources/AGENTS.md b/src/main/resources/AGENTS.md index 519e0a8..0dfbce8 100644 --- a/src/main/resources/AGENTS.md +++ b/src/main/resources/AGENTS.md @@ -17,7 +17,8 @@ Configuration and database migrations. | `config/springdoc.yml` | Swagger UI options | | `config/cors.yml` | `cors.allowed-origins` from `${CORS_ALLOWED_ORIGINS}` | | `config/security.yml` | Basic auth user for swagger from `${SWAGGER_USER}` / `${SWAGGER_PASSWORD}` | -| `db/migration/` | Flyway migrations: `V{yyyyMMddHHmmss}__{description}.sql` (timestamp version). The first migration enables the pgvector extension; the second creates `users`/`boards`/`images` and the shared `set_updated_at()` trigger function | +| `config/gcs.yml` | `gcs.bucket` / `gcs.credentials-path` / `gcs.upload-signed-url-expiration-minutes` from `${GCS_*}` | +| `db/migration/` | Flyway migrations: `V{yyyyMMddHHmmss}__{description}.sql` (timestamp version). The first migration enables the pgvector extension; the second creates `users`/`boards`/`analysis`/`photos` and the shared `set_updated_at()` trigger function | ## Rules diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f041265..6687829 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,7 @@ spring: - classpath:config/server.yml - classpath:config/datasource.yml - classpath:config/flyway.yml + - classpath:config/gcs.yml - classpath:config/jooq.yml - classpath:config/jackson.yml - classpath:config/actuator.yml diff --git a/src/main/resources/config/gcs.yml b/src/main/resources/config/gcs.yml new file mode 100644 index 0000000..3b2f6aa --- /dev/null +++ b/src/main/resources/config/gcs.yml @@ -0,0 +1,5 @@ +gcs: + bucket: ${GCS_BUCKET} + credentials-path: ${GCS_CREDENTIALS_PATH} + upload-signed-url-expiration-minutes: ${GCS_UPLOAD_SIGNED_URL_EXPIRATION_MINUTES} + timeout-millis: ${GCS_TIMEOUT_MILLIS} diff --git a/src/main/resources/db/migration/V20260726030450__create_user_board_image.sql b/src/main/resources/db/migration/V20260726030450__create_user_board_analysis_photo.sql similarity index 51% rename from src/main/resources/db/migration/V20260726030450__create_user_board_image.sql rename to src/main/resources/db/migration/V20260726030450__create_user_board_analysis_photo.sql index 9c1e07b..a0fda24 100644 --- a/src/main/resources/db/migration/V20260726030450__create_user_board_image.sql +++ b/src/main/resources/db/migration/V20260726030450__create_user_board_analysis_photo.sql @@ -29,18 +29,41 @@ CREATE TRIGGER boards_set_updated_at BEFORE UPDATE ON boards FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -CREATE TABLE images ( +CREATE TABLE analysis ( id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL, + board_id UUID NOT NULL, + status VARCHAR(50) NOT NULL, + progress INT NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), + failed_reason TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_analysis_user_created ON analysis (user_id, created_at); +CREATE INDEX idx_analysis_board_id ON analysis (board_id); + +CREATE TRIGGER analysis_set_updated_at +BEFORE UPDATE ON analysis +FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE photos ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + analysis_id UUID NOT NULL, board_id UUID NOT NULL, + content_type VARCHAR(50) NOT NULL, upload_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - upload_session_id UUID NOT NULL, + uploaded_at TIMESTAMPTZ, + taken_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX idx_images_board_id ON images (board_id); -CREATE INDEX idx_images_upload_session_id ON images (upload_session_id); +CREATE INDEX idx_photos_analysis_id ON photos (analysis_id); +CREATE INDEX idx_photos_board_id ON photos (board_id); -CREATE TRIGGER images_set_updated_at -BEFORE UPDATE ON images +CREATE TRIGGER photos_set_updated_at +BEFORE UPDATE ON photos FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisServiceTest.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisServiceTest.kt new file mode 100644 index 0000000..40b38b9 --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/application/AnalysisServiceTest.kt @@ -0,0 +1,227 @@ +package com.github.nexters.ppotto.analysis.application + +import com.github.nexters.ppotto.analysis.domain.AnalysisStatus +import com.github.nexters.ppotto.analysis.domain.UploadStatus +import com.github.nexters.ppotto.analysis.infrastructure.AnalysisRepository +import com.github.nexters.ppotto.analysis.infrastructure.PhotoObjectKeys +import com.github.nexters.ppotto.analysis.infrastructure.PhotoRepository +import com.github.nexters.ppotto.analysis.support.AnalysisTestConfig +import com.github.nexters.ppotto.analysis.support.FakePhotoStorage +import com.github.nexters.ppotto.board.infrastructure.BoardRepository +import com.github.nexters.ppotto.global.error.ConflictException +import com.github.nexters.ppotto.global.error.InvalidInputException +import com.github.nexters.ppotto.global.error.NotFoundException +import com.github.nexters.ppotto.jooq.tables.references.ANALYSIS +import com.github.nexters.ppotto.support.IntegrationTest +import com.github.nexters.ppotto.user.infrastructure.UserRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.jooq.DSLContext +import org.springframework.context.annotation.Import +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +@Import(AnalysisTestConfig::class) +class AnalysisServiceTest( + private val analysisService: AnalysisService, + private val analysisRepository: AnalysisRepository, + private val photoRepository: PhotoRepository, + private val photoStorage: FakePhotoStorage, + private val dslContext: DSLContext, + boardRepository: BoardRepository, + userRepository: UserRepository, +) : IntegrationTest({ + val photoObjectKeys = PhotoObjectKeys + + Given("Board가 등록된 상태에서 여러 장의 사진으로 분석 생성을 요청하면") { + val board = boardRepository.save(userRepository.save().id) + val photos = + (0 until 90).map { i -> + PhotoUploadItemRequest( + Instant.parse("2026-07-01T00:00:00Z").plusSeconds(i.toLong()), + if (i % 2 == 0) "image/jpeg" else "image/png", + ) + } + + val result = analysisService.createAnalysis(board.id, photos) + + Then("UPLOADING 상태의 analysis가 생성된다") { + val analysis = analysisRepository.findById(result.analysisId) + analysis.shouldNotBeNull() + analysis.status shouldBe AnalysisStatus.UPLOADING + analysis.boardId shouldBe board.id + analysis.userId shouldBe board.userId + } + + Then("요청 순서와 동일하게 photo가 PENDING 상태로 생성되고 signed URL이 발급된다") { + result.uploads shouldHaveSize photos.size + val savedPhotos = photoRepository.findAllByAnalysisId(result.analysisId) + savedPhotos shouldHaveSize photos.size + savedPhotos.forEach { it.uploadStatus shouldBe UploadStatus.PENDING } + + result.uploads.forEachIndexed { i, upload -> + val photo = savedPhotos.first { it.id == upload.photoId } + photo.takenAt shouldBe photos[i].takenAt + photo.contentType.mimeType shouldBe + (photos[i].contentType ?: "image/jpeg") + + val expectedKey = photoObjectKeys.keyFor(result.analysisId, upload.photoId, photo.contentType) + upload.uploadUrl shouldBe "https://fake-signed-url/$expectedKey" + } + } + } + + Given("존재하지 않는 boardId로") { + When("분석 생성을 요청하면") { + Then("NotFoundException이 발생한다") { + shouldThrow { + analysisService.createAnalysis( + UUID.randomUUID(), + listOf(PhotoUploadItemRequest(Instant.now(), null)), + ) + } + } + } + } + + Given("분석이 생성되고 사진 중 일부만 실제로 업로드된 상태에서") { + val board = boardRepository.save(userRepository.save().id) + val created = + analysisService.createAnalysis( + board.id, + listOf( + PhotoUploadItemRequest(Instant.now(), "image/jpeg"), + PhotoUploadItemRequest(Instant.now(), "image/png"), + ), + ) + val missingPhotoId = created.uploads[1].photoId + val missingPhoto = photoRepository.findPendingByAnalysisId(created.analysisId).first { it.id == missingPhotoId } + photoStorage.markMissing(photoObjectKeys.keyFor(created.analysisId, missingPhotoId, missingPhoto.contentType)) + + When("업로드 완료를 통보하면") { + val result = analysisService.startUpload(created.analysisId) + + Then("업로드된 사진은 COMPLETED로 바뀌고, 누락된 사진은 PENDING을 유지한 채 failedPhotoIds로만 보고된다") { + result.uploadedCount shouldBe 1 + result.failedCount shouldBe 1 + result.failedPhotoIds shouldContainExactly listOf(missingPhotoId) + + val missing = photoRepository.findPendingByAnalysisId(created.analysisId) + missing shouldHaveSize 1 + missing.first().id shouldBe missingPhotoId + missing.first().uploadStatus shouldBe UploadStatus.PENDING + } + + Then("analysis의 status/startedAt은 그대로 UPLOADING/null이다") { + val analysis = analysisRepository.findById(created.analysisId) + analysis.shouldNotBeNull() + analysis.status shouldBe AnalysisStatus.UPLOADING + analysis.startedAt.shouldBeNull() + } + } + + When("누락됐던 사진이 이후 업로드를 마치고 다시 통보하면") { + analysisService.startUpload(created.analysisId) + val uploadedAt = Instant.parse("2026-06-15T10:00:00Z").truncatedTo(ChronoUnit.MICROS) + photoStorage.markUploaded( + photoObjectKeys.keyFor(created.analysisId, missingPhotoId, missingPhoto.contentType), + createdAt = uploadedAt, + ) + val result = analysisService.startUpload(created.analysisId) + + Then("뒤늦게 업로드된 사진도 COMPLETED로 바뀌고, 응답은 이번 호출의 델타가 아닌 analysis 전체의 최종 집계다") { + result.uploadedCount shouldBe 2 + result.failedCount shouldBe 0 + result.failedPhotoIds.shouldBeEmpty() + photoRepository.findPendingByAnalysisId(created.analysisId).shouldBeEmpty() + } + + Then("uploadedAt은 확인 시각이 아닌 실제 업로드(GCS 객체 생성) 시각으로 저장된다") { + val completedPhoto = photoRepository.findAllByAnalysisId(created.analysisId).first { it.id == missingPhotoId } + completedPhoto.uploadedAt shouldBe uploadedAt + } + } + + When("모든 사진이 COMPLETED로 확정된 뒤 다시 통보하면") { + analysisService.startUpload(created.analysisId) + photoStorage.markUploaded(photoObjectKeys.keyFor(created.analysisId, missingPhotoId, missingPhoto.contentType)) + analysisService.startUpload(created.analysisId) + val result = analysisService.startUpload(created.analysisId) + + Then("이미 처리된 사진은 다시 건드리지 않는다") { + result.uploadedCount shouldBe 0 + result.failedCount shouldBe 0 + result.failedPhotoIds.shouldBeEmpty() + } + } + } + + Given("분석이 생성되고 사진이 0바이트로(빈 바디로) 업로드된 상태에서") { + val board = boardRepository.save(userRepository.save().id) + val created = + analysisService.createAnalysis(board.id, listOf(PhotoUploadItemRequest(Instant.now(), "image/jpeg"))) + val photo = photoRepository.findPendingByAnalysisId(created.analysisId).first() + photoStorage.markUploaded(photoObjectKeys.keyFor(created.analysisId, photo.id, photo.contentType), size = 0) + + When("업로드 완료를 통보하면") { + val result = analysisService.startUpload(created.analysisId) + + Then("COMPLETED로 확정하지 않고 PENDING을 유지한 채 failedPhotoIds로 보고한다") { + result.uploadedCount shouldBe 0 + result.failedCount shouldBe 1 + result.failedPhotoIds shouldContainExactly listOf(photo.id) + + val pending = photoRepository.findPendingByAnalysisId(created.analysisId) + pending shouldHaveSize 1 + pending.first().id shouldBe photo.id + pending.first().uploadStatus shouldBe UploadStatus.PENDING + } + } + } + + Given("진행 중이 아닌(UPLOADING이 아닌) 분석에") { + val board = boardRepository.save(userRepository.save().id) + val created = + analysisService.createAnalysis(board.id, listOf(PhotoUploadItemRequest(Instant.now(), "image/jpeg"))) + dslContext + .update(ANALYSIS) + .set(ANALYSIS.STATUS, AnalysisStatus.ANALYZING.name) + .where(ANALYSIS.ID.eq(created.analysisId)) + .execute() + + When("업로드 완료를 통보하면") { + Then("ConflictException(ANALYSIS-003)이 발생한다") { + val exception = shouldThrow { analysisService.startUpload(created.analysisId) } + exception.errorCode.code shouldBe "ANALYSIS-003" + } + } + } + + Given("존재하지 않는 analysisId로") { + When("업로드 완료를 통보하면") { + Then("NotFoundException이 발생한다") { + shouldThrow { + analysisService.startUpload(UUID.randomUUID()) + } + } + } + } + + Given("Board가 등록된 상태에서") { + val board = boardRepository.save(userRepository.save().id) + + When("지원하지 않는 contentType으로 분석 생성을 요청하면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + analysisService.createAnalysis(board.id, listOf(PhotoUploadItemRequest(Instant.now(), "image/gif"))) + } + } + } + } + }) diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoContentTypeTest.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoContentTypeTest.kt new file mode 100644 index 0000000..cb98d40 --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/domain/PhotoContentTypeTest.kt @@ -0,0 +1,33 @@ +package com.github.nexters.ppotto.analysis.domain + +import com.github.nexters.ppotto.global.error.InvalidInputException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe + +class PhotoContentTypeTest : + BehaviorSpec({ + Given("지원하는 mime-type 문자열이 주어졌을 때") { + When("fromMimeType으로 변환하면") { + Then("각 mime-type에 대응하는 enum과 확장자를 반환한다") { + PhotoContentType.fromMimeType("image/jpeg") shouldBe PhotoContentType.JPEG + PhotoContentType.fromMimeType("image/png") shouldBe PhotoContentType.PNG + PhotoContentType.fromMimeType("image/heic") shouldBe PhotoContentType.HEIC + + PhotoContentType.JPEG.extension shouldBe "jpg" + PhotoContentType.PNG.extension shouldBe "png" + PhotoContentType.HEIC.extension shouldBe "heic" + } + } + } + + Given("지원하지 않는 mime-type 문자열이 주어졌을 때") { + When("fromMimeType으로 변환하면") { + Then("InvalidInputException이 발생한다") { + shouldThrow { + PhotoContentType.fromMimeType("image/gif") + } + } + } + } + }) diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/AnalysisRepositoryTest.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/AnalysisRepositoryTest.kt new file mode 100644 index 0000000..25c40ab --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/AnalysisRepositoryTest.kt @@ -0,0 +1,45 @@ +package com.github.nexters.ppotto.analysis.infrastructure + +import com.github.nexters.ppotto.analysis.domain.AnalysisStatus +import com.github.nexters.ppotto.board.infrastructure.BoardRepository +import com.github.nexters.ppotto.support.IntegrationTest +import com.github.nexters.ppotto.user.infrastructure.UserRepository +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class AnalysisRepositoryTest( + analysisRepository: AnalysisRepository, + boardRepository: BoardRepository, + userRepository: UserRepository, +) : IntegrationTest({ + Given("Board가 등록된 상태에서 Analysis를 저장하면") { + val board = boardRepository.save(userRepository.save().id) + val saved = analysisRepository.save(board.userId, board.id) + + When("저장된 아이디로 조회하면") { + val found = analysisRepository.findById(saved.id) + + Then("UPLOADING 상태의 Analysis를 반환한다") { + found?.id shouldBe saved.id + found?.userId shouldBe board.userId + found?.boardId shouldBe board.id + found?.status shouldBe AnalysisStatus.UPLOADING + found?.progress shouldBe 0 + } + } + } + + Given("존재하지 않는 아이디로") { + When("조회하면") { + val found = + analysisRepository.findById( + java.util.UUID + .randomUUID(), + ) + + Then("null을 반환한다") { + found.shouldBeNull() + } + } + } + }) diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoObjectKeysTest.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoObjectKeysTest.kt new file mode 100644 index 0000000..14e1ca4 --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoObjectKeysTest.kt @@ -0,0 +1,30 @@ +package com.github.nexters.ppotto.analysis.infrastructure + +import com.github.nexters.ppotto.analysis.domain.PhotoContentType +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldStartWith +import java.util.UUID + +class PhotoObjectKeysTest : + BehaviorSpec({ + Given("analysisId와 photoId가 주어졌을 때") { + val analysisId = UUID.randomUUID() + val photoId = UUID.randomUUID() + + When("keyFor를 호출하면") { + Then("photos/{analysisId}/{photoId}.{ext} 형태의 키를 반환한다") { + PhotoObjectKeys.keyFor(analysisId, photoId, PhotoContentType.JPEG) shouldBe + "photos/$analysisId/$photoId.jpg" + } + } + + When("prefixFor를 호출하면") { + Then("keyFor 결과의 접두사와 일치한다") { + val prefix = PhotoObjectKeys.prefixFor(analysisId) + prefix shouldBe "photos/$analysisId/" + PhotoObjectKeys.keyFor(analysisId, photoId, PhotoContentType.PNG) shouldStartWith prefix + } + } + } + }) diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoRepositoryTest.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoRepositoryTest.kt new file mode 100644 index 0000000..c6b511b --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/infrastructure/PhotoRepositoryTest.kt @@ -0,0 +1,108 @@ +package com.github.nexters.ppotto.analysis.infrastructure + +import com.github.nexters.ppotto.analysis.domain.PhotoContentType +import com.github.nexters.ppotto.analysis.domain.UploadStatus +import com.github.nexters.ppotto.board.infrastructure.BoardRepository +import com.github.nexters.ppotto.support.IntegrationTest +import com.github.nexters.ppotto.user.infrastructure.UserRepository +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +class PhotoRepositoryTest( + photoRepository: PhotoRepository, + analysisRepository: AnalysisRepository, + boardRepository: BoardRepository, + userRepository: UserRepository, +) : IntegrationTest({ + Given("Analysis가 등록된 상태에서 여러 Photo를 배치로 저장하면") { + val board = boardRepository.save(userRepository.save().id) + val analysis = analysisRepository.save(board.userId, board.id) + val items = + listOf( + PhotoCreate(PhotoContentType.JPEG, Instant.parse("2026-07-01T00:00:00Z")), + PhotoCreate(PhotoContentType.PNG, Instant.parse("2026-07-02T00:00:00Z")), + ) + + val saved = photoRepository.saveAll(analysis.id, board.id, items) + + When("입력 순서와 저장된 Photo를 비교하면") { + Then("요청 순서와 동일한 순서로 반환된다") { + saved shouldHaveSize 2 + saved.map { it.contentType } shouldBe listOf(PhotoContentType.JPEG, PhotoContentType.PNG) + saved.map { it.takenAt } shouldBe items.map { it.takenAt } + } + } + + When("analysisId로 PENDING 상태 Photo를 조회하면") { + val found = photoRepository.findPendingByAnalysisId(analysis.id) + + Then("저장된 Photo 전부를 반환한다") { + found.map { it.id } shouldContainExactlyInAnyOrder saved.map { it.id } + found.forEach { it.uploadStatus shouldBe UploadStatus.PENDING } + } + } + } + + Given("빈 아이템 목록으로 배치 저장을 호출하면") { + val board = boardRepository.save(userRepository.save().id) + val analysis = analysisRepository.save(board.userId, board.id) + + When("저장을 수행하면") { + val saved = photoRepository.saveAll(analysis.id, board.id, emptyList()) + + Then("빈 목록을 반환한다") { + saved.shouldBeEmpty() + } + } + } + + Given("PENDING 상태의 Photo가 저장된 상태에서") { + val board = boardRepository.save(userRepository.save().id) + val analysis = analysisRepository.save(board.userId, board.id) + + When("기대 상태(PENDING)를 걸고 COMPLETED로 배치 갱신하면") { + val saved = + photoRepository.saveAll(analysis.id, board.id, listOf(PhotoCreate(PhotoContentType.JPEG, Instant.now()))) + // Postgres TIMESTAMPTZ는 마이크로초까지만 저장하므로, Instant.now()의 나노초 부분까지 + // 그대로 왕복 비교하면 시스템 클럭 해상도에 따라(특히 Linux) 정밀도가 달라 실패할 수 있다. + val uploadedAt = Instant.now().truncatedTo(ChronoUnit.MICROS) + val updated = + photoRepository.updateStatusBatch( + saved.associate { it.id to uploadedAt }, + UploadStatus.PENDING, + UploadStatus.COMPLETED, + ) + + Then("갱신된 Photo를 반환한다") { + updated shouldHaveSize 1 + updated.first().uploadStatus shouldBe UploadStatus.COMPLETED + updated.first().uploadedAt shouldBe uploadedAt + } + } + + When("이미 다른 상태로 바뀐 뒤 다시 기대 상태(PENDING)를 걸고 갱신하면") { + val saved = + photoRepository.saveAll(analysis.id, board.id, listOf(PhotoCreate(PhotoContentType.JPEG, Instant.now()))) + photoRepository.updateStatusBatch(saved.associate { it.id to Instant.now() }, UploadStatus.PENDING, UploadStatus.COMPLETED) + val updated = + photoRepository.updateStatusBatch(saved.associate { it.id to Instant.now() }, UploadStatus.PENDING, UploadStatus.FAILED) + + Then("빈 목록을 반환한다") { + updated.shouldBeEmpty() + } + } + + When("빈 id 목록으로 갱신하면") { + val updated = photoRepository.updateStatusBatch(emptyMap(), UploadStatus.PENDING, UploadStatus.COMPLETED) + + Then("빈 목록을 반환한다") { + updated.shouldBeEmpty() + } + } + } + }) diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/presentation/AnalysisControllerTest.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/presentation/AnalysisControllerTest.kt new file mode 100644 index 0000000..135623f --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/presentation/AnalysisControllerTest.kt @@ -0,0 +1,140 @@ +package com.github.nexters.ppotto.analysis.presentation + +import com.github.nexters.ppotto.analysis.application.AnalysisService +import com.github.nexters.ppotto.analysis.application.PhotoUploadItemRequest +import com.github.nexters.ppotto.analysis.support.AnalysisTestConfig +import com.github.nexters.ppotto.board.infrastructure.BoardRepository +import com.github.nexters.ppotto.support.IntegrationTest +import com.github.nexters.ppotto.user.infrastructure.UserRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.Instant +import java.util.UUID + +@AutoConfigureMockMvc +@Import(AnalysisTestConfig::class) +class AnalysisControllerTest( + @Autowired val mockMvc: MockMvc, + boardRepository: BoardRepository, + userRepository: UserRepository, + analysisService: AnalysisService, +) : IntegrationTest({ + fun createPhotosJson(count: Int): String { + val photos = + (0 until count).joinToString(",") { + """{"takenAt": "2026-07-0${(it % 9) + 1}T00:00:00Z", "contentType": "image/jpeg"}""" + } + return "[$photos]" + } + Given("Board가 등록된 상태에서") { + val board = boardRepository.save(userRepository.save().id) + + When("사진 목록을 담아 분석 생성을 요청하면") { + Then("성공 응답에 analysisId와 사진별 signed URL이 담긴다") { + mockMvc + .perform( + post("/analysis") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "boardId": "${board.id}", + "photos": ${createPhotosJson(90)} + } + """.trimIndent(), + ), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.analysisId").exists()) + .andExpect(jsonPath("$.data.uploads.length()").value(90)) + .andExpect(jsonPath("$.data.uploads[0].photoId").exists()) + .andExpect(jsonPath("$.data.uploads[0].uploadUrl").exists()) + } + } + + When("빈 사진 배열로 요청하면") { + Then("400 응답을 반환한다") { + mockMvc + .perform( + post("/analysis") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"boardId": "${board.id}", "photos": []}"""), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + } + + When("사진이 89장으로(하한 미만) 요청하면") { + Then("400 응답을 반환한다") { + mockMvc + .perform( + post("/analysis") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "boardId": "${board.id}", + "photos": ${createPhotosJson(89)} + } + """.trimIndent(), + ), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + } + + When("업로드 완료를 통보하면") { + val created = + analysisService.createAnalysis( + board.id, + listOf(PhotoUploadItemRequest(Instant.now(), "image/jpeg")), + ) + + Then("성공 응답에 업로드/실패 카운트가 담긴다") { + mockMvc + .perform(post("/analysis/${created.analysisId}/start")) + .andExpect(status().isAccepted) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.uploadedCount").exists()) + .andExpect(jsonPath("$.data.failedCount").exists()) + .andExpect(jsonPath("$.data.failedPhotoIds").exists()) + } + } + } + + Given("존재하지 않는 boardId로") { + When("분석 생성을 요청하면") { + Then("404 응답을 반환한다") { + mockMvc + .perform( + post("/analysis") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "boardId": "${UUID.randomUUID()}", + "photos": ${createPhotosJson(90)} + } + """.trimIndent(), + ), + ).andExpect(status().isNotFound) + } + } + } + + Given("존재하지 않는 analysisId로") { + When("업로드 완료를 통보하면") { + Then("404 응답을 반환한다") { + mockMvc + .perform(post("/analysis/${UUID.randomUUID()}/start")) + .andExpect(status().isNotFound) + } + } + } + }) diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/support/AnalysisTestConfig.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/support/AnalysisTestConfig.kt new file mode 100644 index 0000000..569d1c9 --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/support/AnalysisTestConfig.kt @@ -0,0 +1,13 @@ +package com.github.nexters.ppotto.analysis.support + +import com.github.nexters.ppotto.analysis.domain.PhotoStorage +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary + +@TestConfiguration +class AnalysisTestConfig { + @Bean + @Primary + fun photoStorage(): PhotoStorage = FakePhotoStorage() +} diff --git a/src/test/kotlin/com/github/nexters/ppotto/analysis/support/FakePhotoStorage.kt b/src/test/kotlin/com/github/nexters/ppotto/analysis/support/FakePhotoStorage.kt new file mode 100644 index 0000000..341a553 --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/analysis/support/FakePhotoStorage.kt @@ -0,0 +1,30 @@ +package com.github.nexters.ppotto.analysis.support + +import com.github.nexters.ppotto.analysis.domain.BlobMeta +import com.github.nexters.ppotto.analysis.domain.PhotoStorage +import com.github.nexters.ppotto.analysis.domain.PhotoUploadTarget +import java.time.Instant + +class FakePhotoStorage : PhotoStorage { + private val objects = mutableMapOf() + + override fun issueUploadUrls(targets: List): List = + targets.map { + objects[it.objectKey] = BlobMeta(size = 1, createdAt = Instant.now()) + "https://fake-signed-url/${it.objectKey}" + } + + override fun existingObjects(prefix: String): Map = objects.filterKeys { it.startsWith(prefix) } + + fun markMissing(objectKey: String) { + objects -= objectKey + } + + fun markUploaded( + objectKey: String, + size: Long = 1, + createdAt: Instant = Instant.now(), + ) { + objects[objectKey] = BlobMeta(size, createdAt) + } +} diff --git a/src/test/kotlin/com/github/nexters/ppotto/global/storage/ObjectKeyGeneratorTest.kt b/src/test/kotlin/com/github/nexters/ppotto/global/storage/ObjectKeyGeneratorTest.kt new file mode 100644 index 0000000..bbc1add --- /dev/null +++ b/src/test/kotlin/com/github/nexters/ppotto/global/storage/ObjectKeyGeneratorTest.kt @@ -0,0 +1,27 @@ +package com.github.nexters.ppotto.global.storage + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import java.util.UUID + +class ObjectKeyGeneratorTest : + BehaviorSpec({ + val objectKeyGenerator = ObjectKeyGenerator() + + Given("네임스페이스와 세그먼트가 주어졌을 때") { + val id = UUID.randomUUID() + + When("prefix를 생성하면") { + Then("세그먼트를 슬래시로 이어 붙이고 마지막에 슬래시를 붙인다") { + objectKeyGenerator.prefix("photos", "abc") shouldBe "photos/abc/" + } + } + + When("extension을 지정해 키를 생성하면") { + Then("prefix + id + .extension 형태를 반환한다") { + objectKeyGenerator.generate("photos", "abc", id = id, extension = "jpg") shouldBe + "photos/abc/$id.jpg" + } + } + } + }) diff --git a/src/test/kotlin/com/github/nexters/ppotto/image/infrastructure/ImageRepositoryTest.kt b/src/test/kotlin/com/github/nexters/ppotto/image/infrastructure/ImageRepositoryTest.kt deleted file mode 100644 index 35e6af5..0000000 --- a/src/test/kotlin/com/github/nexters/ppotto/image/infrastructure/ImageRepositoryTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.nexters.ppotto.image.infrastructure - -import com.github.nexters.ppotto.board.infrastructure.BoardRepository -import com.github.nexters.ppotto.image.domain.UploadStatus -import com.github.nexters.ppotto.support.IntegrationTest -import com.github.nexters.ppotto.user.infrastructure.UserRepository -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.shouldBe -import java.util.UUID - -class ImageRepositoryTest( - imageRepository: ImageRepository, - boardRepository: BoardRepository, - userRepository: UserRepository, -) : IntegrationTest({ - Given("Board가 등록된 상태에서 Image를 저장하면") { - val board = boardRepository.save(userRepository.save().id) - val uploadSessionId = UUID.randomUUID() - val saved = imageRepository.save(board.id, uploadSessionId) - - When("저장된 아이디로 조회하면") { - val found = imageRepository.findById(saved.id) - - Then("PENDING 상태의 Image를 반환한다") { - found?.id shouldBe saved.id - found?.boardId shouldBe board.id - found?.uploadSessionId shouldBe uploadSessionId - found?.uploadStatus shouldBe UploadStatus.PENDING - } - } - - When("Board 아이디로 조회하면") { - val found = imageRepository.findByBoardId(board.id) - - Then("해당 Board의 Image 목록을 반환한다") { - found.map { it.id } shouldContainExactly listOf(saved.id) - } - } - } - - Given("존재하지 않는 아이디로") { - When("조회하면") { - val found = imageRepository.findById(UUID.randomUUID()) - - Then("null을 반환한다") { - found.shouldBeNull() - } - } - } - }) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1c95e4a..7ecdb0f 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -7,3 +7,7 @@ POSTGRES_PASSWORD: ppotto CORS_ALLOWED_ORIGINS: http://localhost:3000 SWAGGER_USER: ppotto SWAGGER_PASSWORD: ppotto +GCS_BUCKET: ppotto-test-bucket +GCS_CREDENTIALS_PATH: ./src/test/resources/dummy-gcs-key.json +GCS_UPLOAD_SIGNED_URL_EXPIRATION_MINUTES: 15 +GCS_TIMEOUT_MILLIS: 5000 diff --git a/src/test/resources/dummy-gcs-key.json b/src/test/resources/dummy-gcs-key.json new file mode 100644 index 0000000..2552c08 --- /dev/null +++ b/src/test/resources/dummy-gcs-key.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "ppotto-test-project", + "private_key_id": "dummy-key-id-0000000000000000000000000000000", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmdOxKHD7envXr\npYw1bdUGE2RExCCR/zDl4uH8rP0hu8z8N/XTqS59Ml1evR/n4KhGRLwMq9ymG1iF\nRTnwGT/d9RIfno1DDeb0TaQ5DL5Q+kQV4TXQY+/454/Xa1yk4Tv618mdt4DeLpiJ\n4S2NhkbexlBRrBk9zEsdjeJPpSp0aGtKNmM7XWAuWTFFyFNW1xK+7tTUdho2vhw1\n9oN7y1AAveNBtDMuP+zOltCjQL26swUmztKPkbpT/UATps4DtZmYjQPo94HGWymf\n/DS5rZlT3/SsQq5PXKnLi/Am74IQ7U7dATcOwzXOUlnTQv/wvVBkreH/lvqPNK+b\nRyDcuyVZAgMBAAECggEAGvQX4r8U91KeLpZpJKKY5KIF+yj5KjovjOORTM0qufRO\nED23Sa8i2c+3Lepuvd7/r2BAojbDksXl/4hOM2+wkZQlL3+KxAnvNimSiH2eELiE\nmA6EDcByg6kJoMdUyY/yRO9SDFk3AEVw38dvXXsT9OA66qJ3PUwUiPtcObZ2lbCb\n8CeWFlIYx+T8PodK5og6hG/ILjhIhcMNy3DEBNnViJzwq1g9TsnxcpX7QZvEPWlX\nr+Q/r8/+otPyOCVGv+pmaaF0x4gp3doi5u9kIshNkcAXT7QHiB5FIwBDwAHGgbOi\n+qPpS5gc1yxBHTgH2REf4bKml39QWUW+T/V1/UvUEQKBgQD3ec1jjSQchz+eNDYY\nnVKDvfL1Dz5PdP7ivuSTwXOzEbohkcNVNH26OkJtmqcBapfeexfY2PIohkkzW0nc\nC0is4m/RIEmCWqc4cV0LHIEr1uxn7fWTchzFov94/VLIC4NaQEUxgzM5Acv2ywrt\nZjIFi3JXFHzIwIzfLGWWssO2pQKBgQDuZQy133JwcMSrsxolsH7yLOSbq6+Z7TZw\nUov1nm0KxD+qCBUu1SU3Z4+ssO20ZDnXTtXiGTn9UcZzYmDsUdQU9KP7NYcKrO4h\ncM7R502/abmidZTFObQTirohL4GXAOZ53P84wv2NvTmpJz3Qk2VJWf/qojrUgWbN\nEd+jQR4ppQKBgH6yTQR6bdJtK5TDBi7z3Bq2VHxBoZTsQNG1CTDcCW/T04b8KRSm\nGvgMe5XBZ17CQ54TRtItv0wf87nnMpkE7eAUzUozTW7/Gj07THcz0K2xCrbqjJ8/\n/JryJ/i5OHhR7J136fS/RqoY0WM38BA6EcHk8lTCmF/utDBNenT8cFZBAoGBAOUU\nNSKPlWh29IYJlRaig1ozyXnNgPBpAebta5CarC2sZ4D8Q169cwXKfkVzvveZV/uN\nxfl+RDsoWYqG/pwYBbQdYXYD+cBIbIu12wfZPNyyu69hTZQ33tLoe/Nnsx9nvhFc\nS+Q/e2a4brAdBUloWewij8joG6AYuLBWfJBkApgBAoGAZbZqgimStOgWQ1oUafzp\n9AdRvKmnL9m+j7xTpWOx2b8cnxLikUmgQWl34/2e7408CBUloWw5EWF2Oke+JRoZ\nPLjZD9G8jppKNo1MlzuFFKPwDqiQ3dIcWYn/+GRW+z6rEFY63py9m896pTO8ofLn\n+XVZO6WuyDABHAgTvD62so0=\n-----END PRIVATE KEY-----\n", + "client_email": "ppotto-test@ppotto-test-project.iam.gserviceaccount.com", + "client_id": "000000000000000000000", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/ppotto-test%40ppotto-test-project.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +}