docs: Phase V2-6 Admin CRM 고도화 설계서
Admin API(Edge Function) + Swagger UI + CRUD 웹페이지 + recharts 차트 + 결제 이력 + 감사로그 설계.
This commit is contained in:
parent
1439ed3c7a
commit
f7c50eb2ed
1 changed files with 418 additions and 0 deletions
418
docs/v2/phase-V2-6.md
Normal file
418
docs/v2/phase-V2-6.md
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
# Phase V2-6: Admin CRM 고도화 — CRUD + 차트 + 결제 + 감사
|
||||
|
||||
> Admin CRM(apps/admin)을 운영 도구 수준으로 격상.
|
||||
> super_admin/admin 2-tier 권한, 구독 전체 CRUD, 사용량 차트, 결제 이력, 감사로그.
|
||||
|
||||
---
|
||||
|
||||
## 0. 배경
|
||||
|
||||
- Phase 3.3에서 `apps/admin` 독립 Next.js 앱 + 4개 조회 페이지 완성
|
||||
- 현재는 **읽기 전용** — 구독/role 변경은 SQL 직접 실행 필요
|
||||
- 소수 팀(2~5명) 운영 → super_admin만 위험 조작 가능하게
|
||||
|
||||
## 1. 권한 체계
|
||||
|
||||
### 1.1 Role 확장
|
||||
|
||||
| role | 조회 | 구독 수정 | role 변경 | 구독 삭제 |
|
||||
|------|------|-----------|-----------|-----------|
|
||||
| `user` | X (접근 불가) | X | X | X |
|
||||
| `admin` | O (전체) | X | X | X |
|
||||
| `super_admin` | O (전체) | O | O | O |
|
||||
|
||||
- `auth.users.raw_app_meta_data.role`: `'user'` | `'admin'` | `'super_admin'`
|
||||
- `profiles.role` CHECK도 동일 확장
|
||||
- 기존 RLS: `app_metadata.role = 'admin'` → `IN ('admin', 'super_admin')` 변경
|
||||
|
||||
### 1.2 Admin Guard 수정
|
||||
|
||||
```typescript
|
||||
// apps/admin/src/lib/admin-guard.ts
|
||||
type AdminRole = 'admin' | 'super_admin'
|
||||
|
||||
function getAdminRole(user): AdminRole | null
|
||||
function requireAdmin(user): void // admin 이상
|
||||
function requireSuperAdmin(user): void // super_admin만
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. DB 마이그레이션
|
||||
|
||||
### 2.1 audit_log 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
admin_id uuid NOT NULL REFERENCES auth.users(id),
|
||||
action text NOT NULL, -- 'subscription.update', 'subscription.create', 'subscription.delete', 'user.role_change'
|
||||
target_type text NOT NULL, -- 'subscription', 'profile'
|
||||
target_id uuid NOT NULL, -- 대상 레코드의 user_id 또는 subscription_id
|
||||
before_data jsonb, -- 변경 전 스냅샷
|
||||
after_data jsonb, -- 변경 후 스냅샷
|
||||
memo text NOT NULL, -- 관리자 메모 (필수)
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_target ON public.audit_log(target_type, target_id);
|
||||
CREATE INDEX idx_audit_log_admin ON public.audit_log(admin_id);
|
||||
CREATE INDEX idx_audit_log_date ON public.audit_log(created_at DESC);
|
||||
```
|
||||
|
||||
RLS: admin 이상 SELECT, super_admin만 INSERT (Edge Function은 service_role).
|
||||
|
||||
### 2.2 subscriptions.admin_note
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.subscriptions
|
||||
ADD COLUMN IF NOT EXISTS admin_note text;
|
||||
```
|
||||
|
||||
### 2.3 profiles.role CHECK 확장
|
||||
|
||||
```sql
|
||||
-- 기존: CHECK (role IN ('user', 'admin'))
|
||||
-- 변경: CHECK (role IN ('user', 'admin', 'super_admin'))
|
||||
ALTER TABLE public.profiles DROP CONSTRAINT IF EXISTS profiles_role_check;
|
||||
ALTER TABLE public.profiles
|
||||
ADD CONSTRAINT profiles_role_check
|
||||
CHECK (role IN ('user', 'admin', 'super_admin'));
|
||||
```
|
||||
|
||||
### 2.4 기존 RLS 정책 업데이트
|
||||
|
||||
```sql
|
||||
-- admin → admin 또는 super_admin
|
||||
DROP POLICY IF EXISTS "admin_read_all_profiles" ON public.profiles;
|
||||
CREATE POLICY "admin_read_all_profiles" ON public.profiles
|
||||
FOR SELECT TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
|
||||
|
||||
-- subscriptions, daily_usage 동일 패턴
|
||||
-- + subscriptions에 UPDATE/INSERT/DELETE 정책 추가 (super_admin만)
|
||||
|
||||
CREATE POLICY "super_admin_write_subscriptions" ON public.subscriptions
|
||||
FOR ALL TO authenticated
|
||||
USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin')
|
||||
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') = 'super_admin');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Admin API Edge Functions
|
||||
|
||||
4개 Edge Function, 모두 `verify_jwt = true`, 내부에서 admin/super_admin 검증.
|
||||
|
||||
### 3.1 admin-users
|
||||
|
||||
| Method | Action | 권한 |
|
||||
|--------|--------|------|
|
||||
| GET | 유저 목록/상세 (기존 Supabase 직접 쿼리 대체) | admin+ |
|
||||
| PATCH | role 변경 (`{ userId, newRole, memo }`) | super_admin |
|
||||
|
||||
role 변경 시:
|
||||
1. `auth.admin.updateUserById()` → `raw_app_meta_data.role` 변경 (service_role 필요)
|
||||
2. `profiles.role` 동기화
|
||||
3. `audit_log` INSERT (before/after diff)
|
||||
|
||||
### 3.2 admin-subscriptions
|
||||
|
||||
| Method | Action | 권한 |
|
||||
|--------|--------|------|
|
||||
| GET | 구독 목록/상세 | admin+ |
|
||||
| POST | 구독 생성 (VIP 부여 / 레코드 복구) | super_admin |
|
||||
| PATCH | 구독 수정 (tier/기간/상태/admin_note/overage_credits) | super_admin |
|
||||
| DELETE | 구독 삭제 (soft: status='deleted') | super_admin |
|
||||
|
||||
모든 쓰기 작업에 `memo` 필수 → `audit_log` 자동 기록.
|
||||
|
||||
**요청 바디 (PATCH 예시)**:
|
||||
```typescript
|
||||
interface UpdateSubscriptionBody {
|
||||
tier?: 'free' | 'pro' | 'pro_plus'
|
||||
status?: 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
current_period_end?: string // ISO 8601
|
||||
overage_credits?: number
|
||||
admin_note?: string
|
||||
memo: string // 감사로그 메모 (필수)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 admin-payments
|
||||
|
||||
| Method | Action | 권한 |
|
||||
|--------|--------|------|
|
||||
| GET `?userId=...` | 특정 유저 결제 이력 | admin+ |
|
||||
| GET `?userId=...&source=payple` | Payple API 직접 조회 | admin+ |
|
||||
|
||||
DB 조회: `subscriptions` 테이블 + `audit_log` (결제 관련 action 필터)
|
||||
Payple 조회: `_shared/payple.ts`의 `paypleAuth()` → 결제 내역 API 호출
|
||||
|
||||
### 3.4 admin-audit-log
|
||||
|
||||
| Method | Action | 권한 |
|
||||
|--------|--------|------|
|
||||
| GET | 감사로그 목록 (필터: target_type, admin_id, 날짜 범위) | admin+ |
|
||||
| GET `?id=...` | 단건 상세 (before/after diff) | admin+ |
|
||||
|
||||
페이지네이션: `?page=1&limit=20&target_type=subscription&from=2026-04-01&to=2026-04-12`
|
||||
|
||||
### 3.5 공통 패턴
|
||||
|
||||
```typescript
|
||||
// _shared/admin-auth.ts (신규)
|
||||
import { requireUser } from './auth.ts'
|
||||
|
||||
type AdminLevel = 'admin' | 'super_admin'
|
||||
|
||||
export async function requireAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
const role = user.app_metadata?.role
|
||||
if (role !== 'admin' && role !== 'super_admin') {
|
||||
throw { status: 403, message: 'Admin access required' }
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function requireSuperAdmin(req: Request): Promise<User> {
|
||||
const user = await requireUser(req)
|
||||
if (user.app_metadata?.role !== 'super_admin') {
|
||||
throw { status: 403, message: 'Super admin access required' }
|
||||
}
|
||||
return user
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// _shared/audit.ts (신규)
|
||||
export async function writeAuditLog(
|
||||
supabase: SupabaseClient,
|
||||
params: {
|
||||
adminId: string
|
||||
action: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
beforeData: Record<string, unknown> | null
|
||||
afterData: Record<string, unknown> | null
|
||||
memo: string
|
||||
}
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
### 3.6 config.toml 추가
|
||||
|
||||
```toml
|
||||
[functions.admin-users]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.admin-subscriptions]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.admin-payments]
|
||||
verify_jwt = true
|
||||
|
||||
[functions.admin-audit-log]
|
||||
verify_jwt = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Swagger UI
|
||||
|
||||
### 4.1 OpenAPI 3.0 Spec
|
||||
|
||||
`server/supabase/functions/_shared/openapi.json` — 4개 Edge Function의 전체 API 명세.
|
||||
|
||||
### 4.2 독립 정적 페이지
|
||||
|
||||
```
|
||||
apps/admin-swagger/
|
||||
├── index.html # swagger-ui-dist CDN + openapi.json 로드
|
||||
├── openapi.json # OpenAPI 3.0 spec (빌드 시 _shared에서 복사)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
- `swagger-ui-dist` CDN에서 로드 (설치 불필요)
|
||||
- Bearer token 입력으로 admin 인증
|
||||
- 로컬: `npx serve apps/admin-swagger` 또는 admin 앱에서 링크
|
||||
|
||||
---
|
||||
|
||||
## 5. Admin CRUD 웹페이지 (apps/admin)
|
||||
|
||||
### 5.1 새 페이지/컴포넌트
|
||||
|
||||
| 경로 | 용도 |
|
||||
|------|------|
|
||||
| `(admin)/subscriptions/[id]/page.tsx` | 구독 상세 + 수정/삭제 폼 |
|
||||
| `(admin)/subscriptions/new/page.tsx` | 구독 생성 (VIP 부여) |
|
||||
| `(admin)/audit-log/page.tsx` | 감사로그 목록 |
|
||||
| `(admin)/audit-log/[id]/page.tsx` | 감사로그 상세 (diff 뷰) |
|
||||
| `components/subscription-form.tsx` | 구독 생성/수정 공용 폼 |
|
||||
| `components/role-change-dialog.tsx` | role 변경 확인 다이얼로그 |
|
||||
| `components/audit-diff-viewer.tsx` | before/after JSON diff 뷰어 |
|
||||
| `components/memo-dialog.tsx` | 메모 입력 다이얼로그 (필수) |
|
||||
|
||||
### 5.2 기존 페이지 수정
|
||||
|
||||
| 페이지 | 변경 |
|
||||
|--------|------|
|
||||
| `users/[id]/page.tsx` | role 변경 버튼 추가 (super_admin만 표시) |
|
||||
| `subscriptions/page.tsx` | 각 행에 편집/삭제 버튼 + "새 구독" 버튼 추가 |
|
||||
| `admin-sidebar.tsx` | Audit Log 메뉴 추가, Swagger UI 외부 링크 |
|
||||
|
||||
### 5.3 super_admin 가드 UI
|
||||
|
||||
```typescript
|
||||
// 현재 유저의 role을 체크해서 위험 버튼 표시/숨김
|
||||
const isSuperAdmin = user.app_metadata?.role === 'super_admin'
|
||||
|
||||
// 버튼 예시
|
||||
{isSuperAdmin && <Button onClick={handleDelete}>구독 삭제</Button>}
|
||||
```
|
||||
|
||||
### 5.4 데이터 페칭 패턴
|
||||
|
||||
기존: Supabase SDK 직접 쿼리 (RSC)
|
||||
신규 쓰기 작업: Edge Function 호출 (클라이언트 컴포넌트)
|
||||
|
||||
```typescript
|
||||
// Edge Function 호출 헬퍼
|
||||
async function callAdminApi(path: string, options: RequestInit) {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
return fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 사용량 차트 (recharts)
|
||||
|
||||
### 6.1 설치
|
||||
|
||||
```bash
|
||||
npm install --workspace=@d3ro/admin recharts
|
||||
```
|
||||
|
||||
### 6.2 차트 컴포넌트
|
||||
|
||||
| 컴포넌트 | 차트 유형 | 데이터 |
|
||||
|----------|-----------|--------|
|
||||
| `FeatureUsageChart` | StackedBar / Line | feature별 일간 API 호출 (7/14/30일) |
|
||||
| `UserActivityChart` | Line | DAU/WAU/MAU 추이 |
|
||||
| `TopUsersChart` | HorizontalBar | 유저별 사용량 Top 20 |
|
||||
| `RetentionChart` | Heatmap / Line | 주간/월간 리텐션율 |
|
||||
|
||||
### 6.3 데이터 소스
|
||||
|
||||
기존 `daily_usage` 테이블 + SQL 집계 RPC 추가:
|
||||
|
||||
```sql
|
||||
-- 일별 feature 집계
|
||||
CREATE OR REPLACE FUNCTION public.admin_usage_by_feature(
|
||||
p_from date, p_to date
|
||||
) RETURNS TABLE(date date, feature text, total_count bigint, unique_users bigint)
|
||||
LANGUAGE sql SECURITY DEFINER STABLE
|
||||
AS $$
|
||||
SELECT date, feature,
|
||||
SUM(count)::bigint AS total_count,
|
||||
COUNT(DISTINCT user_id)::bigint AS unique_users
|
||||
FROM public.daily_usage
|
||||
WHERE date BETWEEN p_from AND p_to
|
||||
GROUP BY date, feature
|
||||
ORDER BY date, feature;
|
||||
$$;
|
||||
|
||||
-- 유저별 사용량 랭킹
|
||||
CREATE OR REPLACE FUNCTION public.admin_top_users(
|
||||
p_from date, p_to date, p_limit integer DEFAULT 20
|
||||
) RETURNS TABLE(user_id uuid, name text, total_count bigint, feature_count bigint)
|
||||
LANGUAGE sql SECURITY DEFINER STABLE
|
||||
AS $$
|
||||
SELECT du.user_id, p.name,
|
||||
SUM(du.count)::bigint AS total_count,
|
||||
COUNT(DISTINCT du.feature)::bigint AS feature_count
|
||||
FROM public.daily_usage du
|
||||
JOIN public.profiles p ON p.id = du.user_id
|
||||
WHERE du.date BETWEEN p_from AND p_to
|
||||
GROUP BY du.user_id, p.name
|
||||
ORDER BY total_count DESC
|
||||
LIMIT p_limit;
|
||||
$$;
|
||||
|
||||
-- DAU 추이
|
||||
CREATE OR REPLACE FUNCTION public.admin_dau(
|
||||
p_from date, p_to date
|
||||
) RETURNS TABLE(date date, active_users bigint)
|
||||
LANGUAGE sql SECURITY DEFINER STABLE
|
||||
AS $$
|
||||
SELECT date, COUNT(DISTINCT user_id)::bigint AS active_users
|
||||
FROM public.daily_usage
|
||||
WHERE date BETWEEN p_from AND p_to
|
||||
GROUP BY date
|
||||
ORDER BY date;
|
||||
$$;
|
||||
```
|
||||
|
||||
RPC 권한: admin 이상만 호출 가능 (RLS + role 체크).
|
||||
|
||||
---
|
||||
|
||||
## 7. 결제 이력
|
||||
|
||||
### 7.1 DB 조회
|
||||
|
||||
`subscriptions` 테이블 (payment_provider, payple_payer_id, payple_pay_oid, status, tier 변경 이력)
|
||||
+ `audit_log` 필터 (`action LIKE 'subscription.%'`)
|
||||
|
||||
### 7.2 Payple API 직접 조회
|
||||
|
||||
`admin-payments` Edge Function에서 `_shared/payple.ts` 활용:
|
||||
- `paypleAuth()` → 인증 토큰 발급
|
||||
- Payple 결제 내역 조회 API 호출 (PCD_PAYER_ID 기반)
|
||||
|
||||
### 7.3 UI
|
||||
|
||||
`(admin)/users/[id]/page.tsx`에 결제 이력 탭 추가:
|
||||
- 구독 변경 타임라인 (audit_log 기반)
|
||||
- Payple 결제 내역 (API 조회 결과)
|
||||
|
||||
---
|
||||
|
||||
## 8. 구현 순서
|
||||
|
||||
| Step | 내용 | 파일 |
|
||||
|------|------|------|
|
||||
| **8.1** | DB 마이그레이션 | `server/supabase/migrations/20260413000004_admin_enhancement.sql` |
|
||||
| **8.2** | 공유 유틸 | `_shared/admin-auth.ts`, `_shared/audit.ts` |
|
||||
| **8.3** | Edge Functions 4개 | `admin-users/`, `admin-subscriptions/`, `admin-payments/`, `admin-audit-log/` |
|
||||
| **8.4** | OpenAPI spec + Swagger UI | `_shared/openapi.json`, `apps/admin-swagger/` |
|
||||
| **8.5** | Admin 웹 CRUD 페이지 | `apps/admin/src/` 수정/추가 |
|
||||
| **8.6** | recharts 차트 | `apps/admin/src/components/charts/` |
|
||||
| **8.7** | 결제 이력 UI | `users/[id]` 탭 확장 |
|
||||
| **8.8** | RLS 정책 + config.toml | 마이그레이션 + 서버 설정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 검증 체크리스트
|
||||
|
||||
- [ ] `apps/admin` typecheck 통과
|
||||
- [ ] Edge Functions 4개 배포 + 응답 확인
|
||||
- [ ] Swagger UI에서 전체 API 테스트 가능
|
||||
- [ ] admin: 조회만 가능, 쓰기 버튼 숨김
|
||||
- [ ] super_admin: CRUD 전체 동작
|
||||
- [ ] 구독 수정/삭제 시 audit_log 기록 확인
|
||||
- [ ] before/after diff 정확성
|
||||
- [ ] 메모 미입력 시 제출 불가
|
||||
- [ ] recharts 차트 4종 렌더링
|
||||
- [ ] 기간 필터(7/14/30일) 동작
|
||||
- [ ] Payple 결제 이력 조회 동작
|
||||
Loading…
Add table
Add a link
Reference in a new issue