Skip to content

Commit

Permalink
Merge pull request #27 from SW-rocket-dan/7-템플릿
Browse files Browse the repository at this point in the history
[#7] 사용자가 입력한 프롬프트 데이터를 받아 에디터 JSON 데이터를 생성해 반환한다.
  • Loading branch information
inpink authored Sep 14, 2024
2 parents 7138444 + ad76014 commit 815974d
Show file tree
Hide file tree
Showing 96 changed files with 2,363 additions and 295 deletions.
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,13 @@ deploy/*
legacy/*
.ssh/*
Procfile
deployment-package.zip
deployment-package.zip

### test files
TestController.java
realJson.json
testJson.json
generatedJson.json
generateTemplate.html
generateImage.html
/src/main/resources/private/**
15 changes: 15 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ plugins {
id 'io.spring.dependency-management' version '1.1.5'
}

ext {
springAiVersion = "1.0.0-M1"
}

group = 'app'
version = '0.0.1-SNAPSHOT'

Expand All @@ -21,6 +25,7 @@ configurations {

repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
}

dependencies {
Expand All @@ -32,6 +37,10 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation 'com.auth0:java-jwt:3.18.1'
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
implementation 'org.springframework.ai:spring-ai-stability-ai-spring-boot-starter'
implementation 'io.sentry:sentry-spring-boot-starter-jakarta:7.14.0'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down Expand Up @@ -61,3 +70,9 @@ task makeZipForDeploy(type: Exec) {
zip -r ../deployment-package.zip .
'''
}

dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion"
}
}
7 changes: 5 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,14 @@ grant_type=authorization_code&code=AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&clie
- [X] 유저는 자동 로그인을 할 수 있다.
- [X] refresh token을 발급할 수 있다.
- [X] refresh token을 통해 access token을 재발급 받는다.
- [X] 프론트에서 access token 보낼 때 401 => refresh token 안보냄(사용자가 직접 로그아웃/블랙리스트 등)
- [X] 프론트에서 access token 보낼 때 403 => refresh token이 있으면 보내주세욥(모종의 이유로 토큰이 올바르지 않음. 재인증 필요)
- [X] 프론트에서 access token 보낼 때 403 => refresh token 안보냄(사용자가 직접 로그아웃/블랙리스트 등)
- [X] 프론트에서 access token 보낼 때 401 => refresh token이 있으면 보내주세욥(모종의 이유로 토큰이 올바르지 않음. 재인증 필요)
//TODO: Expired면 refresh 보내라는 에러코드 만들기

- [X] 유저는 로그아웃을 할 수 있다.
- 유저는 회원탈퇴를 할 수 있다.
- (유저는 회원정보를 수정할 수 있다.)

- 블랙리스트를 직접 등록하여 차단할 수 있다.

- [X] 신규 유저에게 5장의 무료 AI 템플릿 생성권을 지급한다
84 changes: 63 additions & 21 deletions docs/README_SINGLE_PAYMENT.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
## 단건 결제

![img.png](SinglePaymentV2.png)
- 포트원의 결제 API를 호출하려면 "포트원 API Secret"을 헤더에 넣거나, "포트원 API Access Token"을 헤더에 넣어야 합니다.
- 해당 API들은 백엔드에서만 호출합니다.
- API Secret은 만료기간이 더 깁니다.
- 어차피 백엔드에서는 secret알고있는 상황이고, 이 access token이 필요한 경우에는, secret을 모르는 사람에게 짧은 시간동안 권한을 빌려주기 위함이다.
- [X] 포트원의 결제 API를 호출하려면 "포트원 API Secret"을 헤더에 넣거나, "포트원 API Access Token"을 헤더에 넣어야 합니다.
- [X] 해당 API들은 백엔드에서만 호출합니다.
- [X] API Secret은 만료기간이 더 깁니다.
- [X] 어차피 백엔드에서는 secret알고있는 상황이고, 이 access token이 필요한 경우에는, secret을 모르는 사람에게 짧은 시간동안 권한을 빌려주기 위함이다.
즉, 우리 입장에서는 필요가 없으므로, "포트원 API Secret"을 사용하도록 합니다.

- ~~포트원 API Access Token 및 Refresh Token 발급받는 방법~~
Expand All @@ -16,16 +16,19 @@ curl --request post \
--data '{"apiSecret":"your-api-secret"}'
~~~

- 백엔드에서 포트원 API 호출 시 RestClient를 사용합니다.
- [X] 백엔드에서 포트원 API 호출 시 RestClient를 사용합니다.

### [1~3]
- 고객이 "구매하기" 버튼을 눌렀을 때, 프론트에서는 백엔드에 다음을 요청합니다.
구매가 가능한 상황을 확인하고 응답을 보내줘야 합니다.
- 구매 가능한지 요청 시 포맷
- [X] 고객이 "구매하기" 버튼을 눌렀을 때, 프론트에서는 백엔드에 다음을 요청합니다.
- 구매 가능한가?
- [X] 고유한 paymentId
- [X] 프론트에서 요청하는 포맷은 다음과 같습니다.
~~~
Header Authorization: Bearer {accessToken}
Body
{
"paymentId": "example-payment-id", // 프론트에서 설정해주는 "결제의 고유 번호"
"products": [
"paymentProducts": [
{
"productId": "example-product-id", // 상품의 고유 번호
"quantity": 1, // 상품의 수량
Expand All @@ -37,25 +40,55 @@ curl --request post \
"price": 2000
}
],
"totalAmount": 3000,
"timestamp": "2024-04-25T10:00:00.000Z",
"totalPrice": 3000,
"requestTime: "2024-04-25T10:00:00.000Z", // TODO: 프론트값 말고 서버값을 쓸 것 (프론트값은 거의 신뢰하지 않아도 됨)
}
~~~

- 구매 불가능한 상황이란?
- 재고가 있는 상품: 재고가 0개일 때
- 모든 상품: 이 상품을 팔면, 프로젝트 전체의 판매액이 762만원을 넘어가는 구매일 때

- ⭐️동시성 처리
- 같은 "구매 요청건"에 대해 똑같은 요청이 동시에 여러 개 도착해도, 한 번만 "구매가능하다"라고 보내줘야 한다.
- a) 모든 유저: "유저는 1초에 구매 요청을 한 번만 할 수 있다" 라는 가정을 둔다. 유저가 1초에 여러 번 구매 요청을 보내면, 첫 번째 요청만 처리하게 된다.
- DB에 구매 요청 왔다는걸 기록하고, 또 다른 요청이 도착했으면 1초 내에 요청했던 사람이면 거절한다.
- [구매 요청 테이블]
- request_id (Primary Key)
- user_id (필요 시 유저 테이블과 연결되지만 FK 제약조건은 없다.)
- request_timestamp (프론트에서 보내주는, 구매 요청이 이루어진 시간)
- 분산 서버 환경에서도 동작해야 한다.
- paymentId로 중복 요청을 구분한다.
-
- (⭐️동시성 처리-1) RDB를 쓰는 경우, SELECT FOR UPDATE로 행 단위 락을 건다.
- 새로운 요청의 타임스탬프가 그 유저의 가장 최신 요청의 타임스탬프보다 더 빠르면, 이 요청은 거절한다.
- 새로운 요청의 타임스탬프가, 그 유저의 가장 최신 요청의 타임스탬프보다 1초 이상 늦게 도착했으면, 이 요청은 허용한다.
- (Select 된 행만 락이 걸릴 것인지 테스트할 것)
- 추후에 Redis로 이 요청을 기록한다.
- 이유는 다음과 같다. 1초에 한 번씩이라는 기준이면 DB I/O가 너무 많아질 것
- 그리고 RDB는 expired를 처리해주는 로직이 없다. 직접 개발자가 배치같은걸 돌려서 해줘야함. (Redis에서는 expired를 설정해줄 수 있다.)

- a-1) 최근(n초) 이내에 n회를 초과하는 구매 요청을 보냈으면 거절한다. (임시 장바구니에 너무 많은 물량을 가지고 있는 것을 방지하기 위함)


- b) 재고가 있는 상품: 재고가 0개일 때
- 추후 구독제 구현할 때 작성

- [X] c) 모든 상품: 이 상품을 팔면, 프로젝트 전체의 판매액이 762만원을 넘어가는 구매일 때 거절한다
- [X] [총 판매액 테이블]
- [X] id (Primary Key)
- [X] total_sales (총 판매액)
- [X] SELECT FOR UPDATE로 행 단위 쓰기&읽기 락을 건다.


- [X] d) 프론트 정합성 오류
- [X] 없는 상품 id면 거절(400)
- [X] 가격이 0원 이하면 거절(400)
- [X] 수량이 0개 이하면 거절(400)
- [X] 요청 상품 id의 가격이 백엔드에 저장되어있는 가격과 다르면 거절(400)
- [X] totalPrice이 products의 가격 합계와 다르면 거절(400)

- [X] ⭐️동시성 처리-1
- [X] SELECT FOR UPDATE로 행 단위 잠금을 하지 않았을 때 생길 수 있는 문제
- [X] 서버A와 서버B가 거의 동시에 삽입 가능하다는 확인을 했을 때, 서버A가 삽입을 하고, 서버B가 삽입을 하면, 둘 다 삽입이 되어버린다. 요구사항에 어긋나버린다.


### [11]
- 프론트에서 포트원과 결제를 진행하면, 포트원은 결제 결과를 백엔드에 "웹훅"으로 알려줍니다
- "결제 결과"에 대해 웹훅을 받을 엔드포인트가 필요합니다.
- [X] 프론트에서 포트원과 결제를 진행하면, 포트원은 결제 결과를 백엔드에 "웹훅"으로 알려줍니다
- [X] "결제 결과"에 대해 웹훅을 받을 엔드포인트가 필요합니다.
- "결제 결과"를 검증하고, DB에 저장합니다.
- 웹훅은 신뢰할 수 없기 때문에, 아래와 같은 방법으로 "결제 결과"를 검증합니다. (출처: 포트원)
~~~
Expand Down Expand Up @@ -90,3 +123,12 @@ https://api.portone.io/payments?requestBody=인코딩된_요청_바디
예) 인코딩_전_요청_바디={"page":{"number":2,"size":5},"filter":{"isTest":false,"from":"2024-07-25T00:00:00Z","until":"2024-07-25T23:59:59Z"}}
~~~

[단품]
- [X] 구매한 이용권 개수만큼 유저의 이용권 개수가 늘어난다.
- [X] 이용권을 사용하면 차감된다.
- [X] 사용했다는 기준은 /api/v1/template/create로 템플릿을 생성했을 때이다.
- [X] 이미 이용권 반영 처리된 payment라면 또 이용권 추가시키면 안됨
- [X] 동시성 처리. 이 checkPaymentStatus가 분산 환경에서 여러 번 호출될 수 있으니까, 해당 Payment record status에 select for update 락 걸어야함
- [X] 이미 있는 값이면 새롭게 record를 넣는게 아니라 업데이트해줘야 한다
- [X] ProductCategory에 식별 코드 추가
- [X] 구매하지 않은 ProductCategory에 대해서도 0이라는 default 값을 담아 모두 반환
9 changes: 7 additions & 2 deletions docs/README_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

- [X] 사용자가 입력한 프롬프트 데이터를 요청 받을 수 있다.
- 프롬프트 데이터를 기반으로 AI를 이용해 에디터 JSON 데이터를 생성한다.
- [X] Spring AI를 이용한다.
- [X] 프롬프트 데이터를 기반으로 배경 이미지 1개를 생성한다.
- [X] 에디터 JSON 데이터를 응답한다.
- 프론트에서 템플릿을 업로드할 수 있도록 PresignUrl을 제공한다.
- JSON 데이터와, 이미지 Url을 같이 저장한다
- [X] 프론트에서 템플릿을 업로드할 수 있도록 PresignUrl을 제공한다.
- [X] JSON 데이터와, 이미지 Url을 같이 저장한다

- [X] 사용자는 템플릿을 조회할 수 있다.
- [X] JSON 데이터, 이미지 Url 등 포함
- [X] 사용자는 템플릿을 수정할 수 있다.
- [X] template의 id를 이용해서, 수정할 수 있다.
- [X] Optional한 요소들 때문에, 변경된 요소를 같이 알려줘야 합니다. 필수: templateId,바뀐요소들 / 선택: 나머지 모두 다.

32 changes: 32 additions & 0 deletions src/main/java/app/cardcapture/ai/common/AiImage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package app.cardcapture.ai.common;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@Entity
@Table(name = "ai_images")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class AiImage {

@Id
@Column(name = "ai_image_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, columnDefinition = "TEXT")
private String prompt;
}
13 changes: 13 additions & 0 deletions src/main/java/app/cardcapture/ai/common/AiModel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package app.cardcapture.ai.common;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Getter
public enum AiModel {
DALL_E_3("dall-e-3"),
STABLE_DIFFUSION("stable-diffusion");

private final String apiName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.cardcapture.ai.common.repository;

import app.cardcapture.ai.common.AiImage;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AiImageRepository extends JpaRepository<AiImage, Long> {

}
Loading

0 comments on commit 815974d

Please sign in to comment.