diff --git a/build.gradle.kts b/build.gradle.kts index 848d57b..010ad46 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,8 @@ project(":user-api") { implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.security:spring-security-test") implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("org.springframework.boot:spring-boot-starter-security") runtimeOnly("com.h2database:h2") runtimeOnly("mysql:mysql-connector-java") diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommand.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommand.kt new file mode 100644 index 0000000..11d70d5 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommand.kt @@ -0,0 +1,35 @@ +package com.sns.user.component.authcode.application + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.Purpose +import com.sns.user.component.authcode.repositories.AuthCodeRepository +import com.sns.user.component.user.repositories.DefaultUserRepository +import com.sns.user.core.exceptions.NoAuthorityException +import com.sns.user.core.infrastructures.mail.MailService +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service + +@Service +class AuthCodeCommand( + val authCodeRepository: AuthCodeRepository, + val mailService: MailService, + val userRepository: DefaultUserRepository +) { + fun create(userId: String): AuthCode { + val user = userRepository.findByIdOrNull(userId) ?: throw NoAuthorityException() + + val authCode = AuthCode.createSignUp(user.id) + authCodeRepository.save(authCode) + + mailService.sendSignUpAuthCodeMail(authCode.code, user.infoEmailAddress) + return authCode + } + + fun verify(userId: String, purpose: Purpose, code: String): Boolean { + val authCode: AuthCode? = authCodeRepository.findByUserIdAndPurpose(userId, purpose) + return authCode?.isCorrect(userId, code, purpose) + .takeIf { it == true }.apply { + // TOOD update STATUS userRepository.save() + } ?: false + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/domain/AuthCode.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/domain/AuthCode.kt new file mode 100644 index 0000000..2646ed6 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/domain/AuthCode.kt @@ -0,0 +1,54 @@ +package com.sns.user.component.authcode.domain + +import kotlin.random.Random +import java.sql.ResultSet +import java.time.Instant +import javax.validation.constraints.NotBlank +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.Id +import org.springframework.jdbc.core.RowMapper + +data class AuthCode( + @Id + val id: Int? = null, + // TODO 복합키 구현가능한지 확인. + @NotBlank + val purpose: Purpose, + @NotBlank + val userId: String, + @NotBlank + val code: String = (1..CODE_LENGTH) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString(""), + @CreatedDate + val createdAt: Instant = Instant.MIN +) { + + fun isCorrect(userId: String, code: String, purpose: Purpose): Boolean = + (this.userId == userId) and (this.code == code) and (this.purpose == purpose) + + companion object { + private const val CODE_LENGTH = 10; + private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + fun createSignUp(userId: String) = AuthCode(purpose = Purpose.SIGN_UP, userId = userId) + val MAPPER: RowMapper = AuthCodeRowMapper() + } +} + +// purpose enum 매핑이 안되서 수동으로 작성함. 확인필요. +class AuthCodeRowMapper : RowMapper { + override fun mapRow(rs: ResultSet, rowNum: Int): AuthCode? { + return AuthCode( + id = rs.getInt("id"), + purpose = Purpose.valueOf(rs.getString("purpose")), + userId = rs.getString("user_id"), + code = rs.getString("code"), + createdAt = Instant.ofEpochMilli(rs.getTimestamp("created_at").time), + ) + } +} + +enum class Purpose { + SIGN_UP +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeCrudRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeCrudRepository.kt new file mode 100644 index 0000000..93ccf0f --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeCrudRepository.kt @@ -0,0 +1,9 @@ +package com.sns.user.component.authcode.repositories + +import com.sns.user.component.authcode.domain.AuthCode +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface AuthCodeCrudRepository : CrudRepository { +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepository.kt new file mode 100644 index 0000000..f87fd46 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepository.kt @@ -0,0 +1,11 @@ +package com.sns.user.component.authcode.repositories + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.Purpose +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.NoRepositoryBean + +@NoRepositoryBean +interface AuthCodeRepository : CrudRepository { + fun findByUserIdAndPurpose(userId: String, purpose: Purpose): AuthCode? +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/DefaultAuthCodeRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/DefaultAuthCodeRepository.kt new file mode 100644 index 0000000..b319b3f --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/repositories/DefaultAuthCodeRepository.kt @@ -0,0 +1,25 @@ +package com.sns.user.component.authcode.repositories + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.Purpose +import org.springframework.data.repository.CrudRepository +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository + +@Repository +class DefaultAuthCodeRepository( + val jdbcTemplate: JdbcTemplate, + val authCodeCrudRepository: AuthCodeCrudRepository +) : AuthCodeRepository, CrudRepository by authCodeCrudRepository { + + override fun findByUserIdAndPurpose(userId: String, purpose: Purpose): AuthCode? = jdbcTemplate.queryForObject( + """ + SELECT id,user_id,`code`,created_at,purpose + FROM auth_code + WHERE user_id = ? AND purpose = ? + ORDER BY id DESC + LIMIT 1 + """.trimIndent(), + AuthCode.MAPPER, userId, purpose.name, + ) +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/config/SwaggerTag.kt b/user-api/src/main/kotlin/com/sns/user/core/config/SwaggerTag.kt new file mode 100644 index 0000000..ebbd3bd --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/config/SwaggerTag.kt @@ -0,0 +1,7 @@ +package com.sns.user.core.config + +class SwaggerTag { + companion object { + const val SIGN_UP: String = "SIGN_UP" + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/exceptions/NoAuthorityException.kt b/user-api/src/main/kotlin/com/sns/user/core/exceptions/NoAuthorityException.kt new file mode 100644 index 0000000..d3eab9e --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/exceptions/NoAuthorityException.kt @@ -0,0 +1,4 @@ +package com.sns.user.core.exceptions + +class NoAuthorityException(msg: String? = "권한이 없습니다") : RuntimeException(msg) { +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/infrastructures/mail/MailService.kt b/user-api/src/main/kotlin/com/sns/user/core/infrastructures/mail/MailService.kt new file mode 100644 index 0000000..18f382a --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/core/infrastructures/mail/MailService.kt @@ -0,0 +1,38 @@ +package com.sns.user.core.infrastructures.mail + +import java.nio.charset.StandardCharsets +import java.util.* +import javax.mail.Message +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage +import org.springframework.beans.factory.annotation.Value +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.stereotype.Service +import org.thymeleaf.context.Context +import org.thymeleaf.spring5.ISpringTemplateEngine + +@Service +class MailService( + val javaMailSender: JavaMailSender, + val templateEngine: ISpringTemplateEngine, + @Value("\${spring.mail.username}") val fromId: String +) { + /** + * 가입 인증코드 메일 발송 + * @param authCode 인증 코드 + * @param toAddress 수신인 주소 + */ + fun sendSignUpAuthCodeMail(authCode: String, toAddress: String) { + javaMailSender.send(javaMailSender.createMimeMessage().setBase("가입 인증 코드", createSignUpAuthCodeMailTemplate(authCode), toAddress)) + } + + private fun createSignUpAuthCodeMailTemplate(authCode: String): String = + templateEngine.process("signUpAuthCode", Context(Locale.KOREAN, mapOf("code" to authCode))) + + fun MimeMessage.setBase(title: String, content: String, toAddress: String): MimeMessage { + setRecipient(Message.RecipientType.TO, InternetAddress(toAddress)) + setSubject("[DDD SNS] $title", StandardCharsets.UTF_8.displayName()) + setContent(content, "text/html;charset=euc-kr") + return this + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt index b0c465b..3e47c8c 100644 --- a/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt @@ -1,9 +1,16 @@ package com.sns.user.endpoints.user +import com.sns.user.component.authcode.application.AuthCodeCommand +import com.sns.user.component.authcode.domain.Purpose +import com.sns.user.core.config.SwaggerTag import com.sns.user.endpoints.user.requests.SignUpRequest -import com.sns.user.endpoints.user.responses.IdExistsCheckResponse +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import javax.validation.constraints.Email import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -16,31 +23,43 @@ import org.springframework.web.bind.annotation.RestController @Validated @RestController +@Tag(name = SwaggerTag.SIGN_UP) @RequestMapping("/api") -class SignUpController { +class SignUpController(val authCodeCommand: AuthCodeCommand) { + @ApiResponse(description = "회원 가입", responseCode = "202") @ResponseStatus(HttpStatus.CREATED) @PostMapping("/v1/sign-up") fun signUp(@RequestBody request: SignUpRequest) { // TODO 패스워드 유효성 검증 } + @ApiResponse( + description = "이메일 중복 검사", responseCode = "200", + content = [Content(schema = Schema(implementation = Boolean::class))], + ) @ResponseStatus(HttpStatus.OK) @GetMapping("/v1/sign-up/verifications/emails/{email}") - fun verifyEmail(@Email @PathVariable email: String): IdExistsCheckResponse { + fun verifyEmail(@Email @PathVariable email: String): ResponseEntity { // TODO email 중복 검사 - return IdExistsCheckResponse(false) + return ResponseEntity.ok(false) } + @ApiResponse(description = "가입 인증 코드 재발송", responseCode = "202") @ResponseStatus(HttpStatus.CREATED) - @PutMapping("/v1/sign-up/verifications/ids/{userId}/auth-code") + @PutMapping("/v1/sign-up/verifications/auth-code/ids/{userId}") fun createAuthenticationCode(@PathVariable userId: String) { - // TODO 인증번호 재발송 + + authCodeCommand.create(userId) } + @ApiResponse( + description = "가입 인증 코드 검사", responseCode = "200", + content = [Content(schema = Schema(implementation = Boolean::class))], + ) @ResponseStatus(HttpStatus.OK) - @PostMapping("/v1/sign-up/verifications/ids/{userId}/auth-code") - fun verifyAuthenticationCode(@PathVariable userId: String, @RequestBody code: String) { - // TODO 인증번호 검사 + @PostMapping("/v1/sign-up/verifications/auth-code/ids/{userId}") + fun verifyAuthenticationCode(@PathVariable userId: String, @RequestBody code: String): ResponseEntity { + return ResponseEntity.ok(authCodeCommand.verify(userId, Purpose.SIGN_UP, code)) } } diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/responses/IdExistsCheckResponse.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/responses/IdExistsCheckResponse.kt deleted file mode 100644 index 2966a2c..0000000 --- a/user-api/src/main/kotlin/com/sns/user/endpoints/user/responses/IdExistsCheckResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.sns.user.endpoints.user.responses - -data class IdExistsCheckResponse( - val exist: Boolean -) { -} diff --git a/user-api/src/main/resources/application.yml b/user-api/src/main/resources/application.yml index f7b77b5..bd7af5a 100644 --- a/user-api/src/main/resources/application.yml +++ b/user-api/src/main/resources/application.yml @@ -27,4 +27,15 @@ spring: implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy show-sql: true open-in-view: false + mail: + host: smtp.gmail.com + port: 587 + username: ${GMAIL_ADD} + password: ${GMAIL_PW} + properties: + mail: + smtp: + auth: true + starttls: + enable: true diff --git a/user-api/src/main/resources/templates/signUpAuthCode.html b/user-api/src/main/resources/templates/signUpAuthCode.html new file mode 100644 index 0000000..10b470e --- /dev/null +++ b/user-api/src/main/resources/templates/signUpAuthCode.html @@ -0,0 +1,20 @@ + + + + + + DDD SNS 인증 코드 + + +
+
+

DDD SNS 가입 인증 코드

+

가입페이지에 인증코드를 입력해주세요. 바로 인증 링크는 TODO

+

인증 코드 : + +

+ +
+
+ + diff --git a/user-api/src/test/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandMockTest.kt b/user-api/src/test/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandMockTest.kt new file mode 100644 index 0000000..9af8f3e --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/component/authcode/application/AuthCodeCommandMockTest.kt @@ -0,0 +1,74 @@ +package com.sns.user.component.authcode.application + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.Purpose +import com.sns.user.component.authcode.repositories.AuthCodeRepository +import com.sns.user.component.user.domains.User +import com.sns.user.component.user.repositories.DefaultUserRepository +import com.sns.user.core.infrastructures.mail.MailService +import com.sns.user.isEqualTo +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.data.repository.findByIdOrNull + +class AuthCodeCommandMockTest() { + @MockK + private lateinit var authCodeRepository: AuthCodeRepository + + @MockK + private lateinit var mailService: MailService + + @MockK + private lateinit var userRepository: DefaultUserRepository + + @InjectMockKs + private lateinit var authCodeCommand: AuthCodeCommand + + @BeforeEach + internal fun setUp() { + MockKAnnotations.init(this) + every { mailService.sendSignUpAuthCodeMail(ofType(String::class), ofType(String::class)) } returns Unit + every { authCodeRepository.save(any()) } returnsArgument 0 + every { userRepository.findByIdOrNull(any()) } returns User.create("id", "pass", "name", "mail@gmail.com") + } + + @Test + fun create() { + val authCode = authCodeCommand.create("id") + + verify { authCodeRepository.save(eq(authCode)) } + verify { mailService.sendSignUpAuthCodeMail(any(), any()) } + } + + @DisplayName("userId, purpose에 맞는 authcode 기록이 없다면, 인증 실패해야한다.") + @Test + fun verify_null() { + every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns null + + authCodeCommand.verify("userId", Purpose.SIGN_UP, "123") isEqualTo false + } + + @DisplayName("정상 케이스인 경우, 인증에 성공해야한다.") + @Test + fun verify_success() { + val authCode = AuthCode.createSignUp("userId") + every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns authCode + + authCodeCommand.verify("userId", Purpose.SIGN_UP, authCode.code) isEqualTo true + } + + @DisplayName("인증 코드가 다른 경우, 인증에 실패해야한다.") + @Test + fun verify_different_code() { + val authCode = AuthCode.createSignUp("userId") + every { authCodeRepository.findByUserIdAndPurpose(ofType(String::class), ofType(Purpose::class)) } returns authCode + + authCodeCommand.verify("userId", Purpose.SIGN_UP, "different") isEqualTo false + } +} diff --git a/user-api/src/test/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepositoryTest.kt b/user-api/src/test/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepositoryTest.kt new file mode 100644 index 0000000..c49810c --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/component/authcode/repositories/AuthCodeRepositoryTest.kt @@ -0,0 +1,24 @@ +package com.sns.user.component.authcode.repositories + +import com.sns.user.component.authcode.domain.AuthCode +import com.sns.user.component.authcode.domain.Purpose +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class AuthCodeRepositoryTest { + @Autowired + lateinit var authCodeRepository: AuthCodeRepository + + @Test + internal fun save() { + authCodeRepository.save(AuthCode.createSignUp("userId")) + } + + @Test + internal fun findByUserIdAndPurpose() { + assertThat(authCodeRepository.findByUserIdAndPurpose("userId", Purpose.SIGN_UP)).isNotNull + } +} diff --git a/user-api/src/test/kotlin/com/sns/user/core/infrastructures/mail/MailServiceTest.kt b/user-api/src/test/kotlin/com/sns/user/core/infrastructures/mail/MailServiceTest.kt new file mode 100644 index 0000000..a23b084 --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/core/infrastructures/mail/MailServiceTest.kt @@ -0,0 +1,25 @@ +package com.sns.user.core.infrastructures.mail + +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +// https://myaccount.google.com/lesssecureapps 보안이 낮은 수준의 앱 액세스 허용 필요. +@SpringBootTest( + properties = [ + "spring.mail.username=***@gmail.com", + "spring.mail.password=***", + ], +) +class MailServiceTest @Autowired constructor( + val mailService: MailService +) { + + @Disabled("진짜 메일 발송용") + @Test + internal fun sendSignUpAuthCodeMail() { + mailService.sendSignUpAuthCodeMail("ABC123", "***@gmail.com") + // then checkout your mail + } +} diff --git a/user-api/src/test/resources/application.properties b/user-api/src/test/resources/application.properties deleted file mode 100644 index 305a3f2..0000000 --- a/user-api/src/test/resources/application.properties +++ /dev/null @@ -1,17 +0,0 @@ -spring.profiles.active=test -server.port=10001 -spring.application.name=demo -# datasource -spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=root -spring.datasource.password=test -spring.sql.init.mode=always -spring.sql.init.schema-locations=classpath*:db/*/*schema.sql -spring.sql.init.data-locations=classpath*:db/*/*data.sql -spring.sql.init.continue-on-error=false -logging.level.org.springframework.jdbc.core=debug -# h2 -#spring.h2.console.enabled=true -#spring.h2.console.path=/h2-console - diff --git a/user-api/src/test/resources/application.yml b/user-api/src/test/resources/application.yml new file mode 100644 index 0000000..9b8b3b5 --- /dev/null +++ b/user-api/src/test/resources/application.yml @@ -0,0 +1,42 @@ +server: + port: 10001 +logging: + level: + root: info + pattern: + console: "%d{HH:mm:ss} [%t][%-5level] %msg \\(%F:%L\\)%n" +spring: + profiles: + active: test + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb + username: root + password: test + sql: + init: + mode: always + schema-locations: classpath*:db/*/*schema.sql + data-locations: classpath*:db/*/*data.sql + continue-on-error: false + jpa: + database-platform: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: update + naming: + physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + implicit-strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy + show-sql: true + open-in-view: false + mail: + host: smtp.gmail.com + port: 587 + username: "none" + password: "none" + properties: + mail: + smtp: + auth: true + starttls: + enable: true + diff --git a/user-api/src/test/resources/db/users/data.sql b/user-api/src/test/resources/db/users/data.sql new file mode 100644 index 0000000..0fb684e --- /dev/null +++ b/user-api/src/test/resources/db/users/data.sql @@ -0,0 +1,2 @@ +INSERT INTO auth_code (`user_id`, `code`, created_at, purpose) +VALUES ('userId', 'ABC123', NOW(), 'SIGN_UP'); diff --git a/user-api/src/test/resources/db/users/schema.sql b/user-api/src/test/resources/db/users/schema.sql index 77712f2..c81135d 100644 --- a/user-api/src/test/resources/db/users/schema.sql +++ b/user-api/src/test/resources/db/users/schema.sql @@ -11,9 +11,11 @@ CREATE TABLE IF NOT EXISTS `user` ); -CREATE TABLE IF NOT EXISTS `sign_up_auth_code` +CREATE TABLE IF NOT EXISTS `auth_code` ( - id VARCHAR(50) NOT NULL PRIMARY KEY COMMENT 'user.id', - auth_code VARCHAR(50) NOT NULL COMMENT '인증 코드', + id INT NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT 'user.id', + user_id VARCHAR(50) NOT NULL COMMENT 'user.id', + purpose VARCHAR(50) NOT NULL COMMENT '사용 목적', + code VARCHAR(50) NOT NULL COMMENT '인증 코드', created_at DATETIME NOT NULL COMMENT '생성 시각' );