Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 1 addition & 2 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ insert_final_newline = true
trim_trailing_whitespace = true

[*.java]
indent_style = space
indent_size = 4
indent_style = tab

[*.gradle]
indent_style = tab
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-run-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ jobs:
uses: docker/setup-buildx-action@v2

- name: Run checks
run: ./gradlew spotlessCheck test
run: ./gradlew checkFormat test
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
- README는 외부 방문자용으로 유지하고, 긴 설계 설명은 `docs` 아래에 둔다.
- 문서화되지 않은 모듈은 `src/main/java/com/linglevel/api` 아래 실제 패키지와 테스트를 기준으로 맥락을 확인한다.
- 커밋 메시지, 브랜치명, PR 제목과 본문은 [Repository Conventions](docs/templates/repository-conventions.md)를 따른다.
- Java 포맷은 Spotless와 google-java-format AOSP 스타일을 기준으로 하며, 코드 변경 후 필요하면 `./gradlew spotlessCheck`를 실행한다.
- Java 포맷은 Spring Java Format을 기준으로 하며, 코드 변경 후 필요하면 `./gradlew checkFormat`을 실행한다.
- 구조 변경은 관련 architecture 문서와 decision 문서의 갱신 필요성을 함께 확인한다.
- 운영 리스크가 있는 변경은 테스트, 로그, 메트릭, 부하 테스트 중 최소 하나로 검증 근거를 남긴다.
- 외부 네트워크, AI 모델, 저장소, 푸시 알림에 의존하는 코드는 실패와 비용을 별도 리스크로 다룬다.
15 changes: 1 addition & 14 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
id "io.sentry.jvm.gradle" version "5.10.0"
id 'com.diffplug.spotless' version '8.7.0'
id 'io.spring.javaformat' version '0.0.47'
}

springBoot {
Expand All @@ -29,15 +29,6 @@ repositories {
mavenCentral()
}

spotless {
ratchetFrom 'origin/develop'

java {
googleJavaFormat('1.28.0').aosp()
formatAnnotations()
}
}

dependencies {
implementation platform('org.springframework.ai:spring-ai-bom:1.0.0-M6')
implementation 'org.springframework.boot:spring-boot-starter-web'
Expand Down Expand Up @@ -82,7 +73,3 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}

tasks.named('check') {
dependsOn 'spotlessCheck'
}
14 changes: 7 additions & 7 deletions docs/templates/repository-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,19 @@ PR 본문은 [pull_request_template.md](../../.github/pull_request_template.md)

## 코드 스타일

Java 포맷은 Spotless와 google-java-format AOSP 스타일을 기준으로 한다.
AOSP 스타일을 사용해 기존 Java/Spring 코드의 4-space indentation을 유지한다.
Java 포맷은 Spring Java Format을 기준으로 한다.
Spring Java Format은 Spring 프로젝트 스타일에 맞춘 formatter이며 Java indentation은 tab을 사용한다.

명령:

```bash
./gradlew spotlessCheck
./gradlew spotlessApply
./gradlew checkFormat
./gradlew format
```

작성 원칙:

- 포맷 검사는 `spotlessCheck`로 수행한다.
- 자동 포맷 적용은 `spotlessApply`로 수행한다.
- 포맷 검사는 `checkFormat`으로 수행한다.
- 자동 포맷 적용은 `format`으로 수행한다.
- 포맷 변경은 기능 변경과 분리한다.
- 기존 전체 Java 파일 재포맷은 별도 PR로 분리한다.
- 대규모 포맷 변경은 별도 PR로 분리한다.
7 changes: 7 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}

