feat(render): booth render history gallery (G-09)

History endpoint over render_job (DONE only) with covering index V54;
design studio shows a per-booth history gallery with watermark intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 06:26:52 +09:00
parent 23ff7d1799
commit 216f3720a1
9 changed files with 181 additions and 5 deletions

View File

@ -55,4 +55,13 @@ public class RenderJobController {
guard.requireEventAccess(principal, eventId); guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.listByBooth(boothId, shot)); return ApiResponse.ok(service.listByBooth(boothId, shot));
} }
/** GET /booths/{boothId}/render-history — 완료(DONE) 렌더 이력 갤러리(SCR-06, G-09). 최신순 영속 이력. */
@GetMapping("/booths/{boothId}/render-history")
public ApiResponse<List<RenderJobDto>> history(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId,
@PathVariable String boothId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.listHistoryByBooth(boothId));
}
} }

View File

@ -26,6 +26,9 @@ public interface RenderJobService {
/** 부스 단위 잡 목록(SCR-12 갤러리) — 내구 저장(DB) 기반. */ /** 부스 단위 잡 목록(SCR-12 갤러리) — 내구 저장(DB) 기반. */
List<RenderJobDto> listByBooth(String boothId, String shotPreset); List<RenderJobDto> listByBooth(String boothId, String shotPreset);
/** 부스 완료(DONE) 렌더 이력(SCR-06 이력 갤러리) — 내구 저장(DB) 기반, 최신순. */
List<RenderJobDto> listHistoryByBooth(String boothId);
/** 워커 완료/실패 콜백 처리 — 상태 갱신 + WebSocket 푸시(+ 성공 시 쿼터 차감). */ /** 워커 완료/실패 콜백 처리 — 상태 갱신 + WebSocket 푸시(+ 성공 시 쿼터 차감). */
RenderJobDto handleWorkerCallback(WorkerCallbackRequest callback); RenderJobDto handleWorkerCallback(WorkerCallbackRequest callback);
} }

View File

