diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index 5299571b..06a75fa0 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -99,6 +99,19 @@ ### 2.6 배틀 삭제 - `DELETE /api/v1/admin/battles/{battleId}` +### 2.7 배틀 공개·보관 상태 변경 +- `PATCH /api/v1/admin/battles/{battleId}/status` +- 요청 본문: `{ "status": "ARCHIVED" }` (`PUBLISHED` 공개 / `ARCHIVED` 보관) +- 공개 ↔ 보관 전환만 한다. 보관된 배틀은 사용자 화면(목록·상세·추천)에 나오지 않고, 다시 공개하면 그대로 보인다. +- 같은 상태로 보내면 변경 없이 통과한다. +- 임시저장(`PENDING`) 배틀은 배틀 수정(만들기)으로 발행한다. 임시 이미지 이동이 함께 일어나야 하기 때문이다. +- 응답: `AdminBattleDetailResponse` + +| 상황 | 응답 | +|---|---| +| 그 밖의 전환 (임시저장 → 공개, 공개 → 임시저장, 반려 등) | 409 `BATTLE_409_STATUS` | +| 삭제된 배틀 | 404 `BATTLE_404` | + --- ## 3. 상태/정책 메모 diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java index 1a06d623..c71d5329 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; @@ -89,6 +90,17 @@ public ApiResponse updateClassSettings( return ApiResponse.onSuccess(adminBattleService.updateClassSettings(battleId, request)); } + @Operation(summary = "배틀 공개·보관 상태 변경", + description = "공개(PUBLISHED) ↔ 보관(ARCHIVED) 전환만 합니다. 보관된 배틀은 사용자 화면에 나오지 않습니다. " + + "임시저장 배틀은 배틀 수정(만들기)으로 발행하며, 그 밖의 전환은 409 BATTLE_409_STATUS, 삭제된 배틀은 404 BATTLE_404.") + @PatchMapping("/{battleId}/status") + public ApiResponse updateStatus( + @PathVariable Long battleId, + @RequestBody @Valid AdminBattleStatusRequest request + ) { + return ApiResponse.onSuccess(adminBattleService.updateStatus(battleId, request)); + } + @Operation(summary = "배틀 삭제") @DeleteMapping("/{battleId}") public ApiResponse deleteBattle( diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleStatusRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleStatusRequest.java new file mode 100644 index 00000000..82f278c0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleStatusRequest.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +/** 공개(PUBLISHED) ↔ 보관(ARCHIVED) 전환만 받는다. 임시저장 배틀의 첫 발행은 배틀 수정(만들기)으로 한다 (#622) */ +@Schema(description = "배틀 공개·보관 상태 변경 요청") +public record AdminBattleStatusRequest( + + @Schema(description = "바꿀 상태. PUBLISHED(공개) 또는 ARCHIVED(보관)", example = "ARCHIVED") + @NotNull + BattleStatus status +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java index be4c136f..234feecf 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -38,6 +39,10 @@ public AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleC return battleService.updateClassSettings(battleId, request); } + public AdminBattleDetailResponse updateStatus(Long battleId, AdminBattleStatusRequest request) { + return battleService.updateStatus(battleId, request); + } + public AdminBattleDeleteResponse deleteBattle(Long battleId) { return battleService.deleteBattle(battleId); } diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java index 808903aa..bd3bf944 100644 --- a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java +++ b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java @@ -216,4 +216,9 @@ public void publish() { this.status = BattleStatus.PUBLISHED; } + /** 어드민 공개·보관 전환 (#622). 삭제된 배틀과 그 밖의 전환은 서비스에서 막는다 */ + public void changePublicationStatus(BattleStatus status) { + this.status = status; + } + } diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java index e26c7a32..2331a6b8 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.battle.service; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -62,6 +63,8 @@ public interface BattleService { AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request); + AdminBattleDetailResponse updateStatus(Long battleId, AdminBattleStatusRequest request); + AdminBattleDeleteResponse deleteBattle(Long battleId); void seedBattle(Long battleId, int botCount); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index 9b4f3718..4a1ca4c2 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.battle.converter.BattleConverter; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; @@ -520,6 +521,26 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), updatedOptions, optionTagsMap); } + /** 공개 ↔ 보관만 허용한다. 보관된 배틀은 PUBLISHED 조건으로 걸러져 사용자 화면에 나오지 않는다 (#622) */ + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse updateStatus(Long battleId, AdminBattleStatusRequest request) { + Battle battle = findById(battleId); + if (battle.getDeletedAt() != null) { + throw new CustomException(ErrorCode.BATTLE_NOT_FOUND); + } + BattleStatus current = battle.getStatus(); + BattleStatus target = request.status(); + boolean allowed = (current == BattleStatus.PUBLISHED && target == BattleStatus.ARCHIVED) + || (current == BattleStatus.ARCHIVED && target == BattleStatus.PUBLISHED); + if (current != target && !allowed) { + throw new CustomException(ErrorCode.BATTLE_STATUS_CHANGE_INVALID); + } + battle.changePublicationStatus(target); + return getAdminBattleDetail(battleId); + } + @Override @Transactional @PreAuthorize("hasRole('ADMIN')") diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index b68b2829..98fc00ff 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -63,6 +63,7 @@ public enum ErrorCode { BATTLE_ALREADY_PUBLISHED(HttpStatus.CONFLICT, "BATTLE_409_PUB", "이미 발행된 배틀입니다."), BATTLE_OPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "BATTLE_OPT_404", "존재하지 않는 선택지입니다."), BATTLE_INVALID_OPTION_COUNT(HttpStatus.BAD_REQUEST, "BATTLE_400_OPT", "배틀 타입에 맞지 않는 선택지 개수입니다."), + BATTLE_STATUS_CHANGE_INVALID(HttpStatus.CONFLICT, "BATTLE_409_STATUS", "공개된 배틀은 보관만, 보관된 배틀은 공개만 할 수 있습니다. 임시저장 배틀은 만들기로 발행해 주세요."), BATTLE_CANNOT_REVERT_TO_PENDING(HttpStatus.BAD_REQUEST, "BATTLE_400_REVERT", "발행된 시나리오는 다시 대기(PENDING) 상태로 되돌릴 수 없습니다."), BATTLE_NOT_QUIZ(HttpStatus.BAD_REQUEST, "BATTLE_400_QUIZ", "해당 배틀은 퀴즈 타입이 아닙니다."), BATTLE_NOT_POLL(HttpStatus.BAD_REQUEST, "BATTLE_400_POLL", "해당 배틀은 투표 타입이 아닙니다."), diff --git a/src/main/resources/db/migration/V20261107_01__battles_difficulty_plan.sql b/src/main/resources/db/migration/V20261107_01__battles_difficulty_plan.sql index fc6a26b2..7e11f729 100644 --- a/src/main/resources/db/migration/V20261107_01__battles_difficulty_plan.sql +++ b/src/main/resources/db/migration/V20261107_01__battles_difficulty_plan.sql @@ -1,5 +1,5 @@ -- #610 기획 확정 배틀별 수준 반영. #571 에서 임시로 초급으로 채운 기존 배틀의 수준을 확정값으로 바꾼다. --- 기획 목록 115개와, 기획과 제목이 다르게 등록된 배틀의 이전 제목 9개로 DB에 있는 배틀만 찾는다. 목록에 없거나 DB에 없는 배틀은 그대로 둔다. +-- 기획 목록 115개와, 기획과 제목이 다르게 등록된 배틀의 이전 제목 26개로 DB에 있는 배틀만 찾는다. 목록에 없거나 DB에 없는 배틀은 그대로 둔다. -- 제목은 공백·문장부호를 빼고 비교한다. 사용자가 만든 배틀(USER)과 삭제된 배틀은 제외한다. -- 카테고리는 같은 제목이 사회 초급·역사 중급에 있는 "살기 위해 반역의 왕위, 받아들일까?"에만 비교한다. 한 배틀에 기획 행이 둘 이상 맞으면 바꾸지 않는다. -- 멱등하게 작성되어 재실행해도 안전하다. 한 트랜잭션으로 실행한다. @@ -134,7 +134,24 @@ INSERT INTO battle_difficulty_plan (plan_no, title, category, difficulty) VALUES ('문학 001 (이전 제목)', '역사 왜곡인가, 창작의 자유인가?', NULL, 'INTERMEDIATE'), ('예술 001 (이전 제목)', 'AI는 예술을 창작할 수 있는가?', NULL, 'INTERMEDIATE'), ('예술 003 (이전 제목)', '클래식은 고상하고 대중음악은 가벼운가?', NULL, 'BEGINNER'), - ('바이럴 004 (이전 제목)', '부모가 아이 이름에 종교적 의미를 담는 것은', NULL, 'INTERMEDIATE'); + ('바이럴 004 (이전 제목)', '부모가 아이 이름에 종교적 의미를 담는 것은', NULL, 'INTERMEDIATE'), + ('역사 002 (이전 제목)', '오펜하이머, 영웅인가 악마인가?', NULL, 'ADVANCED'), + ('문학 001 (이전 제목)', '역사 왜곡, 창작의 자유일까?', NULL, 'INTERMEDIATE'), + ('문학 006 (이전 제목)', '로미오와 줄리엣, 숭고한 로맨스인가?', NULL, 'INTERMEDIATE'), + ('사회 014 (이전 제목)', '외모도 실력일까?', NULL, 'BEGINNER'), + ('철학 023 (이전 제목)', '죽음을 앞둔 사람에게 진실을 말해야 할까?', NULL, 'ADVANCED'), + ('역사 005 (이전 제목)', '이방원, 폭군인가 혁명가인가?', NULL, 'INTERMEDIATE'), + ('철학 026 (이전 제목)', '일은 좋아하는 걸 택할까, 잘하는 걸 택할까?', NULL, 'BEGINNER'), + ('철학 025 (이전 제목)', '애인이 내 친구의 깻잎 떼줘도 괜찮을까?', NULL, 'BEGINNER'), + ('철학 013 (이전 제목)', '사라질 위기의 언어는 의무 교육해야 할까?', NULL, 'INTERMEDIATE'), + ('사회 008 (이전 제목)', '노키즈존은 영업상의 자유일까?', NULL, 'BEGINNER'), + ('바이럴 007 (이전 제목)', '탕수육, 부먹인가 찍먹인가?', NULL, 'BEGINNER'), + ('바이럴 014 (이전 제목)', '축의금, 친한 만큼 낼까 형편만큼 낼까?', NULL, 'INTERMEDIATE'), + ('바이럴 019 (이전 제목)', '직장 동료 차 뒷좌석에 타는 건 비매너일까?', NULL, 'INTERMEDIATE'), + ('문학 005 (이전 제목)', '5분 요약으로 영화를 봤다고 해도 될까?', NULL, 'BEGINNER'), + ('바이럴 030 (이전 제목)', '불매운동은 소비자의 권리인가?', NULL, 'INTERMEDIATE'), + ('바이럴 026 (이전 제목)', '진격의 거인, 에렌은 영웅인가 학살자인가?', NULL, 'ADVANCED'), + ('바이럴 024 (이전 제목)', '부모의 극단적 개입, 사랑인가 착취인가?', NULL, 'INTERMEDIATE'); -- 제목은 공백·문장부호를 빼고 비교한다 CREATE TEMP TABLE battle_difficulty_match AS diff --git a/src/main/resources/db/migration/V20261107_02__funding_orders_legacy_cleanup.sql b/src/main/resources/db/migration/V20261107_02__funding_orders_legacy_cleanup.sql new file mode 100644 index 00000000..cfe59748 --- /dev/null +++ b/src/main/resources/db/migration/V20261107_02__funding_orders_legacy_cleanup.sql @@ -0,0 +1,46 @@ +-- #616 운영 DB 에 남아 있는 이전 펀딩 스키마 정리. +-- 운영에는 v3.0.0 이전 펀딩 기능이 만든 funding_orders 가 있어 campaign_id 가 BIGINT(funding_campaigns FK)이고, +-- 지금 엔티티가 쓰지 않는 NOT NULL 컬럼(capacity, buyer_name, reward_line_id, reward_type, additional_support_amount)이 남아 있다. +-- 그대로 두면 주문 저장이 NOT NULL 위반으로 실패하고, ddl-auto 의 campaign_id VARCHAR(50) 변경도 FK 때문에 막힌다. +-- 지금 엔티티 기준(dev 와 같은 구조)으로 맞춘다. 멱등하게 작성되어 재실행해도 안전하다. 한 트랜잭션으로 실행한다. +-- 행이 있으면 아무것도 하지 않고 멈춘다 (이전 스키마로 쌓인 데이터를 지우지 않기 위해). + +BEGIN; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM funding_orders) THEN + RAISE EXCEPTION 'funding_orders 에 행이 있어 정리를 멈춥니다. 데이터를 먼저 확인하세요.'; + END IF; +END $$; + +ALTER TABLE funding_orders DROP CONSTRAINT IF EXISTS fk_funding_order_campaign; +ALTER TABLE funding_orders DROP CONSTRAINT IF EXISTS uk_funding_order_external_line; +ALTER TABLE funding_orders DROP CONSTRAINT IF EXISTS uk_funding_order_supporter_line; +ALTER TABLE funding_orders DROP CONSTRAINT IF EXISTS funding_orders_capacity_check; +DROP INDEX IF EXISTS idx_funding_order_buyer_name; +DROP INDEX IF EXISTS idx_funding_order_contact_hash; + +ALTER TABLE funding_orders ALTER COLUMN campaign_id TYPE VARCHAR(50) USING campaign_id::text; + +ALTER TABLE funding_orders + DROP COLUMN IF EXISTS external_order_id, + DROP COLUMN IF EXISTS external_supporter_number, + DROP COLUMN IF EXISTS reward_line_id, + DROP COLUMN IF EXISTS reward_type, + DROP COLUMN IF EXISTS capacity, + DROP COLUMN IF EXISTS buyer_name, + DROP COLUMN IF EXISTS buyer_contact_encrypted, + DROP COLUMN IF EXISTS buyer_contact_search_hash, + DROP COLUMN IF EXISTS source_version, + DROP COLUMN IF EXISTS external_gift_text, + DROP COLUMN IF EXISTS reward_amount, + DROP COLUMN IF EXISTS additional_support_amount, + DROP COLUMN IF EXISTS total_support_amount, + DROP COLUMN IF EXISTS notification_requested, + DROP COLUMN IF EXISTS tumblbug_notification_status, + DROP COLUMN IF EXISTS tumblbug_payment_status, + DROP COLUMN IF EXISTS tumblbug_gift_delivery_status, + DROP COLUMN IF EXISTS source_snapshot; + +COMMIT; diff --git a/src/main/resources/db/migration/V20261107_rollback__battles_difficulty_plan.sql b/src/main/resources/db/migration/V20261107_rollback__battles_difficulty_plan.sql index 21b66203..925e75a7 100644 --- a/src/main/resources/db/migration/V20261107_rollback__battles_difficulty_plan.sql +++ b/src/main/resources/db/migration/V20261107_rollback__battles_difficulty_plan.sql @@ -131,7 +131,24 @@ INSERT INTO battle_difficulty_plan (plan_no, title, category, difficulty) VALUES ('문학 001 (이전 제목)', '역사 왜곡인가, 창작의 자유인가?', NULL, 'INTERMEDIATE'), ('예술 001 (이전 제목)', 'AI는 예술을 창작할 수 있는가?', NULL, 'INTERMEDIATE'), ('예술 003 (이전 제목)', '클래식은 고상하고 대중음악은 가벼운가?', NULL, 'BEGINNER'), - ('바이럴 004 (이전 제목)', '부모가 아이 이름에 종교적 의미를 담는 것은', NULL, 'INTERMEDIATE'); + ('바이럴 004 (이전 제목)', '부모가 아이 이름에 종교적 의미를 담는 것은', NULL, 'INTERMEDIATE'), + ('역사 002 (이전 제목)', '오펜하이머, 영웅인가 악마인가?', NULL, 'ADVANCED'), + ('문학 001 (이전 제목)', '역사 왜곡, 창작의 자유일까?', NULL, 'INTERMEDIATE'), + ('문학 006 (이전 제목)', '로미오와 줄리엣, 숭고한 로맨스인가?', NULL, 'INTERMEDIATE'), + ('사회 014 (이전 제목)', '외모도 실력일까?', NULL, 'BEGINNER'), + ('철학 023 (이전 제목)', '죽음을 앞둔 사람에게 진실을 말해야 할까?', NULL, 'ADVANCED'), + ('역사 005 (이전 제목)', '이방원, 폭군인가 혁명가인가?', NULL, 'INTERMEDIATE'), + ('철학 026 (이전 제목)', '일은 좋아하는 걸 택할까, 잘하는 걸 택할까?', NULL, 'BEGINNER'), + ('철학 025 (이전 제목)', '애인이 내 친구의 깻잎 떼줘도 괜찮을까?', NULL, 'BEGINNER'), + ('철학 013 (이전 제목)', '사라질 위기의 언어는 의무 교육해야 할까?', NULL, 'INTERMEDIATE'), + ('사회 008 (이전 제목)', '노키즈존은 영업상의 자유일까?', NULL, 'BEGINNER'), + ('바이럴 007 (이전 제목)', '탕수육, 부먹인가 찍먹인가?', NULL, 'BEGINNER'), + ('바이럴 014 (이전 제목)', '축의금, 친한 만큼 낼까 형편만큼 낼까?', NULL, 'INTERMEDIATE'), + ('바이럴 019 (이전 제목)', '직장 동료 차 뒷좌석에 타는 건 비매너일까?', NULL, 'INTERMEDIATE'), + ('문학 005 (이전 제목)', '5분 요약으로 영화를 봤다고 해도 될까?', NULL, 'BEGINNER'), + ('바이럴 030 (이전 제목)', '불매운동은 소비자의 권리인가?', NULL, 'INTERMEDIATE'), + ('바이럴 026 (이전 제목)', '진격의 거인, 에렌은 영웅인가 학살자인가?', NULL, 'ADVANCED'), + ('바이럴 024 (이전 제목)', '부모의 극단적 개입, 사랑인가 착취인가?', NULL, 'INTERMEDIATE'); -- 제목은 공백·문장부호를 빼고 비교한다 CREATE TEMP TABLE battle_difficulty_match AS diff --git a/src/main/resources/db/migration/V20261108_01__battles_planner_answers.sql b/src/main/resources/db/migration/V20261108_01__battles_planner_answers.sql new file mode 100644 index 00000000..56566102 --- /dev/null +++ b/src/main/resources/db/migration/V20261108_01__battles_planner_answers.sql @@ -0,0 +1,25 @@ +-- #620 기획 문의로 남겨 둔 배틀의 기획 답변 반영 (#610, #618 이후). +-- 제목은 V20261107_01 과 같이 공백·문장부호를 빼고 비교한다. 사용자가 만든 배틀(USER)과 삭제된 배틀은 제외한다. +-- "행복은 순간의 감정일까, 남겨진 기억일까?" 는 주제가 모호해 내리기로 해 어드민에서 처리한다 (이 SQL 대상 아님). +-- 멱등하게 작성되어 재실행해도 안전하다. 한 트랜잭션으로 실행한다. + +BEGIN; + +-- 잘못한 사람, 엄벌할까 사랑으로 감쌀까? → 초급 +UPDATE battles +SET difficulty = 'BEGINNER' +WHERE regexp_replace(lower(title), '[[:space:][:punct:]‘’“”·…「」『』]', '', 'g') = '잘못한사람엄벌할까사랑으로감쌀까' + AND deleted_at IS NULL AND creator_type <> 'USER' + AND difficulty IS DISTINCT FROM 'BEGINNER'; + +-- 살기 위해 반역의 왕위, 받아들일까? → 기존 콘텐츠 파일과 같이 역사, 중급 +-- 같은 제목이 기획 표에 사회 초급·역사 중급으로 있어 카테고리가 비어 있는 배틀만 역사로 채운다 +UPDATE battles +SET category = COALESCE(category, 'HISTORY'), + difficulty = 'INTERMEDIATE' +WHERE regexp_replace(lower(title), '[[:space:][:punct:]‘’“”·…「」『』]', '', 'g') = '살기위해반역의왕위받아들일까' + AND deleted_at IS NULL AND creator_type <> 'USER' + AND (category IS NULL OR category = 'HISTORY') + AND (category IS NULL OR difficulty IS DISTINCT FROM 'INTERMEDIATE'); + +COMMIT; diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminBattleStatusApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminBattleStatusApiIntegrationTest.java new file mode 100644 index 00000000..41518f56 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminBattleStatusApiIntegrationTest.java @@ -0,0 +1,172 @@ +package com.swyp.picke.domain.admin.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +/** + * 어드민 배틀 공개·보관 상태 변경(#622)을 HTTP 로 검증한다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminBattleStatusApiIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtProvider jwtProvider; + @Autowired private UserRepository userRepository; + @Autowired private BattleRepository battleRepository; + @Autowired private BattleOptionRepository battleOptionRepository; + + private final List battleIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + private String userToken; + private String adminToken; + + @BeforeEach + void setUp() { + userToken = tokenOf(newUser(UserRole.USER), UserRole.USER); + adminToken = tokenOf(newUser(UserRole.ADMIN), UserRole.ADMIN); + } + + @AfterEach + void tearDown() { + battleOptionRepository.deleteAll(battleOptionRepository.findAll().stream() + .filter(o -> battleIds.contains(o.getBattle().getId())).toList()); + battleRepository.deleteAllById(battleIds); + userRepository.deleteAllById(userIds); + battleIds.clear(); + userIds.clear(); + } + + @Test + @DisplayName("공개 배틀을 보관하면 사용자 상세에서 막히고, 다시 공개하면 보인다") + void archiveThenRepublish() throws Exception { + Battle battle = battle("보관할 배틀", BattleStatus.PUBLISHED); + + changeStatus(battle.getId(), "ARCHIVED", adminToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + assertThat(battleRepository.findById(battle.getId()).orElseThrow().getStatus()).isEqualTo(BattleStatus.ARCHIVED); + mockMvc.perform(get("/api/v1/battles/{battleId}", battle.getId()).header("Authorization", "Bearer " + userToken)) + .andExpect(status().is4xxClientError()); + + changeStatus(battle.getId(), "PUBLISHED", adminToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + mockMvc.perform(get("/api/v1/battles/{battleId}", battle.getId()).header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("같은 상태로 바꾸면 그대로 통과한다") + void sameStatusIsNoop() throws Exception { + Battle battle = battle("이미 공개", BattleStatus.PUBLISHED); + + changeStatus(battle.getId(), "PUBLISHED", adminToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + } + + @Test + @DisplayName("임시저장 배틀 발행·공개 배틀을 임시저장으로·반려 전환은 409, 삭제된 배틀은 404") + void rejectsOtherTransitions() throws Exception { + Battle pending = battle("임시저장", BattleStatus.PENDING); + Battle published = battle("공개", BattleStatus.PUBLISHED); + Battle deleted = battle("삭제됨", BattleStatus.PUBLISHED); + deleted.delete(); + battleRepository.save(deleted); + + changeStatus(pending.getId(), "PUBLISHED", adminToken) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("BATTLE_409_STATUS")); + changeStatus(published.getId(), "PENDING", adminToken) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("BATTLE_409_STATUS")); + changeStatus(published.getId(), "REJECTED", adminToken) + .andExpect(status().isConflict()); + changeStatus(deleted.getId(), "PUBLISHED", adminToken) + .andExpect(status().isNotFound()); + assertThat(battleRepository.findById(pending.getId()).orElseThrow().getStatus()).isEqualTo(BattleStatus.PENDING); + } + + @Test + @DisplayName("일반 사용자는 상태를 바꿀 수 없다") + void userIsForbidden() throws Exception { + Battle battle = battle("권한 확인", BattleStatus.PUBLISHED); + + changeStatus(battle.getId(), "ARCHIVED", userToken) + .andExpect(status().isForbidden()); + assertThat(battleRepository.findById(battle.getId()).orElseThrow().getStatus()).isEqualTo(BattleStatus.PUBLISHED); + } + + private ResultActions changeStatus(Long battleId, String status, String token) throws Exception { + return mockMvc.perform(patch("/api/v1/admin/battles/{battleId}/status", battleId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("status", status)))); + } + + private Battle battle(String title, BattleStatus status) { + Battle battle = battleRepository.save(Battle.builder() + .title(title) + .summary(title + " 요약") + .description(title + " 설명") + .audioDuration(300) + .status(status) + .creatorType(BattleCreatorType.ADMIN) + .build()); + battleIds.add(battle.getId()); + battleOptionRepository.save(BattleOption.builder() + .battle(battle).title("찬성").stance("찬성 근거").representative("칸트").displayOrder(1).build()); + battleOptionRepository.save(BattleOption.builder() + .battle(battle).title("반대").stance("반대 근거").representative("니체").displayOrder(2).build()); + return battle; + } + + private User newUser(UserRole role) { + User user = userRepository.save(User.builder() + .userTag("st-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("상태테스트") + .role(role) + .status(UserStatus.ACTIVE) + .build()); + userIds.add(user.getId()); + return user; + } + + private String tokenOf(User user, UserRole role) { + return jwtProvider.createAccessToken(user.getId(), role.name()); + } +}