전체 코드, 구조 검토 - P0 코드 수정 진행

This commit is contained in:
2026-07-04 10:13:12 +09:00
parent 854a3e2bfb
commit 3c5848cdc5
9 changed files with 339 additions and 101 deletions
+7 -1
View File
@@ -11,13 +11,15 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
## Critical rules ## Critical rules
- **Never alter the DB schema.** All tables are shared with the live chocomae service. - **Never alter the DB schema.** All tables are shared with the live chocomae service.
- **All mutating APIs must verify current `Status = 1` before acting** (idempotency guard, returns 409 if already processed). - **Race-condition guard for approve/cancel/reject**: use a conditional `updateMany({ where: { id, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: <target> } })` as the first step inside a transaction. If `count === 0`, check existence to return 404 vs 409. Never use read-then-write (findUnique → check Status → update) — it allows double-processing under concurrent requests.
- **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically. - **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically.
- **Send email only after a successful commit**, never inside the transaction. - **Send email only after a successful commit**, never inside the transaction.
- **Never use `include: { maestro: true }`** when querying `maestro_extension` or `maestro_upgrade`. Always use `select` with only the needed columns to avoid logging the maestro `Password` field.
- Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan. - Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan.
- Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year. - Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year.
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade. - Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`. - When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
## Authentication ## Authentication
@@ -74,6 +76,10 @@ Always record a log entry after a successful DB change. Use these `Type` values:
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`. - `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success. - `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes. - `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
- Phase 13 (security/correctness fixes):
- P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs.
- P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing.
- P0-3: `docker-compose.yml` and `docker-compose.stage.yml` now set `TZ: Asia/Seoul`; `lib/maestros.ts` date-change log remark uses `formatDate()` instead of `toISOString().slice(0,10)` for KST consistency.
## Planned phases (not yet implemented) ## Planned phases (not yet implemented)
+4 -2
View File
@@ -25,7 +25,8 @@ chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`) - 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`)
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`) - 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`)
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사) - 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 마스킹 - 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 컬럼 마스킹 (`Password`)
- 동시 승인 방지 — 연장/업그레이드 승인·취소·거절 시 조건부 원자 UPDATE로 이중 처리 차단
## 개발 환경 설정 ## 개발 환경 설정
@@ -86,6 +87,7 @@ pnpm dev
| `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) | | `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) |
| `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) | | `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) |
| `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) | | `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) |
| `MAIL_OVERRIDE_TO` | — | 설정 시 모든 메일 수신인을 이 주소로 고정 — local/stage 테스트용 (Phase 11) |
## 배포 (Synology NAS Docker) ## 배포 (Synology NAS Docker)
@@ -94,7 +96,7 @@ pnpm dev
docker compose up -d --build docker compose up -d --build
``` ```
`docker-compose.yml``environment` 섹션에 위 환경변수를 설정한다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다. `docker-compose.yml``environment` 섹션에 위 환경변수를 설정한다. `TZ: Asia/Seoul`은 두 compose 파일에 이미 포함되어 있다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
## 프로젝트 구조 ## 프로젝트 구조
+1
View File
@@ -14,6 +14,7 @@ services:
APP_ENV: stage APP_ENV: stage
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
TZ: Asia/Seoul
networks: networks:
- proxy-network - proxy-network
restart: unless-stopped restart: unless-stopped
+1
View File
@@ -13,6 +13,7 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
TZ: Asia/Seoul
networks: networks:
- proxy-network - proxy-network
restart: unless-stopped restart: unless-stopped
+187
View File
@@ -0,0 +1,187 @@
# chocoadmin 전체 검토 및 개선 계획
작성일: 2026-07-04
검토 범위: Phase 0–12 전체 구현 (인증, 마에스트로 목록/상세/수정, 연장/업그레이드 신청·승인, 이메일 발송, 로깅)
---
## 1. 요약
코드·로직·비즈니스 측면을 전수 검토한 결과, 데이터 파괴적인 치명 결함은 없으나
**(1) 로그를 통한 마에스트로 비밀번호 노출 가능성, (2) 동시 승인 이중 처리 가능성,
(3) 타임존 미확정으로 인한 만료일 오차** 세 가지가 우선 수정 대상이다.
그 외 체험(TRIAL) 계정 관련 비즈니스 규칙 공백과 트랜잭션/로그 기록 비일관성이 있다.
| 등급 | 건수 | 내용 |
|---|---|---|
| P0 (즉시 수정) | 3 | 비밀번호 로그 노출, 동시 승인 레이스, 타임존 정책 |
| P1 (다음 작업) | 7 | TRIAL 규칙 공백, 승인 시점 재검증, 트랜잭션/로그 일관성, 이메일 실패 처리 |
| P2 (보완) | 8 | 매직 넘버, 에러 응답 일관성, 세션 정책, 목록 기본 필터 등 |
---
## 2. P0 — 즉시 수정
### P0-1. 연장 승인 시 maestro 전체 컬럼 조회 → Password 로그 노출 위험
- 위치: `lib/extension-requests.ts:235` (`getActionTarget``include: { maestro: true }`)
- 문제: `maestro.Password`(char 50)까지 조회된다. `lib/db.ts`의 debug 레벨 결과 테이블 로깅이
조회 행 전체를 콘솔에 출력하므로, local/stage(debug 기본)에서 승인 처리 시
**마에스트로 비밀번호가 로그에 그대로 남는다.** phase9 마스킹 정책 위반.
- 대조: `lib/upgrade-requests.ts:253`은 이미 `select: { Name, Email }`로 제한하고 있어 비일관.
- 개선:
1. extension 쪽 `getActionTarget`을 upgrade와 동일하게 `select` 방식으로 변경
(필요 컬럼: 요청 전체 + `maestro.Name`, `Email`, `AvailableActivateDateTime`).
2. 방어선 추가: `lib/db.ts` 결과 로깅에서 `Password` 컬럼명을 만나면 `***` 마스킹.
### P0-2. 동시 승인 레이스 컨디션 (이중 승인 → +2년 연장)
- 위치: `lib/extension-requests.ts:116-157`, `lib/upgrade-requests.ts:123-168`
- 문제: 트랜잭션 안에서 `findUnique``Status = 1` 확인 후 `update`하는 read-then-write 패턴.
MariaDB 기본 격리수준(REPEATABLE READ)에서는 두 관리자가 동시에 승인 버튼을 누르면
둘 다 `Status = 1`을 읽고 둘 다 진행 → 연장이 두 번 적용(만료일 +2년), 로그·메일 2회 발생 가능.
- 개선(택1):
- A안(권장): 상태 전이를 조건부 UPDATE로 원자화.
`updateMany({ where: { MaestroExtensionID, Status: 1 }, data: { Status: 3 } })`
`count === 0`이면 409 throw. maestro 갱신은 그 뒤에 수행.
- B안: `$queryRaw``SELECT ... FOR UPDATE`로 요청 행 잠금 후 진행.
- 적용 대상: 연장 승인/취소, 업그레이드 승인/거절 4개 함수 모두.
### P0-3. 타임존 정책 미확정 — 만료일·이메일 날짜 오차
- 위치:
- `lib/utils.ts:93-105` `calculateExtendedAvailableDate``setHours(0,0,0,0)` — 서버 로컬 자정
- `lib/utils.ts:63-75` `formatDate` — 서버 로컬 기준
- `lib/upgrade-requests.ts:360-366` `calculateUpgradeAvailableDate`
- `docker-compose.yml` / `docker-compose.stage.yml``TZ` 환경변수 없음 → 컨테이너는 UTC
- 문제: 기존 chocomae(PHP)는 DB 서버 `NOW()`(KST 추정) 기준으로 날짜를 기록해 왔다.
chocoadmin 컨테이너가 UTC로 돌면:
- 연장 승인 시 "자정"이 UTC 자정(KST 09:00)으로 저장돼 기존 데이터와 9시간 어긋남.
- `formatDate`로 만든 이메일 표기 날짜가 KST 기준과 하루 차이 날 수 있음 (KST 00:0008:59 구간).
- `updateMaestro`의 날짜 변경 로그 remark는 `toISOString().slice(0,10)`(UTC)인데
다른 곳은 로컬 기준 — 같은 값이 화면/로그/메일에서 다르게 보일 수 있음 (`lib/maestros.ts:216-220`).
- 개선:
1. docker-compose 두 파일에 `TZ: Asia/Seoul` 명시 (앱 레벨 최소 변경으로 기존 동작 재현).
2. 날짜→문자열 변환을 한 가지 방식으로 통일: remark의 `toISOString().slice(0,10)`
`formatDate()`로 교체.
3. stage에서 승인 → DB 저장값 → 이메일 표기 날짜를 실측 검증 (phase11 잔여 항목과 함께).
---
## 3. P1 — 비즈니스 규칙·정합성
### P1-1. 체험(TRIAL) 상태 계정의 연장 신청 허용 문제
- 위치: `lib/extension-requests.ts:160-195` `createExtensionRequest`
- 문제: 유료 `AccountType`(1~5)만 검사하고 `ActivateStatus`는 검사하지 않는다.
체험 계정(ActivateStatus=1)도 AccountType 1~5를 가지므로(`TRIAL_ACCOUNT_TYPE_LABELS` 존재)
체험 계정에 연장 신청→승인하면 **결제 없이 ActivateStatus=2(활성) + 1년**이 부여된다.
승인 로직(`approveExtensionRequest`)에도 동일 검증이 없어 CANCELED(100) 계정도 재활성화된다.
- 결정 필요(비즈니스): 연장 대상을 `ActivateStatus = 2(활성)`로 제한할지,
만료/취소 계정 연장을 관리자 재량으로 허용할지.
- 활성 상태에서만 승인하도록 제한: 신청 생성과 승인 양쪽에서 `ActivateStatus` 검증 추가, 위반 시 400/409.
### P1-2. 체험 → 동일 요금제 유료 전환 신청 불가
- 위치: `lib/upgrade-requests.ts:184-186`, `UpgradeRequestsSection.tsx:42-43,110`
- 문제: `requestedAccountType <= maestro.AccountType`이면 거부한다.
그러나 체험 계정(ActivateStatus=1, AccountType=3)이 같은 3번 요금제를 유료 전환하는 것은
정당한 흐름이며, 가격 계산(`calculateAdditionalPrice`)과 완료 메일도 이미 이 케이스를 지원한다.
현재는 관리자 대행 등록이 불가능하다(같은 tier가 select에서 disabled + 서버 400).
- 개선: `ActivateStatus === TRIAL`이면 `requestedAccountType >= AccountType`(동일 tier 포함) 허용,
ACTIVE면 기존대로 `>`만 허용. UI select disabled 조건도 동일하게 분기.
### P1-3. 업그레이드 승인 시점의 재검증 없음
- 위치: `lib/upgrade-requests.ts:113-168` `approveUpgradeRequest`
- 문제: 신청 생성 시엔 상향 여부를 검증하지만 승인 시엔 하지 않는다.
신청 후 관리자가 정보 수정(Phase 10)으로 `AccountType`을 올려두면,
대기 중인 신청을 승인할 때 **사실상 다운그레이드**가 적용될 수 있다.
- 개선: 승인 트랜잭션 안에서 maestro의 현재 `AccountType`을 읽어
`RequestedAccountType`이 여전히 상향(또는 TRIAL 전환)인지 확인, 아니면 409 + 안내 메시지.
### P1-4. 연장 신청 등록이 트랜잭션 미사용 (일관성 위반)
- 위치: `lib/extension-requests.ts:160-195`
- 문제: maestro 조회 → insert가 트랜잭션 밖에서 수행된다. AGENTS 규칙
("모든 변경은 트랜잭션") 위반이며, `createUpgradeRequest`(트랜잭션 사용)와 비일관.
- 개선: `db.$transaction`으로 감싸 upgrade 쪽과 동일 구조로 정리.
### P1-5. 신청 등록 시 maestro_log 미기록
- 위치: `createExtensionRequest`, `createUpgradeRequest`
- 문제: 관리자 대행 신청 등록은 DB 변경인데 로그를 남기지 않는다.
("성공한 DB 변경 후 항상 로그 기록" 규칙 위반.) 나중에 신청 출처(마에스트로 본인 vs 관리자 대행)를
구분할 수 없다.
- 개선: 신규 타입 `request_extension_maestro` / `request_upgrade_maestro`(가칭)를 정의해
트랜잭션 안에서 기록. 타입 확정 후 AGENTS.md 로그 표에 추가.
### P1-6. 이메일 발송 실패가 관리자에게 보이지 않음
- 위치: `app/api/extension-requests/[id]/route.ts:43-47`, `app/api/upgrade-requests/[id]/route.ts:43-50`
- 문제: `void sendXxxDoneEmail(...)` fire-and-forget. 실패해도 로그만 남고
승인 응답은 `ok: true` → 관리자는 발송 성공으로 인지. 재발송 수단도 없다.
- 개선(단계적):
1. `await`로 결과를 받아 응답에 `emailSent: boolean` 포함, 실패 시 목록 UI에 경고 표시.
(커밋 후 발송이므로 트랜잭션 규칙과 충돌 없음. 지연은 SMTP 1회 호출 수준.)
2. (후속) 실패 건 재발송 버튼 또는 관리자 알림.
- 참고: 현재 Docker 상주 프로세스라 void 실행이 잘리지는 않지만, 배포 방식이 바뀌면
응답 후 프로세스 중단으로 발송 누락 가능 — await 전환으로 함께 해소된다.
### P1-7. 관리자 대행 신청 등록 시 입금 안내 메일 부재 (정책 확인)
- 문제: 기존 서비스는 마에스트로가 신청하면 입금 안내 메일을 발송한다.
관리자 대행 등록(Phase 12)은 메일을 보내지 않는데, 의도된 생략인지 확인이 안 됨.
- 확인: 기존 서비스와 달리 관리자가 대행해서 등록했을 경우 메일을 보내지 않는 것이 의도가 맞다.
- 개선: 정책 결정 후 문서화(AGENTS.md). 발송하기로 하면 기존 안내 메일 템플릿 이식.
---
## 4. P2 — 코드 품질·보완
| # | 항목 | 위치 | 내용 |
|---|---|---|---|
| P2-1 | 매직 넘버 | `lib/extension-requests.ts:125` | `ActivateStatus: 2``ACTIVATE_STATUSES.ACTIVE` 상수 사용 |
| P2-2 | 날짜 입력 silent 무시 | `lib/maestros.ts:207-227` | `availableActivateDateTime`이 파싱 불가면 조용히 건너뜀 → 400 반환이 명확 |
| P2-3 | datetime-local 파싱 TZ | `lib/maestros.ts:208` | `new Date("YYYY-MM-DDTHH:mm")`은 서버 TZ로 해석. 관리자 브라우저 TZ ≠ 서버 TZ면 저장값 이동. P0-3에서 TZ 고정 후 재확인 |
| P2-4 | 이름 중복 검사 TOCTOU | `lib/maestros.ts:153-161` | 검사와 update 사이 레이스. unique 제약이 없으므로(스키마 변경 금지) 검사를 트랜잭션 안으로 이동하는 정도로 완화. 이메일 중복 검사는 아예 없음 — 기존 서비스 정책 확인 필요 |
| P2-5 | 미확인 예외 rethrow | `lib/api-handler.ts:47-51` | 예상 외 오류가 Next 기본 500(HTML)으로 반환. JSON `{ message }` 500 응답으로 통일 |
| P2-6 | 세션 만료 기본 30일 | `auth.ts` | 관리자 도구 치고 길다. `session.maxAge` 단축(예: 8~24h) 검토 |
| P2-7 | 로그인 브루트포스 방어 없음 | `auth.ts:63-96` | rate limit 없음. 공개망 노출 시 실패 횟수 제한/지연 도입 검토. `PASSWORD()` 해시 마이그레이션은 기존 계획대로 별도 과제 |
| P2-8 | 목록 기본 필터 | `lib/extension-requests.ts:23-27` 등 | `status` 파라미터가 없으면 `optional`이 catch보다 먼저 적용돼 전체 표시. 기존 관리자 기본은 미처리(Status<2)였음 — 기본값을 `REQUESTED`로 할지 결정 (`.default(REQUEST_STATUSES.REQUESTED)`) |
| P2-9 | SQL 파라미터 인라인 로깅 | `lib/db.ts:15-29` | debug 한정이지만 이름/이메일 등 PII가 로그에 남음. stage에서 실데이터 사용 시 phase9 마스킹 규칙과 대조해 마스킹 대상 확정 |
| P2-10 | 연장 승인 시 요청 AccountType 불일치 | `lib/extension-requests.ts:140-150` | 신청 시점 AccountType으로 로그/메일 remark 생성 — 신청 후 요금제가 바뀌면 현재값과 다름. 승인 시 maestro 현재값 기준으로 표기할지 결정 |
---
## 5. 이행 순서 제안
1. **Step 1 (P0, 코드 수정 소규모)**
- P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹
- P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드
- P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일
2. **Step 2 (P1 비즈니스 결정 → 반영)**
- 결정 필요 3건: 연장 대상 ActivateStatus 제한(P1-1), 대행 등록 입금 메일(P1-7),
승인 시 remark 기준값(P2-10)
- 코드 반영: P1-2(TRIAL 동일 tier 허용), P1-3(승인 재검증), P1-4(트랜잭션), P1-5(신청 로그)
3. **Step 3 (P1-6 이메일 + 검증)**
- emailSent 응답 + UI 경고, stage에서 연장/업그레이드(체험→유료, 유료→유료) 메일 실측
(phase11 잔여 항목 겸)
4. **Step 4 (P2 일괄)**
- 소규모 항목 묶음 처리 후 AGENTS.md / phase 문서 갱신
각 Step 완료 시 AGENTS.md의 규칙 표(로그 타입, 검증 규칙)를 함께 갱신한다.
---
## 6. 검토했으나 문제 없음으로 확인한 사항
- 승인/취소/거절의 트랜잭션 구성과 `Status=1` 사전 확인(단일 요청 기준)은 규칙대로 구현됨.
- 업그레이드 승인이 `PlayerCount`를 변경하지 않음 — 규칙 준수.
- 메일 발송은 커밋 이후 수행 — 규칙 준수.
- 모든 API 라우트가 `withApiHandler``auth()` 검증 — 인증 우회 경로 없음
(`proxy.ts` matcher가 `/api/`를 제외하지만 핸들러 레벨에서 401 처리됨).
- 로그인 쿼리는 태그드 템플릿 파라미터 바인딩 사용 — SQL 인젝션 없음.
`PASSWORD(` 포함 쿼리는 SQL 로깅에서 파라미터 인라인을 생략함.
- 검색 파라미터는 zod `.catch()`로 방어되어 잘못된 입력이 500을 유발하지 않음.
- NextAuth 쿠키 SameSite=Lax + same-origin fetch 구조로 CSRF 위험은 낮음.
+8 -2
View File
@@ -58,6 +58,12 @@ function onQuery(e: Prisma.QueryEvent): void {
const COL_MAX_WIDTH = 40; const COL_MAX_WIDTH = 40;
const SENSITIVE_KEYS = new Set(["Password", "password"]);
function safeValue(key: string, value: unknown): string {
return SENSITIVE_KEYS.has(key) ? "***" : formatValue(value);
}
function formatValue(v: unknown): string { function formatValue(v: unknown): string {
if (v === null || v === undefined) return "NULL"; if (v === null || v === undefined) return "NULL";
if (typeof v === "bigint") return v.toString(); if (typeof v === "bigint") return v.toString();
@@ -165,7 +171,7 @@ function buildResultLog(
if (typeof preview[0] === "object" && preview[0] !== null) { if (typeof preview[0] === "object" && preview[0] !== null) {
const objRows = preview as Record<string, unknown>[]; const objRows = preview as Record<string, unknown>[];
const headers = Object.keys(objRows[0]); const headers = Object.keys(objRows[0]);
const rows = objRows.map((row) => headers.map((h) => formatValue(row[h]))); const rows = objRows.map((row) => headers.map((h) => safeValue(h, row[h])));
return `${label}${countLabel}\n${renderTable(headers, rows)}${moreLabel}`; return `${label}${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
} }
@@ -176,7 +182,7 @@ function buildResultLog(
if (result !== null && typeof result === "object") { if (result !== null && typeof result === "object") {
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [ const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
k, k,
formatValue(v), safeValue(k, v),
]); ]);
return `${label}\n${renderTable(["field", "value"], rows)}`; return `${label}\n${renderTable(["field", "value"], rows)}`;
} }
+64 -41
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors"; import { ApiError } from "@/lib/errors";
import { PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants"; import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client"; import type { Prisma } from "@/lib/generated/prisma/client";
import { import {
calculateExtendedAvailableDate, calculateExtendedAvailableDate,
@@ -114,45 +114,70 @@ export async function approveExtensionRequest(
maestroEmail: string; maestroEmail: string;
}> { }> {
return db.$transaction(async (tx) => { return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID); // Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_extension.updateMany({
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_extension.count({
where: { MaestroExtensionID: maestroExtensionID },
});
throw new ApiError(
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
select: {
MaestroID: true,
AccountType: true,
maestro: {
select: {
Name: true,
Email: true,
AvailableActivateDateTime: true,
},
},
},
});
const availableActivateDateTime = calculateExtendedAvailableDate( const availableActivateDateTime = calculateExtendedAvailableDate(
request.maestro.AvailableActivateDateTime request!.maestro.AvailableActivateDateTime
); );
await tx.maestro.update({ await tx.maestro.update({
where: { MaestroID: request.MaestroID }, where: { MaestroID: request!.MaestroID },
data: { data: {
ActivateStatus: 2, ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime, AvailableActivateDateTime: availableActivateDateTime,
}, },
}); });
await tx.maestro_extension.updateMany({ await tx.maestro_extension.updateMany({
where: { where: {
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED, Status: REQUEST_STATUSES.REQUESTED,
}, },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
await tx.maestro_extension.update({
where: { MaestroExtensionID: request.MaestroExtensionID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "extension_maestro", Type: "extension_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildExtensionLogRemark( Remark: buildExtensionLogRemark(
request.maestro.Name, request!.maestro.Name,
request.AccountType request!.AccountType
), ),
}, },
}); });
return { return {
availableActivateDateTime: availableActivateDateTime.toISOString(), availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(), maestroName: request!.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(), maestroEmail: request!.maestro.Email.trim(),
}; };
}); });
} }
@@ -198,20 +223,37 @@ export async function cancelExtensionRequest(
maestroExtensionID: number maestroExtensionID: number
): Promise<void> { ): Promise<void> {
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID); const claimed = await tx.maestro_extension.updateMany({
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
await tx.maestro_extension.update({
where: { MaestroExtensionID: request.MaestroExtensionID },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
if (claimed.count === 0) {
const exists = await tx.maestro_extension.count({
where: { MaestroExtensionID: maestroExtensionID },
});
throw new ApiError(
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
select: {
MaestroID: true,
AccountType: true,
maestro: { select: { Name: true } },
},
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "cancel_extension_maestro", Type: "cancel_extension_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildExtensionLogRemark( Remark: buildExtensionLogRemark(
request.maestro.Name, request!.maestro.Name,
request.AccountType request!.AccountType
), ),
}, },
}); });
@@ -226,25 +268,6 @@ export function parseExtensionRequestSearchParams(
); );
} }
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroExtensionID: number
) {
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
include: { maestro: true },
});
if (!request) {
throw new ApiError("연장 신청을 찾을 수 없습니다.", 404);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new ApiError("이미 처리된 연장 신청입니다.", 409);
}
return request;
}
function buildExtensionRequestWhere( function buildExtensionRequestWhere(
params: ExtensionRequestSearchParams params: ExtensionRequestSearchParams
+3 -5
View File
@@ -5,6 +5,7 @@ import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors"; import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants"; import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client"; import type { Prisma } from "@/lib/generated/prisma/client";
import { formatDate } from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const; const pageSizeOptions = [10, 20, 50, 100] as const;
const sortOptions = [ const sortOptions = [
@@ -213,11 +214,8 @@ export async function updateMaestro(
const newMin = Math.floor(newDate.getTime() / 60000); const newMin = Math.floor(newDate.getTime() / 60000);
if (prevMin !== newMin) { if (prevMin !== newMin) {
updates.AvailableActivateDateTime = newDate; updates.AvailableActivateDateTime = newDate;
const prevStr = maestro.AvailableActivateDateTime.toISOString().slice( const prevStr = formatDate(maestro.AvailableActivateDateTime);
0, const newStr = formatDate(newDate);
10
);
const newStr = newDate.toISOString().slice(0, 10);
logs.push({ logs.push({
type: "update_maestro_available_date", type: "update_maestro_available_date",
remark: `${prevStr} -> ${newStr}`, remark: `${prevStr} -> ${newStr}`,
+64 -50
View File
@@ -121,48 +121,69 @@ export async function approveUpgradeRequest(
requestedAccountType: number; requestedAccountType: number;
}> { }> {
return db.$transaction(async (tx) => { return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID); // Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_upgrade.updateMany({
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_upgrade.count({
where: { MaestroUpgradeID: maestroUpgradeID },
});
throw new ApiError(
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
select: {
MaestroID: true,
RequestedAccountType: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
maestro: { select: { Name: true, Email: true } },
},
});
const availableActivateDateTime = calculateUpgradeAvailableDate(); const availableActivateDateTime = calculateUpgradeAvailableDate();
await tx.maestro.update({ await tx.maestro.update({
where: { MaestroID: request.MaestroID }, where: { MaestroID: request!.MaestroID },
data: { data: {
AccountType: request.RequestedAccountType, AccountType: request!.RequestedAccountType,
ActivateStatus: ACTIVATE_STATUSES.ACTIVE, ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime, AvailableActivateDateTime: availableActivateDateTime,
}, },
}); });
await tx.maestro_upgrade.updateMany({ await tx.maestro_upgrade.updateMany({
where: { where: {
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED, Status: REQUEST_STATUSES.REQUESTED,
}, },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
await tx.maestro_upgrade.update({
where: { MaestroUpgradeID: request.MaestroUpgradeID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "upgrade_maestro", Type: "upgrade_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildUpgradeLogRemark( Remark: buildUpgradeLogRemark(
request.RegisteredActivateStatus, request!.RegisteredActivateStatus,
request.RegisteredAccountType, request!.RegisteredAccountType,
request.RequestedAccountType request!.RequestedAccountType
), ),
}, },
}); });
return { return {
availableActivateDateTime: availableActivateDateTime.toISOString(), availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(), maestroName: request!.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(), maestroEmail: request!.maestro.Email.trim(),
registeredActivateStatus: request.RegisteredActivateStatus, registeredActivateStatus: request!.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType, registeredAccountType: request!.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType, requestedAccountType: request!.RequestedAccountType,
}; };
}); });
} }
@@ -215,21 +236,39 @@ export async function rejectUpgradeRequest(
maestroUpgradeID: number maestroUpgradeID: number
): Promise<void> { ): Promise<void> {
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID); const claimed = await tx.maestro_upgrade.updateMany({
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
await tx.maestro_upgrade.update({
where: { MaestroUpgradeID: request.MaestroUpgradeID },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
if (claimed.count === 0) {
const exists = await tx.maestro_upgrade.count({
where: { MaestroUpgradeID: maestroUpgradeID },
});
throw new ApiError(
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
select: {
MaestroID: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
RequestedAccountType: true,
},
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "reject_upgrade_maestro", Type: "reject_upgrade_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildUpgradeLogRemark( Remark: buildUpgradeLogRemark(
request.RegisteredActivateStatus, request!.RegisteredActivateStatus,
request.RegisteredAccountType, request!.RegisteredAccountType,
request.RequestedAccountType request!.RequestedAccountType
), ),
}, },
}); });
@@ -244,31 +283,6 @@ export function parseUpgradeRequestSearchParams(
); );
} }
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroUpgradeID: number
) {
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
include: { maestro: { select: { Name: true, Email: true } } },
});
if (!request) {
throw new ApiError(
"업그레이드 신청을 찾을 수 없습니다.",
404
);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new ApiError(
"이미 처리된 업그레이드 신청입니다.",
409
);
}
return request;
}
function buildUpgradeRequestWhere( function buildUpgradeRequestWhere(
params: UpgradeRequestSearchParams params: UpgradeRequestSearchParams