@ -155,6 +155,24 @@ public class RenderJobServiceImpl implements RenderJobService {
return out; return out;
} }
@Override
public List<RenderJobDto> listHistoryByBooth(String boothId) {
List<Map<String, Object>> rows = renderJobMapper.findHistoryByBooth(boothId);
List<RenderJobDto> out = new java.util.ArrayList<>();
if (rows == null) {
return out;
}
for (Map<String, Object> r : rows) {
out.add(new RenderJobDto(
str(r.get("jobId")), str(r.get("boothId")), str(r.get("shotPreset")),
str(r.get("status")), str(r.get("imageUrl")),
str(r.get("schemaHash")), str(r.get("modelVersion")),
true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE,
null, str(r.get("createdAt"))));
}
return out;
}
@Override @Override
public RenderJobDto handleWorkerCallback(WorkerCallbackRequest callback) { public RenderJobDto handleWorkerCallback(WorkerCallbackRequest callback) {
RenderJobDto prev = readState(callback.jobId()); RenderJobDto prev = readState(callback.jobId());

View File

@ -27,6 +27,9 @@ public interface RenderJobMapper {
List<Map<String, Object>> findByBooth(@Param("boothId") String boothId, List<Map<String, Object>> findByBooth(@Param("boothId") String boothId,
@Param("shotPreset") String shotPreset); @Param("shotPreset") String shotPreset);
/** 부스 단위 완료(DONE) 렌더 이력(SCR-06 이력 갤러리) — 최신순. 이미지 URL 있는 성공 결과만. */
List<Map<String, Object>> findHistoryByBooth(@Param("boothId") String boothId);
/** 행사별 성공 생성 건수(쿼터 산정). */ /** 행사별 성공 생성 건수(쿼터 산정). */
int countSucceededByEvent(@Param("eventId") String eventId); int countSucceededByEvent(@Param("eventId") String eventId);
} }

View File

@ -0,0 +1,6 @@
-- V54 — 부스 렌더 이력(SCR-06 이력 갤러리, G-09) 조회 최적화.
-- findHistoryByBooth: WHERE booth_id = ? AND status = 'DONE' ORDER BY created_at DESC.
-- 기존 idx_render_job_booth(booth_id, shot_preset)는 status/정렬을 커버하지 못함 → 전용 커버링 인덱스 추가.
-- 멱등(IF NOT EXISTS) — 재적용/기존 데이터 무영향. 신규 테이블·컬럼 없음(render_job 재사용).
CREATE INDEX IF NOT EXISTS idx_render_job_history
ON render_job (booth_id, status, created_at DESC);

View File

@ -48,6 +48,23 @@
ORDER BY created_at DESC ORDER BY created_at DESC
</select> </select>
<!-- 부스 완료(DONE) 렌더 이력(SCR-06 이력 갤러리). 이미지 URL 있는 성공 결과만·최신순. -->
<select id="findHistoryByBooth" resultType="map">
SELECT id AS "jobId",
booth_id AS "boothId",
shot_preset AS "shotPreset",
status,
image_url AS "imageUrl",
schema_hash AS "schemaHash",
model_version AS "modelVersion",
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
FROM render_job
WHERE booth_id = #{boothId}
AND status = 'DONE'
AND image_url IS NOT NULL
ORDER BY created_at DESC
</select>
<!-- 행사별 성공(DONE) 건수(쿼터 정본 산정). --> <!-- 행사별 성공(DONE) 건수(쿼터 정본 산정). -->
<select id="countSucceededByEvent" resultType="int"> <select id="countSucceededByEvent" resultType="int">
SELECT COUNT(*) SELECT COUNT(*)

View File

@ -13,6 +13,7 @@ import {
REROOM_STYLES, REROOM_STYLES,
downscaleImage, downscaleImage,
reroomApi, reroomApi,
renderHistoryApi,
MAX_UPLOAD_BYTES, MAX_UPLOAD_BYTES,
} from './reroomApi'; } from './reroomApi';
import './studio.css'; import './studio.css';
@ -83,6 +84,20 @@ export function BoothDesignStudioPage() {
const [reroomError, setReroomError] = useState<string | null>(null); const [reroomError, setReroomError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
// ── 렌더 이력 갤러리(G-09) — 완료 렌더잡 영속 이력 ──
const [history, setHistory] = useState<RenderJobDto[]>([]);
const [historyError, setHistoryError] = useState(false);
const loadHistory = useCallback(async () => {
if (!eventId || !boothId) return;
try {
setHistory(await renderHistoryApi.list(eventId, boothId));
setHistoryError(false);
} catch {
setHistoryError(true); // 미배선/미존재 시 갤러리 숨김(회귀 방지)
}
}, [eventId, boothId]);
// 초기 설계안 로드(501이면 degraded 기본 스펙 유지). // 초기 설계안 로드(501이면 degraded 기본 스펙 유지).
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@ -136,6 +151,21 @@ export function BoothDesignStudioPage() {
}; };
}, [refPreview]); }, [refPreview]);
// 렌더 이력 초기 로드.
useEffect(() => {
void loadHistory();
}, [loadHistory]);
// 잡 완료(DONE) 감지 시 이력 재조회 — 새 결과가 갤러리에 즉시 반영되도록.
const doneKey = Object.values(jobs)
.filter((j) => j.status === 'DONE')
.map((j) => j.jobId)
.concat(reroomJob?.status === 'DONE' && reroomJob.jobId ? [reroomJob.jobId] : [])
.join(',');
useEffect(() => {
if (doneKey) void loadHistory();
}, [doneKey, loadHistory]);
// 참조 이미지 선택 → 1024px 다운스케일 → 즉시 업로드(referenceId 확보). // 참조 이미지 선택 → 1024px 다운스케일 → 즉시 업로드(referenceId 확보).
const handleRefFile = useCallback( const handleRefFile = useCallback(
async (file: File | undefined | null) => { async (file: File | undefined | null) => {
@ -235,11 +265,7 @@ export function BoothDesignStudioPage() {
setNotice('행사 이미지 생성 쿼터가 소진되었습니다.'); setNotice('행사 이미지 생성 쿼터가 소진되었습니다.');
break; break;
} }
if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') { setNotice(err instanceof ApiRequestError ? err.message : '예상 사진 생성 중 오류.');
setNotice('렌더 이력 저장이 준비 중입니다(큐잉만 동작).');
} else {
setNotice(err instanceof ApiRequestError ? err.message : '예상 사진 생성 중 오류.');
}
} }
} }
setGenerating(false); setGenerating(false);
@ -679,6 +705,36 @@ export function BoothDesignStudioPage() {
)} )}
</section> </section>
{/* 렌더 이력 갤러리(G-09) — 완료된 예상 사진의 영속 이력 */}
{!historyError && history.length > 0 && (
<section className="kx-studio__history" aria-label="렌더 이력">
<div className="kx-studio__history-head">
<AiLabel> </AiLabel>
<span className="kx-studio__history-sub">
{history.length} · (AI · )
</span>
</div>
<div className="kx-studio__history-grid">
{history.map((job) => (
<figure className="kx-studio__history-item" key={job.jobId}>
<AiImage
imageUrl={job.imageUrl}
status={job.status}
watermarkText={job.watermarkText}
notice={job.notice}
shotLabel={job.shotPreset}
alt={`${job.shotPreset} 렌더 이력`}
/>
<figcaption className="kx-studio__history-cap tnum">
{job.shotPreset}
{job.createdAt ? ` · ${fmtHistoryDate(job.createdAt)}` : ''}
</figcaption>
</figure>
))}
</div>
</section>
)}
{/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */} {/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */}
<footer className="kx-studio__footer"> <footer className="kx-studio__footer">
<div className="kx-studio__precheck"> <div className="kx-studio__precheck">
@ -755,6 +811,14 @@ const ZONE_LABEL: Record<string, string> = {
storage: '창고', storage: '창고',
}; };
/** 이력 시각(ISO-8601 UTC) → 로컬 "MM-DD HH:mm". 파싱 실패 시 원문 앞부분. */
function fmtHistoryDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 16).replace('T', ' ');
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function Accordion({ function Accordion({
title, title,
open, open,

View File

@ -52,6 +52,18 @@ export const reroomApi = {
), ),
}; };
/**
* G-09 (DONE) ( render_job ).
* endpoints.ts .
* GET /api/events/{eventId}/booths/{boothId}/render-history RenderJobDto[]()
*/
export const renderHistoryApi = {
list: (eventId: string, boothId: string) =>
api.get<RenderJobDto[]>(
`/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/render-history`,
),
};
/** /**
* (ReRoomAI Studio.handleImageFile ) 1024px, JPEG 0.85. * (ReRoomAI Studio.handleImageFile ) 1024px, JPEG 0.85.
* · · . Blob (). * · · . Blob ().

View File

@ -506,3 +506,47 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ── 렌더 이력 갤러리(G-09) ── */
.kx-studio__history {
margin: var(--space-4) var(--space-5) 0;
padding: var(--space-4);
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e8ef);
border-radius: var(--radius-lg);
}
.kx-studio__history-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.kx-studio__history-sub {
font-size: var(--fs-micro);
color: var(--color-neutral-500);
}
.kx-studio__history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
.kx-studio__history-item {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.kx-studio__history-cap {
font-size: var(--fs-nano);
color: var(--color-neutral-500);
}
@media (max-width: 760px) {
.kx-studio__history {
margin: var(--space-4) var(--space-4) 0;
}
.kx-studio__history-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}