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"
+}