rootProject.name = 'api'
403 changes: 207 additions & 196 deletions src/main/java/com/linglevel/api/admin/controller/AdminController.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,39 +23,30 @@
@SecurityRequirement(name = "adminApiKey")
public class PushCampaignController {

private final PushCampaignService pushCampaignService;
private final PushCampaignService pushCampaignService;

@GetMapping
@Operation(
summary = "캠페인 그룹 목록 조회",
description = "푸시 캠페인 그룹 목록을 조회합니다. 기간 필터링이 가능합니다."
)
public ResponseEntity<List<PushCampaignSummary>> getCampaigns(
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@GetMapping
@Operation(summary = "캠페인 그룹 목록 조회", description = "푸시 캠페인 그룹 목록을 조회합니다. 기간 필터링이 가능합니다.")
public ResponseEntity<List<PushCampaignSummary>> getCampaigns(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,

@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate) {
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) {

log.debug("Get campaign groups request - startDate: {}, endDate: {}", startDate, endDate);
log.debug("Get campaign groups request - startDate: {}, endDate: {}", startDate, endDate);

List<PushCampaignSummary> summaries = pushCampaignService.getCampaignSummaries(startDate, endDate);
List<PushCampaignSummary> summaries = pushCampaignService.getCampaignSummaries(startDate, endDate);

return ResponseEntity.ok(summaries);
}
return ResponseEntity.ok(summaries);
}

@GetMapping("/{campaignGroup}/stats")
@Operation(
summary = "캠페인 그룹 상세 통계 조회",
description = "특정 캠페인 그룹의 상세 통계를 조회합니다."
)
public ResponseEntity<PushCampaignStats> getCampaignStats(@PathVariable String campaignGroup) {
log.debug("Get campaign group stats request - campaignGroup: {}", campaignGroup);
@GetMapping("/{campaignGroup}/stats")
@Operation(summary = "캠페인 그룹 상세 통계 조회", description = "특정 캠페인 그룹의 상세 통계를 조회합니다.")
public ResponseEntity<PushCampaignStats> getCampaignStats(@PathVariable String campaignGroup) {
log.debug("Get campaign group stats request - campaignGroup: {}", campaignGroup);

PushCampaignStats stats = pushCampaignService.getStats(campaignGroup);
PushCampaignStats stats = pushCampaignService.getStats(campaignGroup);

return ResponseEntity.ok(stats);
}

return ResponseEntity.ok(stats);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,59 +33,51 @@
@SecurityRequirement(name = "adminApiKey")
public class AdminCrawlingController {

private final CrawlingService crawlingService;
private final CrawlingService crawlingService;

@Operation(summary = "어드민 - DSL 생성", description = "어드민 권한으로 새로운 도메인의 제목/본문 추출 DSL을 추가합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 필드 누락)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "409", description = "도메인이 이미 존재함",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
})
@PostMapping("/crawling-dsl")
public ResponseEntity<CreateDslResponse> createDsl(
@Valid @RequestBody CreateDslRequest request) {
CreateDslResponse response = crawlingService.createDsl(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Operation(summary = "어드민 - DSL 생성", description = "어드민 권한으로 새로운 도메인의 제목/본문 추출 DSL을 추가합니다.")
@ApiResponses(value = { @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 필드 누락)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "409", description = "도메인이 이미 존재함",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) })
@PostMapping("/crawling-dsl")
public ResponseEntity<CreateDslResponse> createDsl(@Valid @RequestBody CreateDslRequest request) {
CreateDslResponse response = crawlingService.createDsl(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@Operation(summary = "어드민 - DSL 업데이트", description = "어드민 권한으로 특정 도메인의 제목/본문 추출 DSL을 업데이트합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (제목/본문 DSL 필드 누락)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
})
@PutMapping("/crawling-dsl/{domain}")
public ResponseEntity<UpdateDslResponse> updateDsl(
@Parameter(description = "업데이트할 도메인명", example = "coupang.com")
@PathVariable String domain,
@Valid @RequestBody UpdateDslRequest request) {
UpdateDslResponse response = crawlingService.updateDsl(domain, request);
return ResponseEntity.ok(response);
}
@Operation(summary = "어드민 - DSL 업데이트", description = "어드민 권한으로 특정 도메인의 제목/본문 추출 DSL을 업데이트합니다.")
@ApiResponses(value = { @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (제목/본문 DSL 필드 누락)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) })
@PutMapping("/crawling-dsl/{domain}")
public ResponseEntity<UpdateDslResponse> updateDsl(
@Parameter(description = "업데이트할 도메인명", example = "coupang.com") @PathVariable String domain,
@Valid @RequestBody UpdateDslRequest request) {
UpdateDslResponse response = crawlingService.updateDsl(domain, request);
return ResponseEntity.ok(response);
}

@Operation(summary = "어드민 - DSL 삭제", description = "어드민 권한으로 특정 도메인과 관련된 제목/본문 추출 DSL을 삭제합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "삭제 성공",
content = @Content(schema = @Schema(implementation = MessageResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
})
@DeleteMapping("/crawling-dsl/{domain}")
public ResponseEntity<MessageResponse> deleteDsl(
@Parameter(description = "삭제할 도메인명", example = "coupang.com")
@PathVariable String domain) {
crawlingService.deleteDsl(domain);
return ResponseEntity.ok(new MessageResponse("DSL deleted successfully."));
}
@Operation(summary = "어드민 - DSL 삭제", description = "어드민 권한으로 특정 도메인과 관련된 제목/본문 추출 DSL을 삭제합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "삭제 성공",
content = @Content(schema = @Schema(implementation = MessageResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
@ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) })
@DeleteMapping("/crawling-dsl/{domain}")
public ResponseEntity<MessageResponse> deleteDsl(
@Parameter(description = "삭제할 도메인명", example = "coupang.com") @PathVariable String domain) {
crawlingService.deleteDsl(domain);
return ResponseEntity.ok(new MessageResponse("DSL deleted successfully."));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,35 @@
@Schema(description = "아티클 출시 알림 전송 요청")
public class ArticleReleaseNotificationRequest {

@Schema(description = "출시된 아티클 목록", required = true)
@NotEmpty(message = "Articles are required")
@Valid
private List<ArticleInfo> articles;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "아티클 정보")
public static class ArticleInfo {

@Schema(description = "아티클 ID", example = "article-123", required = true)
@NotBlank(message = "Article ID is required")
private String articleId;

@Schema(description = "타겟 언어 코드 목록 (null이면 모든 언어)", example = "[\"KO\", \"EN\"]")
private List<LanguageCode> targetLanguageCodes;

@Schema(description = "타겟 카테고리 (displayName 또는 enum 이름)", example = "Technology", required = true)
@NotNull(message = "Target category is required")
private String targetCategory;

/**
* targetCategory String을 ContentCategory enum으로 변환
*/
public ContentCategory getTargetCategoryEnum() {
return ContentCategory.fromString(targetCategory);
}
}
@Schema(description = "출시된 아티클 목록", required = true)
@NotEmpty(message = "Articles are required")
@Valid
private List<ArticleInfo> articles;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "아티클 정보")
public static class ArticleInfo {

@Schema(description = "아티클 ID", example = "article-123", required = true)
@NotBlank(message = "Article ID is required")
private String articleId;

@Schema(description = "타겟 언어 코드 목록 (null이면 모든 언어)", example = "[\"KO\", \"EN\"]")
private List<LanguageCode> targetLanguageCodes;

@Schema(description = "타겟 카테고리 (displayName 또는 enum 이름)", example = "Technology", required = true)
@NotNull(message = "Target category is required")
private String targetCategory;

/**
* targetCategory String을 ContentCategory enum으로 변환
*/
public ContentCategory getTargetCategoryEnum() {
return ContentCategory.fromString(targetCategory);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,27 @@
@Schema(description = "아티클 출시 알림 전송 응답")
public class ArticleReleaseNotificationResponse {

@Schema(description = "총 성공적으로 전송된 알림 수", example = "1250")
private int totalSentCount;
@Schema(description = "총 성공적으로 전송된 알림 수", example = "1250")
private int totalSentCount;

@Schema(description = "아티클별 전송 결과")
private List<ArticleResult> results;
@Schema(description = "아티클별 전송 결과")
private List<ArticleResult> results;

@Data
@Builder
@AllArgsConstructor
@Schema(description = "아티클별 전송 결과")
public static class ArticleResult {
@Data
@Builder
@AllArgsConstructor
@Schema(description = "아티클별 전송 결과")
public static class ArticleResult {

@Schema(description = "아티클 ID", example = "article-123")
private String articleId;
@Schema(description = "아티클 ID", example = "article-123")
private String articleId;

@Schema(description = "해당 아티클로 전송된 알림 수", example = "850")
private int sentCount;
@Schema(description = "해당 아티클로 전송된 알림 수", example = "850")
private int sentCount;

@Schema(description = "타겟 사용자 수", example = "900")
private int targetUserCount;

}

@Schema(description = "타겟 사용자 수", example = "900")
private int targetUserCount;
}
}
23 changes: 12 additions & 11 deletions src/main/java/com/linglevel/api/admin/dto/GrantTicketRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
@AllArgsConstructor
@Schema(description = "어드민 티켓 지급 요청")
public class GrantTicketRequest {

@Schema(description = "티켓을 지급받을 사용자 ID", example = "60d0fe4f5311236168a109ca", required = true)
@NotBlank(message = "사용자 ID는 필수입니다.")
private String userId;

@Schema(description = "지급할 티켓 수", example = "5", required = true)
@NotNull(message = "지급할 티켓 수는 필수입니다.")
private Integer amount;

@Schema(description = "지급 사유", example = "구독 갱신 보상")
private String reason;

@Schema(description = "티켓을 지급받을 사용자 ID", example = "60d0fe4f5311236168a109ca", required = true)
@NotBlank(message = "사용자 ID는 필수입니다.")
private String userId;

@Schema(description = "지급할 티켓 수", example = "5", required = true)
@NotNull(message = "지급할 티켓 수는 필수입니다.")
private Integer amount;

@Schema(description = "지급 사유", example = "구독 갱신 보상")
private String reason;

}
Loading
Loading