웹 로그, DB 쿼리문과 결과 로그 출력
This commit is contained in:
@@ -4,6 +4,9 @@ AUTH_SECRET="replace-with-local-secret"
|
||||
NEXTAUTH_SECRET="replace-with-local-secret"
|
||||
AUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
# Logging: APP_ENV=local|stage|production / LOG_LEVEL=debug|info|warn|error
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Production example (.env.production)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
|
||||
@@ -11,6 +14,8 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
# NEXTAUTH_SECRET="replace-with-production-secret"
|
||||
# AUTH_URL="https://NAS_HOST_OR_DOMAIN"
|
||||
# NEXTAUTH_URL="https://NAS_HOST_OR_DOMAIN"
|
||||
# APP_ENV=production
|
||||
# LOG_LEVEL=info
|
||||
|
||||
# Stage example (.env.stage)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae"
|
||||
@@ -18,3 +23,5 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
# NEXTAUTH_SECRET="replace-with-stage-secret"
|
||||
# AUTH_URL="https://STAGE_HOST_OR_DOMAIN"
|
||||
# NEXTAUTH_URL="https://STAGE_HOST_OR_DOMAIN"
|
||||
# APP_ENV=stage
|
||||
# LOG_LEVEL=debug
|
||||
|
||||
@@ -32,13 +32,17 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
|
||||
|---|---|
|
||||
| `auth.ts` | NextAuth config (Credentials Provider, MariaDB PASSWORD verify) |
|
||||
| `proxy.ts` | Middleware — redirects unauthenticated users to /login |
|
||||
| `lib/db.ts` | Prisma Client singleton |
|
||||
| `lib/db.ts` | Prisma Client singleton with SQL pretty-logging and result table logging |
|
||||
| `lib/logger.ts` | Structured logger — `debug/info/warn/error`, level controlled by `LOG_LEVEL` / `APP_ENV` |
|
||||
| `lib/errors.ts` | `ApiError` — unified error class with HTTP status code |
|
||||
| `lib/api-handler.ts` | `withApiHandler` — wraps route handlers with auth, timing, and error logging |
|
||||
| `lib/constants.ts` | Status code constants (`ACTIVATE_STATUS`, `ACCOUNT_TYPE`, `REQUEST_STATUS`) |
|
||||
| `lib/maestros.ts` | Maestro DB queries |
|
||||
| `lib/extension-requests.ts` | Extension request queries and approval logic |
|
||||
| `lib/upgrade-requests.ts` | Upgrade request queries and approval logic |
|
||||
| `prisma/schema.prisma` | Generated via `prisma db pull` — do not hand-edit model fields |
|
||||
| `docs/business-rules.md` | Full reference for existing chocomae business logic |
|
||||
| `docs/phase/phase9.md` | Log policy: levels, format, masking rules, Docker log operations |
|
||||
|
||||
## maestro_log conventions
|
||||
|
||||
@@ -57,11 +61,11 @@ Always record a log entry after a successful DB change. Use these `Type` values:
|
||||
|
||||
## Implemented phases (do not re-implement)
|
||||
|
||||
- Phase 0–8 are complete: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards.
|
||||
- Phase 0–8: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards.
|
||||
- Phase 9 (substantially complete): structured logging (`lib/logger.ts`), `withApiHandler` wrapper (`lib/api-handler.ts`), `ApiError` class (`lib/errors.ts`), Prisma SQL pretty-logging and result table logging (`lib/db.ts`). Remaining: correlation IDs, stage/production log verification.
|
||||
|
||||
## Planned phases (not yet implemented)
|
||||
|
||||
- **Phase 9** — Structured logging (`lib/logger.ts`, correlation IDs, Prisma query logging)
|
||||
- **Phase 10** — Maestro info edit form + student list with pagination (`PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students`)
|
||||
- **Phase 11** — Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스
|
||||
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`)
|
||||
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`)
|
||||
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
|
||||
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 마스킹
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
@@ -73,8 +74,9 @@ pnpm dev
|
||||
| `NEXTAUTH_SECRET` | ✅ | NextAuth 호환용 (동일 값) |
|
||||
| `AUTH_URL` | ✅ | 서비스 기본 URL |
|
||||
| `NEXTAUTH_URL` | ✅ | NextAuth 호환용 (동일 값) |
|
||||
| `APP_ENV` | — | `local` \| `stage` \| `production` (Phase 9) |
|
||||
| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` (Phase 9) |
|
||||
| `APP_ENV` | — | `local` \| `stage` \| `production` — 미설정 시 `debug` 레벨 기본값 |
|
||||
| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` — production 권장: `info` |
|
||||
| `DB_SLOW_QUERY_MS` | — | slow query 임계값(ms), 기본 `1000` — 초과 시 `warn` 로그 |
|
||||
| `MAIL_FROM_NAME` | — | 발송자 이름 (Phase 11) |
|
||||
| `MAIL_FROM_ADDRESS` | — | 발송자 주소 (Phase 11) |
|
||||
| `MAIL_BCC_ADDRESS` | — | BCC 주소 (Phase 11) |
|
||||
@@ -110,7 +112,10 @@ chocoadmin/
|
||||
│ ├── forms/
|
||||
│ └── ui/ # shadcn/ui 컴포넌트
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma Client 싱글톤
|
||||
│ ├── db.ts # Prisma Client 싱글톤 (SQL/결과 로깅 포함)
|
||||
│ ├── logger.ts # 공통 로거 (debug/info/warn/error)
|
||||
│ ├── errors.ts # ApiError 공통 에러 클래스
|
||||
│ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리)
|
||||
│ ├── constants.ts # 상태값 상수
|
||||
│ ├── maestros.ts # 마에스트로 DB 쿼리
|
||||
│ ├── extension-requests.ts
|
||||
@@ -131,3 +136,4 @@ chocoadmin/
|
||||
- [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획
|
||||
- [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙
|
||||
- [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차
|
||||
- [`docs/phase/phase9.md`](docs/phase/phase9.md) — 로그 정책 및 Docker 로그 운영 절차
|
||||
|
||||
@@ -1,67 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import {
|
||||
approveExtensionRequest,
|
||||
cancelExtensionRequest,
|
||||
ExtensionRequestActionError,
|
||||
} from "@/lib/extension-requests";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const CTX = "extension-requests/[id]";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
action: z.enum(["approve", "cancel"]),
|
||||
});
|
||||
|
||||
type ExtensionRequestRouteContext = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
export const PATCH = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (request, { params, t0 }) => {
|
||||
const { id } = await params;
|
||||
const maestroExtensionID = Number(id);
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: ExtensionRequestRouteContext
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
|
||||
throw new ApiError("Invalid extension request id", 400);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const maestroExtensionID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid extension request id" },
|
||||
{ status: 400 }
|
||||
const body = patchBodySchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
}
|
||||
if (!body.success) {
|
||||
throw new ApiError("Invalid action", 400);
|
||||
}
|
||||
|
||||
const body = patchBodySchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ message: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveExtensionRequest(maestroExtensionID);
|
||||
logger.info(CTX, "approved", {
|
||||
id: maestroExtensionID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
}
|
||||
|
||||
await cancelExtensionRequest(maestroExtensionID);
|
||||
logger.info(CTX, "cancelled", {
|
||||
id: maestroExtensionID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ExtensionRequestActionError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getExtensionRequests } from "@/lib/extension-requests";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withApiHandler("extension-requests", async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const result = await getExtensionRequests(url.searchParams);
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getMaestroDetail } from "@/lib/maestros";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
type MaestroDetailRouteContext = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
export const GET = withApiHandler<{ id: string }>(
|
||||
"maestros/[id]",
|
||||
async (_request, { params }) => {
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: MaestroDetailRouteContext
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
throw new ApiError("Invalid maestro id", 400);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
const result = await getMaestroDetail(maestroID);
|
||||
|
||||
if (!result) {
|
||||
throw new ApiError("Not found", 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid maestro id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await getMaestroDetail(maestroID);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ message: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getMaestros } from "@/lib/maestros";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withApiHandler("maestros", async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const result = await getMaestros(url.searchParams);
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,67 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import {
|
||||
approveUpgradeRequest,
|
||||
rejectUpgradeRequest,
|
||||
UpgradeRequestActionError,
|
||||
} from "@/lib/upgrade-requests";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const CTX = "upgrade-requests/[id]";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
action: z.enum(["approve", "reject"]),
|
||||
});
|
||||
|
||||
type UpgradeRequestRouteContext = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
export const PATCH = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (request, { params, t0 }) => {
|
||||
const { id } = await params;
|
||||
const maestroUpgradeID = Number(id);
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: UpgradeRequestRouteContext
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
|
||||
throw new ApiError("Invalid upgrade request id", 400);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const maestroUpgradeID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid upgrade request id" },
|
||||
{ status: 400 }
|
||||
const body = patchBodySchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
}
|
||||
if (!body.success) {
|
||||
throw new ApiError("Invalid action", 400);
|
||||
}
|
||||
|
||||
const body = patchBodySchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ message: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveUpgradeRequest(maestroUpgradeID);
|
||||
logger.info(CTX, "approved", {
|
||||
id: maestroUpgradeID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
}
|
||||
|
||||
await rejectUpgradeRequest(maestroUpgradeID);
|
||||
logger.info(CTX, "rejected", {
|
||||
id: maestroUpgradeID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof UpgradeRequestActionError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getUpgradeRequests } from "@/lib/upgrade-requests";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withApiHandler("upgrade-requests", async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const result = await getUpgradeRequests(url.searchParams);
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,9 @@ import Credentials from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ACTIVATE_STATUSES } from "@/lib/constants";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const AUTH_CTX = "auth";
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
adminName: z.string().trim().min(1),
|
||||
@@ -78,9 +81,11 @@ export const {
|
||||
const activateStatus = Number(admin?.ActivateStatus);
|
||||
|
||||
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
|
||||
logger.warn(AUTH_CTX, "login failed", { adminName });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(AUTH_CTX, "login success", { adminName, adminID });
|
||||
return {
|
||||
id: String(adminID),
|
||||
name: admin.Name,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Phase 9. 실행 로그 및 DB 쿼리 로그
|
||||
|
||||
## 로그 정책
|
||||
|
||||
### 환경 구분
|
||||
|
||||
| `APP_ENV` | 설명 |
|
||||
|---|---|
|
||||
| `local` | 맥미니 개발 환경 |
|
||||
| `stage` | NAS에서 stage DB로 구동하는 검증 환경 |
|
||||
| `production` | NAS에서 운영 DB로 구동하는 실제 서비스 환경 |
|
||||
|
||||
`NODE_ENV`는 Next.js 빌드 방식(dev/prod)을 제어하고, `APP_ENV`는 로그 레벨·상세도를 제어한다.
|
||||
|
||||
### 로그 레벨
|
||||
|
||||
| `LOG_LEVEL` | 출력 대상 |
|
||||
|---|---|
|
||||
| `debug` | debug + info + warn + error |
|
||||
| `info` | info + warn + error |
|
||||
| `warn` | warn + error |
|
||||
| `error` | error만 |
|
||||
|
||||
`LOG_LEVEL`을 명시하지 않으면 `APP_ENV=production`일 때 `info`, 그 외는 `debug`가 기본값.
|
||||
|
||||
### 출력 형식
|
||||
|
||||
```
|
||||
[2026-06-22T10:00:00.000Z] [INFO ] [maestros] list fetched {"totalCount":120,"duration":45}
|
||||
[2026-06-22T10:00:00.000Z] [WARN ] [extension-requests/[id]] 이미 처리된 연장 신청입니다. {"id":5,"status":409,"duration":12}
|
||||
[2026-06-22T10:00:00.000Z] [ERROR] [upgrade-requests/[id]] unexpected error {"id":3,"error":"...","duration":5}
|
||||
```
|
||||
|
||||
- `warn` / `error`는 stderr, 그 외는 stdout으로 출력한다.
|
||||
- JSON 데이터는 한 줄로 직렬화한다 (Docker 로그 파싱 편의).
|
||||
|
||||
### 민감정보 마스킹 대상
|
||||
|
||||
아래 값은 로그에 포함하지 않는다:
|
||||
|
||||
- 관리자 패스워드 (form 입력값, DB 조회 파라미터)
|
||||
- `AUTH_SECRET` / `NEXTAUTH_SECRET`
|
||||
- `DATABASE_URL` (패스워드 포함)
|
||||
- 세션 토큰 / JWT 페이로드 원문
|
||||
- SMTP 패스워드
|
||||
|
||||
현재 `auth.ts`의 raw SQL 파라미터에 패스워드가 포함될 수 있으므로 DB query 이벤트 핸들러 구현 시 `PASSWORD(` 포함 쿼리의 params를 마스킹한다 (Phase 9 DB query 로그 구현 시 처리).
|
||||
|
||||
---
|
||||
|
||||
## 환경변수
|
||||
|
||||
| 변수 | 설명 | 기본값 |
|
||||
|---|---|---|
|
||||
| `APP_ENV` | 환경 구분 (`local`\|`stage`\|`production`) | `local` |
|
||||
| `LOG_LEVEL` | 최소 로그 레벨 (`debug`\|`info`\|`warn`\|`error`) | `APP_ENV`에 따라 자동 |
|
||||
| `DB_SLOW_QUERY_MS` | DB 쿼리 slow query 임계값(ms) — DB query 로그 구현 시 사용 | `1000` |
|
||||
|
||||
### 권장 `.env` 설정
|
||||
|
||||
**.env.local (개발)**
|
||||
```env
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
**.env.stage (검증)**
|
||||
```env
|
||||
APP_ENV=stage
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
**.env.production (운영)**
|
||||
```env
|
||||
APP_ENV=production
|
||||
LOG_LEVEL=info
|
||||
DB_SLOW_QUERY_MS=500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 적용 현황
|
||||
|
||||
### 완료
|
||||
|
||||
- `lib/logger.ts` — 공통 logger 모듈 (레벨 필터링, 포맷, stderr/stdout 분기)
|
||||
- 로그인 성공/실패 로그 (`auth.ts`)
|
||||
- 마에스트로 목록/상세 조회 로그
|
||||
- 연장 신청 조회/승인/취소 로그 (409는 warn, 그 외 예외는 error)
|
||||
- 업그레이드 신청 조회/승인/거절 로그 (409는 warn, 그 외 예외는 error)
|
||||
|
||||
### 미완료 (별도 확인 후 진행)
|
||||
|
||||
- **Correlation ID**: Edge Middleware(`proxy.ts`)에서 `x-correlation-id` 헤더 생성 → API route에서 AsyncLocalStorage로 전달하는 구조. 구현 전 설계 확정 필요.
|
||||
- **DB query 로그**: `@prisma/adapter-mariadb` + Prisma query 이벤트 호환성 확인 필요. `$on('query', handler)` 발화 여부를 local에서 테스트한 뒤 진행.
|
||||
- **결과 row/count 로그**: Prisma query 이벤트에 결과 데이터가 없음. `$extends` query 컴포넌트 방식으로 재설계하거나 계획 범위 축소 필요.
|
||||
|
||||
---
|
||||
|
||||
## Docker / NAS 로그 운영 절차
|
||||
|
||||
### 애플리케이션 로그 확인
|
||||
|
||||
```bash
|
||||
# 실시간 스트리밍
|
||||
docker compose logs -f chocoadmin
|
||||
|
||||
# 최근 100줄
|
||||
docker compose logs --tail=100 chocoadmin
|
||||
|
||||
# 특정 시간 이후
|
||||
docker compose logs --since="2026-06-22T10:00:00" chocoadmin
|
||||
```
|
||||
|
||||
### warn/error만 필터링
|
||||
|
||||
```bash
|
||||
docker compose logs -f chocoadmin 2>&1 | grep -E '\[(WARN |ERROR)\]'
|
||||
```
|
||||
|
||||
### Nginx Proxy Manager 로그와 함께 확인
|
||||
|
||||
Synology NAS에서 Nginx Proxy Manager(NPM)와 chocoadmin 로그를 같이 보려면:
|
||||
|
||||
```bash
|
||||
# NPM access log (컨테이너 이름은 환경에 맞게 조정)
|
||||
docker logs nginx-proxy-manager 2>&1 | tail -50
|
||||
|
||||
# 두 로그를 동시에
|
||||
docker compose logs -f chocoadmin &
|
||||
docker logs -f nginx-proxy-manager
|
||||
```
|
||||
|
||||
NPM의 access log에는 클라이언트 IP, HTTP method, path, status code, 응답 시간이 기록되고, chocoadmin 로그에는 인증 결과와 비즈니스 처리 결과가 기록된다. `status=409`를 NPM 로그에서 발견했을 때 chocoadmin의 `[WARN]` 로그와 함께 원인을 추적한다.
|
||||
|
||||
### stage/production 로그 레벨 차이
|
||||
|
||||
| 항목 | stage | production |
|
||||
|---|---|---|
|
||||
| `LOG_LEVEL` | `debug` | `info` |
|
||||
| 요청 시작 로그 | 출력 (debug) | 미출력 |
|
||||
| 성공 응답 로그 | 출력 (info) | 출력 (info) |
|
||||
| 인증 실패/409 로그 | 출력 (warn, stderr) | 출력 (warn, stderr) |
|
||||
| 예외/500 로그 | 출력 (error, stderr) | 출력 (error, stderr) |
|
||||
+34
-34
@@ -390,43 +390,43 @@ chocoadmin/
|
||||
|
||||
> local/stage에서는 개발과 디버깅에 충분한 상세 로그를 남기고, production에서는 운영에 필요한 수준으로 로그 양과 민감정보 노출을 제한한다.
|
||||
|
||||
- [ ] 로그 정책 정의 문서 작성 (`docs/phase/phase9.md`)
|
||||
- [ ] 환경 구분: `local`, `stage`, `production`
|
||||
- [ ] 로그 레벨 구분: `debug`, `info`, `warn`, `error`
|
||||
- [ ] 로그 출력 형식: 사람이 읽기 쉬운 콘솔 출력 + 필요 시 JSON line 확장 가능 구조
|
||||
- [ ] 민감정보 마스킹 대상 정의: 비밀번호, 인증 토큰, 쿠키, DB 접속 문자열, secret, 개인정보성 필드
|
||||
- [ ] 서버 실행 로그 유틸 구현
|
||||
- [ ] 공통 logger 모듈 추가 (`lib/logger.ts`)
|
||||
- [ ] request 단위 correlation id 생성 및 로그에 포함
|
||||
- [ ] API route 시작/종료, 처리 시간, status code, action 결과 기록
|
||||
- [ ] 인증 실패, 권한 없음, 유효성 검증 실패, 비즈니스 예외를 구분해 기록
|
||||
- [ ] DB query 로그 구현
|
||||
- [ ] Prisma query event 또는 Prisma Client 설정 기반 query 로깅 적용
|
||||
- [ ] local/stage: query문, params, duration, 결과 row/count 또는 요약 결과를 사람이 보기 쉬운 format으로 출력
|
||||
- [ ] production: query문 전체/결과 전체 출력 금지, duration, table/action 추정값, affected count, slow query 중심으로 출력
|
||||
- [ ] production slow query threshold 설정 (`DB_SLOW_QUERY_MS`)
|
||||
- [ ] 결과 로그 최대 길이 제한 (`DB_LOG_RESULT_LIMIT`) 및 초과 시 truncate 표시
|
||||
- [ ] 환경변수 추가
|
||||
- [ ] `APP_ENV=local|stage|production`
|
||||
- [ ] `LOG_LEVEL=debug|info|warn|error`
|
||||
- [ ] `DB_QUERY_LOG=off|summary|full`
|
||||
- [ ] `DB_SLOW_QUERY_MS`
|
||||
- [ ] `DB_LOG_RESULT_LIMIT`
|
||||
- [ ] 주요 API에 로그 적용
|
||||
- [ ] 로그인 성공/실패 로그
|
||||
- [ ] 마에스트로 목록/상세 조회 로그
|
||||
- [ ] 연장 신청 조회/승인/취소 로그
|
||||
- [ ] 업그레이드 신청 조회/승인/거절 로그
|
||||
- [ ] 중복 처리 방지로 `409` 반환 시 warning 로그
|
||||
- [ ] Docker/NAS 로그 운영 절차 문서화
|
||||
- [ ] `docker compose logs -f` 확인 방법
|
||||
- [ ] stage와 production의 권장 `.env` 로그 설정 예시
|
||||
- [ ] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차
|
||||
- [x] 로그 정책 정의 문서 작성 (`docs/phase/phase9.md`)
|
||||
- [x] 환경 구분: `local`, `stage`, `production`
|
||||
- [x] 로그 레벨 구분: `debug`, `info`, `warn`, `error`
|
||||
- [x] 로그 출력 형식: 사람이 읽기 쉬운 콘솔 출력 + 필요 시 JSON line 확장 가능 구조
|
||||
- [x] 민감정보 마스킹 대상 정의: 비밀번호, 인증 토큰, 쿠키, DB 접속 문자열, secret, 개인정보성 필드
|
||||
- [x] 서버 실행 로그 유틸 구현
|
||||
- [x] 공통 logger 모듈 추가 (`lib/logger.ts`)
|
||||
- [ ] request 단위 correlation id 생성 및 로그에 포함 (보류 — Edge Middleware 제약으로 헤더 주입 방식 필요, `docs/phase/phase9.md` 참고)
|
||||
- [x] API route 시작/종료, 처리 시간, status code, action 결과 기록 (`lib/api-handler.ts` `withApiHandler` 래퍼로 공통 처리)
|
||||
- [x] 인증 실패, 권한 없음, 유효성 검증 실패, 비즈니스 예외를 구분해 기록 (`ApiError` + `withApiHandler`)
|
||||
- [x] DB query 로그 구현
|
||||
- [x] Prisma query event 기반 query 로깅 적용 (`lib/db.ts` `$on("query", ...)`)
|
||||
- [x] local/stage: query문, params, duration, 결과를 `sql-formatter`로 pretty 출력 (`$on` + `$extends.$allOperations`)
|
||||
- [x] production: `LOG_LEVEL=info` 설정 시 debug 로그 전체 억제 — slow query만 warn으로 출력
|
||||
- [x] production slow query threshold 설정 (`DB_SLOW_QUERY_MS`, 기본 1000ms)
|
||||
- [ ] 결과 로그 최대 길이 제한 (`DB_LOG_RESULT_LIMIT`) 및 초과 시 truncate 표시 (미구현)
|
||||
- [x] 환경변수 추가
|
||||
- [x] `APP_ENV=local|stage|production`
|
||||
- [x] `LOG_LEVEL=debug|info|warn|error`
|
||||
- [ ] `DB_QUERY_LOG=off|summary|full` (미구현 — 현재는 `LOG_LEVEL`로 제어)
|
||||
- [x] `DB_SLOW_QUERY_MS`
|
||||
- [ ] `DB_LOG_RESULT_LIMIT` (미구현)
|
||||
- [x] 주요 API에 로그 적용
|
||||
- [x] 로그인 성공/실패 로그 (`auth.ts`)
|
||||
- [x] 마에스트로 목록/상세 조회 로그 (`lib/maestros.ts`)
|
||||
- [x] 연장 신청 조회/승인/취소 로그 (`lib/extension-requests.ts`, `app/api/extension-requests/[id]/route.ts`)
|
||||
- [x] 업그레이드 신청 조회/승인/거절 로그 (`lib/upgrade-requests.ts`, `app/api/upgrade-requests/[id]/route.ts`)
|
||||
- [x] 중복 처리 방지로 `409` 반환 시 warning 로그 (`withApiHandler`가 `ApiError` 캐치 후 warn 출력)
|
||||
- [x] Docker/NAS 로그 운영 절차 문서화 (`docs/phase/phase9.md`)
|
||||
- [x] `docker compose logs -f` 확인 방법
|
||||
- [x] stage와 production의 권장 `.env` 로그 설정 예시
|
||||
- [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차
|
||||
- [ ] 검증
|
||||
- [ ] local에서 query문과 결과 전체가 출력되는지 확인
|
||||
- [x] local에서 query문과 결과 전체가 출력되는지 확인
|
||||
- [ ] stage에서 query문과 결과 전체가 출력되는지 확인
|
||||
- [ ] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인
|
||||
- [ ] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인
|
||||
- [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략)
|
||||
|
||||
### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export type ApiHandlerContext<P> = {
|
||||
params: Promise<P>;
|
||||
t0: number;
|
||||
};
|
||||
|
||||
export function withApiHandler<P = Record<string, never>>(
|
||||
ctx: string,
|
||||
handler: (req: Request, ctx: ApiHandlerContext<P>) => Promise<NextResponse>
|
||||
) {
|
||||
return async (
|
||||
request: Request,
|
||||
routeContext?: { params: Promise<P> }
|
||||
): Promise<NextResponse> => {
|
||||
const t0 = Date.now();
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
logger.warn(ctx, "unauthorized");
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const handlerCtx: ApiHandlerContext<P> = {
|
||||
params: routeContext?.params ?? (Promise.resolve({}) as Promise<P>),
|
||||
t0,
|
||||
};
|
||||
|
||||
try {
|
||||
return await handler(request, handlerCtx);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
const level = error.statusCode >= 500 ? "error" : "warn";
|
||||
logger[level](ctx, error.message, {
|
||||
status: error.statusCode,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
logger.error(ctx, "unexpected error", {
|
||||
error: String(error),
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,202 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { format } from "sql-formatter";
|
||||
|
||||
import { PrismaClient } from "@/lib/generated/prisma/client";
|
||||
import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000");
|
||||
|
||||
// ── SQL 로깅 ($on) ────────────────────────────────────────────────
|
||||
|
||||
function inlineParams(query: string, rawParams: string): string {
|
||||
try {
|
||||
const params = JSON.parse(rawParams) as unknown[];
|
||||
let idx = 0;
|
||||
return query.replace(/\?/g, () => {
|
||||
const val = params[idx++];
|
||||
if (val === null || val === undefined) return "NULL";
|
||||
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
||||
if (typeof val === "boolean") return val ? "1" : "0";
|
||||
return String(val);
|
||||
});
|
||||
} catch {
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
function onQuery(e: Prisma.QueryEvent): void {
|
||||
const isSlow = e.duration >= SLOW_QUERY_MS;
|
||||
const level = isSlow ? "warn" : "debug";
|
||||
if (!logger.isEnabled(level)) return;
|
||||
|
||||
const isSensitive = e.query.includes("PASSWORD(");
|
||||
const sqlToFormat = isSensitive ? e.query : inlineParams(e.query, e.params);
|
||||
const formatted = format(sqlToFormat, { language: "mysql", tabWidth: 2 });
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const tag = level.toUpperCase().padEnd(5);
|
||||
const slowLabel = isSlow ? " [SLOW]" : "";
|
||||
const header = `[${ts}] [${tag}] [db:query] ${e.duration}ms${slowLabel}`;
|
||||
const indented = formatted
|
||||
.split("\n")
|
||||
.map((l) => ` ${l}`)
|
||||
.join("\n");
|
||||
|
||||
const out = `${header}\n${indented}`;
|
||||
if (isSlow) {
|
||||
console.error(out);
|
||||
} else {
|
||||
console.log(out);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 결과 로깅 ($extends) ──────────────────────────────────────────
|
||||
|
||||
const COL_MAX_WIDTH = 40;
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// 한글·한자 등 터미널에서 2칸을 차지하는 유니코드 블록 판별
|
||||
function isWide(cp: number): boolean {
|
||||
return (
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
||||
(cp >= 0x2e80 && cp <= 0x303f) || // CJK Radicals Supplement, Kangxi
|
||||
(cp >= 0x3040 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, etc.
|
||||
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
||||
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
||||
(cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
|
||||
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
|
||||
(cp >= 0xd7b0 && cp <= 0xd7ff) || // Hangul Jamo Extended-B
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(cp >= 0xfe10 && cp <= 0xfe1f) || // Vertical Forms
|
||||
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms
|
||||
(cp >= 0xff01 && cp <= 0xff60) || // Fullwidth Latin
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth Currency
|
||||
cp >= 0x20000 // CJK Extension B+
|
||||
);
|
||||
}
|
||||
|
||||
function displayWidth(s: string): number {
|
||||
let w = 0;
|
||||
for (const char of s) {
|
||||
w += isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
function truncateStr(s: string, maxWidth: number): string {
|
||||
if (displayWidth(s) <= maxWidth) return s;
|
||||
let w = 0;
|
||||
let result = "";
|
||||
for (const char of s) {
|
||||
const cw = isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
||||
if (w + cw > maxWidth - 1) break; // 1칸은 "…"에 예약
|
||||
w += cw;
|
||||
result += char;
|
||||
}
|
||||
return result + "…";
|
||||
}
|
||||
|
||||
function padEnd(s: string, width: number): string {
|
||||
const truncated = truncateStr(s, width);
|
||||
return truncated + " ".repeat(Math.max(0, width - displayWidth(truncated)));
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const colWidths = headers.map((h, i) =>
|
||||
Math.min(
|
||||
COL_MAX_WIDTH,
|
||||
Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? "")))
|
||||
)
|
||||
);
|
||||
|
||||
const rule = (l: string, m: string, r: string, s: string) =>
|
||||
l + colWidths.map((w) => s.repeat(w + 2)).join(m) + r;
|
||||
|
||||
const hRow = "│ " + headers.map((h, i) => padEnd(h, colWidths[i])).join(" │ ") + " │";
|
||||
const dRows = rows.map(
|
||||
(r) => "│ " + r.map((c, i) => padEnd(c ?? "", colWidths[i])).join(" │ ") + " │"
|
||||
);
|
||||
|
||||
return [
|
||||
rule("┌", "┬", "┐", "─"),
|
||||
hRow,
|
||||
rule("├", "┼", "┤", "─"),
|
||||
...dRows,
|
||||
rule("└", "┴", "┘", "─"),
|
||||
]
|
||||
.map((l) => ` ${l}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildResultLog(
|
||||
model: string | undefined,
|
||||
operation: string,
|
||||
result: unknown
|
||||
): string {
|
||||
const label = model ? `${model}.${operation}` : operation;
|
||||
|
||||
if (typeof result === "number") {
|
||||
return `${label} → ${result}`;
|
||||
}
|
||||
|
||||
if (result !== null && typeof result === "object" && "count" in result) {
|
||||
return `${label} → count: ${(result as { count: number }).count}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const total = result.length;
|
||||
if (total === 0) return `${label} → 0 rows`;
|
||||
|
||||
const preview = result.slice(0, 5);
|
||||
const countLabel = `${total} row${total !== 1 ? "s" : ""}${total > 5 ? " (showing 5)" : ""}`;
|
||||
const moreLabel = total > 5 ? `\n ... ${total - 5} more` : "";
|
||||
|
||||
if (typeof preview[0] === "object" && preview[0] !== null) {
|
||||
const objRows = preview as Record<string, unknown>[];
|
||||
const headers = Object.keys(objRows[0]);
|
||||
const rows = objRows.map((row) => headers.map((h) => formatValue(row[h])));
|
||||
return `${label} → ${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
|
||||
}
|
||||
|
||||
const rows = preview.map((v) => [formatValue(v)]);
|
||||
return `${label} → ${countLabel}\n${renderTable(["value"], rows)}${moreLabel}`;
|
||||
}
|
||||
|
||||
if (result !== null && typeof result === "object") {
|
||||
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
formatValue(v),
|
||||
]);
|
||||
return `${label}\n${renderTable(["field", "value"], rows)}`;
|
||||
}
|
||||
|
||||
return `${label} → ${String(result)}`;
|
||||
}
|
||||
|
||||
function logResult(
|
||||
model: string | undefined,
|
||||
operation: string,
|
||||
result: unknown
|
||||
): void {
|
||||
if (!logger.isEnabled("debug")) return;
|
||||
const ts = new Date().toISOString();
|
||||
const body = buildResultLog(model, operation, result);
|
||||
console.log(`[${ts}] [DEBUG] [db:result] ${body}`);
|
||||
}
|
||||
|
||||
// ── PrismaClient 생성 ─────────────────────────────────────────────
|
||||
|
||||
function createPrismaClient() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
@@ -14,8 +205,25 @@ function createPrismaClient() {
|
||||
}
|
||||
|
||||
const adapter = new PrismaMariaDb(databaseUrl);
|
||||
const baseClient = new PrismaClient({
|
||||
adapter,
|
||||
log: [{ emit: "event", level: "query" }],
|
||||
});
|
||||
|
||||
return new PrismaClient({ adapter });
|
||||
baseClient.$on("query", onQuery);
|
||||
|
||||
const extendedClient = baseClient.$extends({
|
||||
query: {
|
||||
$allOperations: async ({ model, operation, args, query }) => {
|
||||
const result = await query(args);
|
||||
logResult(model, operation, result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// $extends 타입은 PrismaClient의 상위 호환이므로 캐스트 안전
|
||||
return extendedClient as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import {
|
||||
@@ -54,19 +56,10 @@ export type ExtensionRequestListResult = {
|
||||
status?: number | "all";
|
||||
};
|
||||
|
||||
export class ExtensionRequestActionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ExtensionRequestActionError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExtensionRequests(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<ExtensionRequestListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseExtensionRequestSearchParams(rawSearchParams);
|
||||
const where = buildExtensionRequestWhere(params);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
@@ -96,6 +89,12 @@ export async function getExtensionRequests(
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("extension-requests", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: requests.map(toExtensionRequestListItem),
|
||||
page: params.page,
|
||||
@@ -194,11 +193,11 @@ async function getActionTarget(
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new ExtensionRequestActionError("연장 신청을 찾을 수 없습니다.", 404);
|
||||
throw new ApiError("연장 신청을 찾을 수 없습니다.", 404);
|
||||
}
|
||||
|
||||
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
|
||||
throw new ExtensionRequestActionError("이미 처리된 연장 신청입니다.", 409);
|
||||
throw new ApiError("이미 처리된 연장 신청입니다.", 409);
|
||||
}
|
||||
|
||||
return request;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const PRIORITY: Record<Level, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
function getMinLevel(): Level {
|
||||
const env = process.env.LOG_LEVEL as Level | undefined;
|
||||
if (env && env in PRIORITY) return env;
|
||||
const appEnv = process.env.APP_ENV;
|
||||
return appEnv === "production" ? "info" : "debug";
|
||||
}
|
||||
|
||||
function shouldLog(level: Level): boolean {
|
||||
return PRIORITY[level] >= PRIORITY[getMinLevel()];
|
||||
}
|
||||
|
||||
function write(
|
||||
level: Level,
|
||||
ctx: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!shouldLog(level)) return;
|
||||
const ts = new Date().toISOString();
|
||||
const tag = level.toUpperCase().padEnd(5);
|
||||
const line = data
|
||||
? `[${ts}] [${tag}] [${ctx}] ${message} ${JSON.stringify(data)}`
|
||||
: `[${ts}] [${tag}] [${ctx}] ${message}`;
|
||||
if (level === "warn" || level === "error") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("debug", ctx, message, data),
|
||||
info: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("info", ctx, message, data),
|
||||
warn: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("warn", ctx, message, data),
|
||||
error: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("error", ctx, message, data),
|
||||
isEnabled: (level: Level): boolean => shouldLog(level),
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
|
||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||
@@ -104,6 +105,7 @@ export type MaestroDetail = {
|
||||
export async function getMaestros(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<MaestroListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseMaestroListSearchParams(rawSearchParams);
|
||||
const where = buildMaestroWhere(params);
|
||||
const orderBy = buildMaestroOrderBy(params.sort);
|
||||
@@ -131,6 +133,12 @@ export async function getMaestros(
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||
|
||||
logger.info("maestros", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: maestros.map(toMaestroListItem),
|
||||
page: params.page,
|
||||
@@ -147,6 +155,7 @@ export async function getMaestros(
|
||||
export async function getMaestroDetail(
|
||||
maestroID: number
|
||||
): Promise<MaestroDetail | null> {
|
||||
const t0 = Date.now();
|
||||
const maestro = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: {
|
||||
@@ -164,6 +173,7 @@ export async function getMaestroDetail(
|
||||
});
|
||||
|
||||
if (!maestro) {
|
||||
logger.warn("maestros/[id]", "not found", { id: maestroID });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -229,6 +239,11 @@ export async function getMaestroDetail(
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("maestros/[id]", "fetched", {
|
||||
id: maestroID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
maestro: {
|
||||
...toMaestroListItem(maestro),
|
||||
|
||||
+11
-12
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import {
|
||||
@@ -58,19 +60,10 @@ export type UpgradeRequestListResult = {
|
||||
status?: number | "all";
|
||||
};
|
||||
|
||||
export class UpgradeRequestActionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UpgradeRequestActionError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUpgradeRequests(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<UpgradeRequestListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseUpgradeRequestSearchParams(rawSearchParams);
|
||||
const where = buildUpgradeRequestWhere(params);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
@@ -100,6 +93,12 @@ export async function getUpgradeRequests(
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("upgrade-requests", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: requests.map(toUpgradeRequestListItem),
|
||||
page: params.page,
|
||||
@@ -198,14 +197,14 @@ async function getActionTarget(
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new UpgradeRequestActionError(
|
||||
throw new ApiError(
|
||||
"업그레이드 신청을 찾을 수 없습니다.",
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
|
||||
throw new UpgradeRequestActionError(
|
||||
throw new ApiError(
|
||||
"이미 처리된 업그레이드 신청입니다.",
|
||||
409
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.11.0",
|
||||
"sql-formatter": "^15.8.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
Generated
+58
@@ -47,6 +47,9 @@ importers:
|
||||
shadcn:
|
||||
specifier: ^4.11.0
|
||||
version: 4.11.0(typescript@5.9.3)
|
||||
sql-formatter:
|
||||
specifier: ^15.8.2
|
||||
version: 15.8.2
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
@@ -1528,6 +1531,9 @@ packages:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -1679,6 +1685,9 @@ packages:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
discontinuous-range@1.0.0:
|
||||
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2650,6 +2659,9 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
moo@0.5.3:
|
||||
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -2674,6 +2686,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
nearley@2.20.1:
|
||||
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
|
||||
hasBin: true
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2968,6 +2984,13 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
railroad-diagrams@1.0.0:
|
||||
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
|
||||
|
||||
randexp@0.4.6:
|
||||
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3033,6 +3056,10 @@ packages:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ret@0.1.15:
|
||||
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
retry@0.12.0:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -3155,6 +3182,10 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sql-formatter@15.8.2:
|
||||
resolution: {integrity: sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg==}
|
||||
hasBin: true
|
||||
|
||||
sqlstring@2.3.3:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4803,6 +4834,8 @@ snapshots:
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
confbox@0.2.4: {}
|
||||
@@ -4914,6 +4947,8 @@ snapshots:
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
discontinuous-range@1.0.0: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -6019,6 +6054,8 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
moo@0.5.3: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mysql2@3.15.3:
|
||||
@@ -6043,6 +6080,13 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
nearley@2.20.1:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
moo: 0.5.3
|
||||
railroad-diagrams: 1.0.0
|
||||
randexp: 0.4.6
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
next-auth@5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
|
||||
@@ -6340,6 +6384,13 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
railroad-diagrams@1.0.0: {}
|
||||
|
||||
randexp@0.4.6:
|
||||
dependencies:
|
||||
discontinuous-range: 1.0.0
|
||||
ret: 0.1.15
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
@@ -6417,6 +6468,8 @@ snapshots:
|
||||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
ret@0.1.15: {}
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
@@ -6632,6 +6685,11 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
sql-formatter@15.8.2:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
nearley: 2.20.1
|
||||
|
||||
sqlstring@2.3.3: {}
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
Reference in New Issue
Block a user