Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f43eb33
feat(media): 콘텐츠 이미지 binding과 projection 추가 (#190)
hwistlezz Aug 27, 2026
f2a82eb
feat(restaurant): 이미지 asset 저장 스키마 추가 (#190)
hwistlezz Aug 27, 2026
a45c7d7
feat(restaurant): 이미지 asset 연결 쓰기 경로 추가 (#190)
hwistlezz Aug 27, 2026
ae8b406
feat(restaurant): 최적화 이미지 응답 연동 (#190)
hwistlezz Aug 27, 2026
5e4e0a0
feat(reservation): 식당 썸네일 media 응답 연동 (#190)
hwistlezz Aug 27, 2026
8ae9e79
feat(review): 식당 썸네일 media 응답 연동 (#190)
hwistlezz Aug 27, 2026
c77792e
fix(restaurant): 관리자 이미지 계약과 잠금 보완 (#190)
hwistlezz Aug 30, 2026
a8f1ff7
test(media): 이미지 binding 동시성 검증 (#190)
hwistlezz Aug 30, 2026
4827079
fix(admin): 식당 이미지 오류 계약 노출 (#190)
hwistlezz Aug 30, 2026
39b6e3a
fix(admin): media 오류 문서의 모듈 경계 유지 (#190)
hwistlezz Aug 30, 2026
3a4bf06
fix(admin): 이미지 입력 계약과 media 오류 일치 (#190)
hwistlezz Aug 30, 2026
37c7e13
test(media): 실제 이미지 binding 잠금 경합 검증 (#190)
hwistlezz Aug 30, 2026
364eacb
chore(media): 상위 이미지 상태 연동 변경 반영 (#190)
hwistlezz Sep 3, 2026
fb74e9a
test(media): 이미지 규격 fixture 최신화 (#190)
hwistlezz Sep 3, 2026
8de6b5f
fix(media): 실패 이미지 상태 표시 유지 (#190)
hwistlezz Sep 3, 2026
8062e76
fix(restaurant): 이미지 연결 migration 복구 경계 분리 (#190)
hwistlezz Sep 3, 2026
49a417d
test(media): 정리 상태별 이미지 노출 계약 검증 (#190)
hwistlezz Sep 3, 2026
e1285f1
test(media): 정리 중 source 노출 회귀 검증 (#190)
hwistlezz Sep 3, 2026
e7a3cff
ci(media): 기능 브랜치 대상 PR 검증 추가 (#190)
hwistlezz Sep 4, 2026
8d18854
fix(media): 이미지 일괄 연결 소유권 우선 검증 (#190)
hwistlezz Sep 4, 2026
1ae42f3
fix(media): 이미지 일괄 작업 잠금 순서 통일 (#190)
hwistlezz Sep 4, 2026
084e1a8
chore(media): 상위 테스트 시각 보완 반영 (#190)
hwistlezz Sep 4, 2026
79b314b
chore(media): 최신 develop 변경 반영 (#190)
hwistlezz Sep 17, 2026
d6f5e50
refactor(media): 이미지 URL 선택 규칙 공통화 (#190)
hwistlezz Sep 17, 2026
dd53a3a
fix(restaurant): 이미지 연결을 비우는 옛 수정 메서드 제거 (#190)
hwistlezz Sep 17, 2026
06f821d
fix(restaurant): 이미지 항목에 기존 주소 함께 제공 (#190)
hwistlezz Sep 17, 2026
d51b829
refactor(reservation): 공통 이미지 선택 규칙 적용 (#190)
hwistlezz Sep 17, 2026
e0f82be
refactor(review): 공통 이미지 선택 규칙 적용 (#190)
hwistlezz Sep 17, 2026
17f5169
refactor(admin): 공통 이미지 선택 규칙 적용 (#190)
hwistlezz Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@

on:
pull_request:
branches:
- develop
- main

permissions:
contents: read
Expand Down Expand Up @@ -64,6 +61,7 @@ jobs:
name: Build and Test
runs-on: ubuntu-latest
needs: validate-convention
timeout-minutes: 30

steps:
- name: Checkout source code
Expand Down Expand Up @@ -129,5 +127,32 @@ jobs:
- name: Grant execute permission for Gradle
run: chmod +x ./gradlew

- name: Require Docker for MySQL tests
run: docker info

- name: Build with Gradle
run: ./gradlew clean build --no-daemon

- name: Verify test execution
run: |
python3 - <<'PY'
from pathlib import Path
import xml.etree.ElementTree as ET

report_root = Path("build/test-results/test")
reports = sorted(report_root.glob("TEST-*.xml"))
assert reports, "Test reports are missing"
assert (report_root / "TEST-org.sopt.hashi.ModularityTests.xml").is_file(), \
"Module verification did not produce a test report"

total = 0
for report in reports:
suite = ET.parse(report).getroot()
tests = int(suite.attrib["tests"])
assert tests > 0, f"{report.name}: no tests executed"
for outcome in ("failures", "errors", "skipped"):
assert int(suite.attrib[outcome]) == 0, f"{report.name}: {outcome} must be zero"
total += tests

print(f"Test suites: {len(reports)}, tests: {total}, failures/errors/skips: 0")
PY
3 changes: 3 additions & 0 deletions docs/conventions/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@
- **MUST**: 신규 최적화 이미지는 상태를 가진 지원 도메인 `media`가 관리한다. `ImageAsset`은 Aggregate Root, `ImageRendition`은 그 자식이다.
- **MUST**: 콘텐츠 도메인은 asset 식별자 값만 보관하고 일반 요청 경로에서는 공개 `MediaPort`로 검증, claim, bulk 조회한다. §2-2 통합 Port 원칙의 명시적 migration-only 예외로 association을 소유한 도메인의 backfill runner만 `MediaBackfillPort`를 사용할 수 있으며 Controller와 일반 Service에서는 사용하지 않는다. media 테이블과 JPA 관계, 모듈 간 FK, DB join을 만들지 않는다.
- **MUST**: 이미지의 콘텐츠 소속과 표시 순서는 기존 콘텐츠 Aggregate가 계속 소유한다. media는 콘텐츠 도메인을 되참조하지 않는다.
- **MAY**: 최종 응답 Service는 공개 값 타입 `MediaImageSelection.from(ImageReference, MediaImage)`으로
기존 URL 허용 여부와 READY URL 선택을 공유한다. 이 타입은 DB·외부 호출이 없는 순수 변환이며
추가 Service facade가 아니다. role·정렬·개수와 `MediaPort` bulk 조회는 호출 Service가 소유한다.
- **MUST**: 원본 확인, 변환과 삭제 같은 S3 작업은 DB 트랜잭션 안에서 실행하지 않는다. 외부 변환 요청은 commit 이후 재시도 가능한 event publication 또는 outbox로 전달한다.
- 상세 결정과 외부 계약은 [`ADR 0001`](../adr/0001-media-module-and-image-pipeline.md)과 [`Image Delivery Contract v1`](../media/image-delivery-contract-v1.md)을 따른다.

Expand Down
21 changes: 20 additions & 1 deletion docs/media/image-delivery-contract-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,25 @@ wrapper 안에 둔다. 식당 이미지는 다음 형태를 사용한다.
식당 association wrapper다. 리뷰의 `previewImages`와 상세 `images` 원소는 stable
`reviewImageId`를 가진 리뷰 association wrapper다.

식당 wrapper는 `restaurantImageId`, `displayOrder`, `image`, `legacyUrl`을 제공한다.
`legacyUrl`은 asset ID가 없는 기존 사진에만 제공한다. asset이 있으면 READY 여부나
조회 결과 누락과 관계없이 `legacyUrl`은 `null`이다. 신규 원본 주소는 제공하지 않는다.

```json
[
{"restaurantImageId": 101, "displayOrder": 1, "image": null, "legacyUrl": "https://cdn.example.com/legacy/a.jpg"},
{"restaurantImageId": 102, "displayOrder": 2, "image": {"assetId": "550e8400-e29b-41d4-a716-446655440000", "role": "RESTAURANT_HERO", "status": "PROCESSING", "defaultSource": null, "sourceSets": []}, "legacyUrl": null}
]
```

- 새 클라이언트는 wrapper 목록의 순서를 그대로 사용하며, 다른 응답과 사진을 연결할 때는
`restaurantImageId`를 사용한다. `imageUrls`의 같은 인덱스와 맞추지 않는다.
- `image.status=READY`이면 새 이미지, `image=null`이고 `legacyUrl`이 있으면 기존 주소를 사용한다.
PROCESSING은 placeholder, FAILED 또는 둘 다 없으면 DefaultImage를 표시한다.
- 기존 `imageUrls`는 표시 가능한 기존 주소와 READY 주소만 담으므로 새 목록과 길이가 다를 수 있다.
- 데이터 전환이 완료돼도 필드는 즉시 제거하지 않는다. 클라이언트의 기존 주소 사용 종료까지
확인한 뒤 별도 호환성 변경으로 제거한다. 원본 파일 보관·접근 차단과는 별개다.

교차 모듈 Port의 전환기 이미지 값은 `ImageReference(assetId, legacyUrl)` 형태로 전달한다.
기존 key를 소유한 모듈이 현재 방식으로 계산한 `legacyUrl`을 제공하며, object key 자체는 다른
모듈에 공개하지 않는다.
Expand All @@ -732,7 +751,7 @@ wrapper 안에 둔다. 식당 이미지는 다음 형태를 사용한다.

| 저장 상태 | 기존 URL 필드 | 신규 이미지 필드 |
| --- | --- | --- |
| legacy key만 있음 | 기존 CloudFront 원본 URL | 단일 이미지는 `null`, association collection은 stable ID와 순서를 가진 wrapper의 `image: null` |
| legacy key만 있음 | 기존 CloudFront 원본 URL | 단일 이미지는 `null`, 식당 wrapper는 ID·순서와 `image: null`, `legacyUrl` 제공 |
| media asset READY | 해당 endpoint role의 `defaultSource.url` | READY 이미지 객체 |
| 신규 media asset PROCESSING 또는 FAILED | scalar는 `null`, 배열은 READY 항목만 오름차순으로 포함 | 슬롯과 상태를 유지한 이미지 객체 |
| 이미지가 없음 | `null` 또는 빈 배열 | `null` 또는 빈 배열 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ public record AdminReservationListResponse(
long totalCount,
int totalPages) {

public static AdminReservationListResponse from(Page<AdminReservationInfo> page) {
List<AdminReservationResponse> reservations = page.getContent().stream()
.map(AdminReservationResponse::from)
.toList();
public static AdminReservationListResponse from(
Page<AdminReservationInfo> page,
List<AdminReservationResponse> reservations
) {
return new AdminReservationListResponse(
reservations,
List.copyOf(reservations),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.sopt.hashi.media.MediaImage;
import org.sopt.hashi.reservation.AdminReservationInfo;
import org.sopt.hashi.reservation.PaymentStatus;
import org.sopt.hashi.reservation.ReservationStatus;
Expand All @@ -19,6 +20,7 @@ public record AdminReservationResponse(
Long restaurantId,
String restaurantName,
String restaurantImageUrl,
MediaImage restaurantThumbnailImage,
String restaurantAddress,
LocalDateTime reservedAt,
int adultCount,
Expand All @@ -31,15 +33,20 @@ public record AdminReservationResponse(
BigDecimal amount,
Long confirmDDay) {

public static AdminReservationResponse from(AdminReservationInfo info) {
public static AdminReservationResponse from(
AdminReservationInfo info,
String restaurantImageUrl,
MediaImage restaurantThumbnailImage
) {
return new AdminReservationResponse(
info.reservationId(),
info.userId(),
info.reservationType(),
info.reserverName(),
info.restaurantId(),
info.restaurantName(),
info.restaurantImageUrl(),
restaurantImageUrl,
restaurantThumbnailImage,
info.restaurantAddress(),
info.reservedAt(),
info.adultCount(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import org.sopt.hashi.media.MediaImage;
import org.sopt.hashi.restaurant.AdminRestaurantInfo;
import org.sopt.hashi.restaurant.RestaurantImageInfo;

/**
* 어드민 식당 단건 응답(등록·수정 결과). thumbnailUrl·imageUrls·메뉴 imageUrl은 저장된 키를
* 변환한 조회 URL이다. genre·curationTypes는 사용자 API와 같은 소문자 케밥 값이다.
* 어드민 식당 단건 응답(등록·수정 결과). 기존 URL 필드는 legacy 또는 READY media 호환용이며,
* 신규 이미지 필드는 상태와 반응형 후보를 전달한다. genre·curationTypes는 사용자 API와 같은
* 소문자 케밥 값이다.
*/
public record AdminRestaurantResponse(
Long restaurantId,
Expand All @@ -20,11 +23,13 @@ public record AdminRestaurantResponse(
String genre,
String foodCategory,
String thumbnailUrl,
RestaurantImageInfo thumbnailImage,
String priceCurrency,
BigDecimal minPrice,
BigDecimal maxPrice,
boolean deleted,
List<String> imageUrls,
List<RestaurantImageInfo> heroImages,
List<AdminRestaurantMenuResponse> menus,
List<String> hashtags,
List<String> curationTypes,
Expand All @@ -43,11 +48,13 @@ public static AdminRestaurantResponse from(AdminRestaurantInfo info) {
info.genre(),
info.foodCategory(),
info.thumbnailUrl(),
info.thumbnailImage(),
info.priceCurrency(),
info.minPrice(),
info.maxPrice(),
info.deleted(),
info.imageUrls(),
info.heroImages(),
info.menus().stream()
.map(AdminRestaurantMenuResponse::from)
.toList(),
Expand All @@ -64,6 +71,7 @@ public record AdminRestaurantMenuResponse(
String name,
String description,
String imageUrl,
MediaImage listImage,
String priceCurrency,
BigDecimal priceAmount,
boolean main) {
Expand All @@ -74,6 +82,7 @@ static AdminRestaurantMenuResponse from(AdminRestaurantInfo.AdminRestaurantMenuI
info.name(),
info.description(),
info.imageUrl(),
info.listImage(),
info.priceCurrency(),
info.priceAmount(),
info.main());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package org.sopt.hashi.admin.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
Expand All @@ -11,6 +15,7 @@
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.util.List;
import java.util.UUID;

/**
* 어드민 식당 등록 요청. imageKeys·메뉴 imageKey는 presigned URL로 업로드 완료된 S3 object key다.
Expand Down Expand Up @@ -51,10 +56,15 @@ public record CreateRestaurantRequest(
@NotNull(message = "최대 가격은 필수입니다")
@PositiveOrZero(message = "최대 가격은 0 이상입니다") BigDecimal maxPrice,
@Schema(description = "식당 이미지 S3 key 목록", example = "[\"restaurants/a1b2c3-1.jpg\"]")
@NotNull(message = "식당 이미지는 필수입니다")
@Size(min = 1, message = "식당 이미지는 최소 1개 이상 필요합니다")
List<@NotBlank(message = "이미지 키는 비어 있을 수 없습니다")
@Size(max = 500, message = "이미지 키는 500자 이내입니다") String> imageKeys,
@Schema(description = "식당 이미지 asset ID 목록")
@Size(min = 1, message = "식당 이미지 asset은 최소 1개 이상 필요합니다")
List<@NotNull(message = "이미지 asset ID는 null일 수 없습니다") UUID> imageAssetIds,
@JsonProperty("images")
@Schema(hidden = true)
JsonNode unsupportedImages,
List<@NotNull(message = "메뉴 항목은 null일 수 없습니다") @Valid MenuRequest> menus,
@Schema(description = "해시태그 목록", example = "[\"현지인맛집\"]")
@NotNull(message = "해시태그는 필수입니다")
Expand All @@ -67,6 +77,69 @@ public record CreateRestaurantRequest(
@Size(min = 7, max = 7, message = "영업시간은 모든 요일(7개)을 포함해야 합니다")
List<@NotNull(message = "영업시간 항목은 null일 수 없습니다") @Valid BusinessHourRequest> businessHours) {

/** legacy 생성 호출부와 테스트를 신규 필드 활성화 전까지 호환한다. */
public CreateRestaurantRequest(
String name,
String localName,
String summary,
String description,
String address,
String area,
String genre,
String foodCategory,
String priceCurrency,
BigDecimal minPrice,
BigDecimal maxPrice,
List<String> imageKeys,
List<MenuRequest> menus,
List<String> hashtags,
List<String> curationTypes,
List<BusinessHourRequest> businessHours
) {
this(
name, localName, summary, description, address, area, genre, foodCategory,
priceCurrency, minPrice, maxPrice, imageKeys, null, menus, hashtags,
curationTypes, businessHours);
}

/** 신규 asset 생성 호출부가 사용하던 canonical 인자 순서를 유지한다. */
public CreateRestaurantRequest(
String name,
String localName,
String summary,
String description,
String address,
String area,
String genre,
String foodCategory,
String priceCurrency,
BigDecimal minPrice,
BigDecimal maxPrice,
List<String> imageKeys,
List<UUID> imageAssetIds,
List<MenuRequest> menus,
List<String> hashtags,
List<String> curationTypes,
List<BusinessHourRequest> businessHours
) {
this(
name, localName, summary, description, address, area, genre, foodCategory,
priceCurrency, minPrice, maxPrice, imageKeys, imageAssetIds, null, menus,
hashtags, curationTypes, businessHours);
}

@AssertTrue(message = "식당 이미지는 imageKeys 또는 imageAssetIds 중 하나만 필요합니다")
@JsonIgnore
public boolean isImageSourceValid() {
return (imageKeys == null) != (imageAssetIds == null);
}

@AssertTrue(message = "식당 등록에서는 images를 사용할 수 없습니다")
@JsonIgnore
public boolean isCreateImageContractValid() {
return unsupportedImages == null;
}

/** 메뉴 항목 — 목록 전체가 함께 저장되므로 각 항목은 완전한 값으로 받는다. */
public record MenuRequest(
@Schema(description = "메뉴명", example = "특선 모둠 야키니쿠")
Expand All @@ -78,6 +151,8 @@ public record MenuRequest(
@Schema(description = "메뉴 이미지 S3 key(선택)", example = "restaurant-menus/a1b2c3-menu.jpg")
@Pattern(regexp = ".*\\S.*", message = "메뉴 이미지 키는 공백일 수 없습니다")
@Size(max = 500, message = "메뉴 이미지 키는 500자 이내입니다") String imageKey,
@Schema(description = "메뉴 이미지 asset ID(선택)")
UUID imageAssetId,
@Schema(description = "통화 코드", example = "JPY")
@NotBlank(message = "통화는 필수입니다")
@Size(min = 3, max = 3, message = "통화는 3자리 코드여야 합니다") String priceCurrency,
Expand All @@ -86,6 +161,23 @@ public record MenuRequest(
@PositiveOrZero(message = "가격은 0 이상입니다") BigDecimal priceAmount,
@Schema(description = "대표 메뉴 여부", example = "true")
@NotNull(message = "대표 메뉴 여부는 필수입니다") Boolean main) {

public MenuRequest(
String name,
String description,
String imageKey,
String priceCurrency,
BigDecimal priceAmount,
Boolean main
) {
this(name, description, imageKey, null, priceCurrency, priceAmount, main);
}

@AssertTrue(message = "메뉴 이미지는 imageKey와 imageAssetId를 함께 사용할 수 없습니다")
@JsonIgnore
public boolean isImageSourceValid() {
return imageKey == null || imageAssetId == null;
}
}

/**
Expand Down
Loading
Loading