diff --git a/app/admin_manager.py b/app/admin_manager.py
index 4bf0bd0..670eb13 100644
--- a/app/admin_manager.py
+++ b/app/admin_manager.py
@@ -1,14 +1,32 @@
+from pathlib import Path
+
from fastapi import FastAPI
from sqladmin import Admin
+from sqladmin.authentication import login_required
+from starlette.requests import Request
+from starlette.responses import Response
+from sqlalchemy.ext.asyncio import AsyncEngine
from app.backoffice.admin.admin_view import AdminAdmin
from app.backoffice.admin.auth import AdminAuthBackend
-from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin, CreditTransactionAdmin
-from sqlalchemy.ext.asyncio import AsyncEngine
-from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
+from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin
+from app.backoffice.dashboard import get_dashboard_context
+from app.user.api.user_admin import SocialAccountAdmin, UserAdmin
from config import prj_settings
-# https://github.com/aminalaee/sqladmin
+TEMPLATES_DIR = Path(__file__).parent / "backoffice" / "frontend" / "templates"
+
+
+class DashboardAdmin(Admin):
+ @login_required
+ async def index(self, request: Request) -> Response:
+ ctx = await get_dashboard_context()
+ admin_role = request.session.get("admin_role", "viewer")
+ return await self.templates.TemplateResponse(
+ request,
+ "sqladmin/index.html",
+ {"title": "대시보드", "subtitle": "", "admin_role": admin_role, **ctx},
+ )
def init_admin(
@@ -18,19 +36,20 @@ def init_admin(
) -> Admin:
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
- admin = Admin(
+ admin = DashboardAdmin(
app,
db_engine,
base_url=base_url,
authentication_backend=auth_backend,
title="ADO2 관리자",
+ templates_dir=str(TEMPLATES_DIR),
)
# 사용자 관리
admin.add_view(UserAdmin)
admin.add_view(SocialAccountAdmin)
- # 크레딧 관리
+ # 크레딧 관리 (superadmin: 전체, viewer: 읽기 전용)
admin.add_view(CreditChargeRequestAdmin)
admin.add_view(CreditTransactionAdmin)
diff --git a/app/backoffice/admin/admin_view.py b/app/backoffice/admin/admin_view.py
index d50a321..7ab56b0 100644
--- a/app/backoffice/admin/admin_view.py
+++ b/app/backoffice/admin/admin_view.py
@@ -1,19 +1,22 @@
from sqladmin import ModelView
+from wtforms import PasswordField, SelectField
from app.backoffice.admin.models import Admin
+from app.backoffice.mixins import SuperAdminOnly
-class AdminAdmin(ModelView, model=Admin):
+class AdminAdmin(SuperAdminOnly, ModelView, model=Admin):
name = "관리자 계정"
name_plural = "관리자 계정 목록"
icon = "fa-solid fa-user-shield"
category = "백오피스 설정"
- page_size = 20
+ page_size = 30
column_list = [
"id",
"username",
"name",
+ "role",
"is_active",
"last_login_at",
"created_at",
@@ -23,13 +26,27 @@ class AdminAdmin(ModelView, model=Admin):
"id",
"username",
"name",
+ "role",
"is_active",
"last_login_at",
"created_at",
"updated_at",
]
- form_columns = ["username", "name", "is_active"]
+ form_columns = ["username", "password", "name", "role", "is_active"]
+
+ form_overrides = {
+ "password": PasswordField,
+ "role": SelectField,
+ }
+
+ form_args = {
+ "role": {
+ "label": "권한",
+ "choices": [("superadmin", "전체 관리자"), ("viewer", "조회 전용")],
+ "default": "viewer",
+ }
+ }
column_searchable_list = [Admin.username, Admin.name]
@@ -47,6 +64,7 @@ class AdminAdmin(ModelView, model=Admin):
"id": "ID",
"username": "아이디",
"name": "이름",
+ "role": "권한",
"is_active": "활성화",
"last_login_at": "마지막 로그인",
"created_at": "생성일시",
diff --git a/app/backoffice/admin/auth.py b/app/backoffice/admin/auth.py
index d7e44be..7c55642 100644
--- a/app/backoffice/admin/auth.py
+++ b/app/backoffice/admin/auth.py
@@ -35,7 +35,8 @@ class AdminAuthBackend(AuthenticationBackend):
return False
request.session["admin_id"] = admin.id
- logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username}")
+ request.session["admin_role"] = admin.role
+ logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username} role={admin.role}")
# 마지막 로그인 시간 갱신
async with AsyncSessionLocal() as session:
diff --git a/app/backoffice/admin/models.py b/app/backoffice/admin/models.py
index 77cf02e..fd6fd83 100644
--- a/app/backoffice/admin/models.py
+++ b/app/backoffice/admin/models.py
@@ -46,6 +46,14 @@ class Admin(Base):
comment="표시 이름",
)
+ role: Mapped[str] = mapped_column(
+ String(20),
+ nullable=False,
+ default="viewer",
+ server_default="viewer",
+ comment="권한 (superadmin: 전체, viewer: 조회만)",
+ )
+
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
diff --git a/app/backoffice/credit_view.py b/app/backoffice/credit_view.py
index 0a737fa..026426c 100644
--- a/app/backoffice/credit_view.py
+++ b/app/backoffice/credit_view.py
@@ -3,6 +3,7 @@ import logging
from sqlalchemy import select
from sqlalchemy.orm import outerjoin
from sqladmin import ModelView, action
+from app.backoffice.mixins import SuperAdminEditable
from starlette.requests import Request
from starlette.responses import RedirectResponse
@@ -14,12 +15,12 @@ from app.database.session import AsyncSessionLocal
logger = logging.getLogger(__name__)
-class CreditChargeRequestAdmin(ModelView, model=CreditChargeRequest):
+class CreditChargeRequestAdmin(SuperAdminEditable, ModelView, model=CreditChargeRequest):
name = "충전 요청"
name_plural = "충전 요청 목록"
icon = "fa-solid fa-coins"
category = "크레딧 관리"
- page_size = 20
+ page_size = 30
column_list = [
"id",
@@ -134,12 +135,12 @@ class CreditChargeRequestAdmin(ModelView, model=CreditChargeRequest):
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
-class CreditTransactionAdmin(ModelView, model=CreditTransaction):
+class CreditTransactionAdmin(SuperAdminEditable, ModelView, model=CreditTransaction):
name = "크레딧 변경"
name_plural = "크레딧 변경 목록"
icon = "fa-solid fa-clock-rotate-left"
category = "크레딧 관리"
- page_size = 50
+ page_size = 30
can_create = False
can_edit = False
diff --git a/app/backoffice/dashboard.py b/app/backoffice/dashboard.py
new file mode 100644
index 0000000..cd39693
--- /dev/null
+++ b/app/backoffice/dashboard.py
@@ -0,0 +1,73 @@
+from datetime import datetime
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from starlette.requests import Request
+from starlette.responses import Response
+
+from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction, CreditTransactionType
+from app.database.session import AsyncSessionLocal
+from app.user.models import User
+from config import TIMEZONE
+
+
+async def get_dashboard_context() -> dict:
+ async with AsyncSessionLocal() as session:
+ today_start = datetime.now(TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0)
+
+ total_users = (await session.execute(
+ select(func.count()).select_from(User).where(User.is_deleted == False)
+ )).scalar()
+
+ pending_charge_requests_count = (await session.execute(
+ select(func.count()).select_from(CreditChargeRequest)
+ .where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
+ )).scalar()
+
+ today_consume = (await session.execute(
+ select(func.count()).select_from(CreditTransaction)
+ .where(
+ CreditTransaction.type == CreditTransactionType.CONSUME,
+ CreditTransaction.created_at >= today_start,
+ )
+ )).scalar()
+
+ today_charge = (await session.execute(
+ select(func.count()).select_from(CreditTransaction)
+ .where(
+ CreditTransaction.type == CreditTransactionType.CHARGE,
+ CreditTransaction.created_at >= today_start,
+ )
+ )).scalar()
+
+ pending_requests = (await session.execute(
+ select(CreditChargeRequest)
+ .where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
+ .order_by(CreditChargeRequest.created_at.desc())
+ .limit(10)
+ )).scalars().all()
+
+ recent_transactions = (await session.execute(
+ select(CreditTransaction)
+ .order_by(CreditTransaction.created_at.desc())
+ .limit(10)
+ )).scalars().all()
+
+ recent_users = (await session.execute(
+ select(User)
+ .where(User.is_deleted == False)
+ .order_by(User.created_at.desc())
+ .limit(10)
+ )).scalars().all()
+
+ return {
+ "stats": {
+ "total_users": total_users,
+ "pending_charge_requests": pending_charge_requests_count,
+ "today_consume": today_consume,
+ "today_charge": today_charge,
+ },
+ "pending_requests": pending_requests,
+ "recent_transactions": recent_transactions,
+ "recent_users": recent_users,
+ }
diff --git a/app/backoffice/frontend/templates/sqladmin/index.html b/app/backoffice/frontend/templates/sqladmin/index.html
new file mode 100644
index 0000000..d6fe8e6
--- /dev/null
+++ b/app/backoffice/frontend/templates/sqladmin/index.html
@@ -0,0 +1,149 @@
+{% extends "sqladmin/layout.html" %}
+
+{% block content %}
+
+
+
+
+
+
+
+
{{ stats.total_users }}
+
+
+
+
+
+
+
+
{{ stats.pending_charge_requests }}
+
+
+
+
+
+
+
+
{{ stats.today_consume }}
+
+
+
+
+
+
+
+
{{ stats.today_charge }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 사용자 UUID |
+ 요청 크레딧 |
+ 요청일시 |
+
+
+
+ {% for req in pending_requests %}
+
+ | {{ req.user_uuid }} |
+ {{ req.requested_amount }} |
+ {{ req.created_at.strftime('%Y-%m-%d %H:%M') }} |
+
+ {% else %}
+ | 대기 중인 요청 없음 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 사용자 UUID |
+ 유형 |
+ 변경 |
+ 잔액 |
+ 일시 |
+
+
+
+ {% for tx in recent_transactions %}
+
+ | {{ tx.user_uuid }} |
+ {{ tx.type }} |
+ {{ '%+d' % tx.amount }} |
+ {{ tx.balance_after }} |
+ {{ tx.created_at.strftime('%Y-%m-%d %H:%M') }} |
+
+ {% else %}
+ | 이력 없음 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 닉네임 |
+ 이메일 |
+ 크레딧 |
+ 권한 |
+ 가입일시 |
+
+
+
+ {% for user in recent_users %}
+
+ | {{ user.nickname or '-' }} |
+ {{ user.email or '-' }} |
+ {{ user.credits }} |
+ {{ user.role }} |
+ {{ user.created_at.strftime('%Y-%m-%d %H:%M') }} |
+
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/app/backoffice/mixins.py b/app/backoffice/mixins.py
new file mode 100644
index 0000000..f612bf5
--- /dev/null
+++ b/app/backoffice/mixins.py
@@ -0,0 +1,41 @@
+from starlette.requests import Request
+
+
+class SuperAdminOnly:
+ """superadmin만 접근 가능 (편집/삭제/액션 모두 허용)"""
+
+ def is_accessible(self, request: Request) -> bool:
+ return request.session.get("admin_role") == "superadmin"
+
+
+class ViewerReadOnly:
+ """viewer만 접근 가능한 읽기 전용 뷰"""
+
+ can_create = False
+ can_edit = False
+ can_delete = False
+
+ def is_accessible(self, request: Request) -> bool:
+ return request.session.get("admin_role") == "viewer"
+
+
+class ViewerAccessible:
+ """superadmin + viewer 접근 가능, 읽기 전용"""
+
+ can_create = False
+ can_edit = False
+ can_delete = False
+
+ def is_accessible(self, request: Request) -> bool:
+ return request.session.get("admin_role") in ("superadmin", "viewer")
+
+
+class SuperAdminEditable:
+ """superadmin + viewer 접근 가능, superadmin만 편집"""
+
+ can_create = False
+ can_edit = False
+ can_delete = False
+
+ def is_accessible(self, request: Request) -> bool:
+ return request.session.get("admin_role") in ("superadmin", "viewer")
diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py
index 6e619e2..8cc700e 100644
--- a/app/user/api/user_admin.py
+++ b/app/user/api/user_admin.py
@@ -4,27 +4,27 @@ from sqladmin import ModelView, action
from starlette.requests import Request
from starlette.responses import RedirectResponse
+from app.backoffice.mixins import SuperAdminEditable, ViewerAccessible
from app.backoffice.user_view_actions import (
handle_block_users,
handle_deduct_credits,
handle_grant_credits,
- handle_set_role,
)
-from app.user.models import RefreshToken, SocialAccount, User
+from app.user.models import SocialAccount, User
logger = logging.getLogger(__name__)
-class UserAdmin(ModelView, model=User):
+class UserAdmin(SuperAdminEditable, ModelView, model=User):
name = "사용자"
name_plural = "사용자 목록"
icon = "fa-solid fa-user"
category = "사용자 관리"
- page_size = 20
+ page_size = 30
column_list = [
"id",
- "kakao_id",
+ "user_uuid",
"email",
"nickname",
"credits",
@@ -36,7 +36,7 @@ class UserAdmin(ModelView, model=User):
column_details_list = [
"id",
- "kakao_id",
+ "user_uuid",
"email",
"nickname",
"profile_image_url",
@@ -73,7 +73,7 @@ class UserAdmin(ModelView, model=User):
]
column_searchable_list = [
- User.kakao_id,
+ User.user_uuid,
User.email,
User.nickname,
User.phone,
@@ -84,7 +84,7 @@ class UserAdmin(ModelView, model=User):
column_sortable_list = [
User.id,
- User.kakao_id,
+ User.user_uuid,
User.email,
User.nickname,
User.credits,
@@ -96,13 +96,13 @@ class UserAdmin(ModelView, model=User):
column_labels = {
"id": "ID",
- "kakao_id": "카카오 ID",
+ "user_uuid": "UUID",
"email": "이메일",
"nickname": "닉네임",
"profile_image_url": "프로필 이미지",
"thumbnail_image_url": "썸네일 이미지",
"phone": "전화번호",
- "name": "실명",
+ "name": "이름",
"birth_date": "생년월일",
"gender": "성별",
"credits": "크레딧",
@@ -119,7 +119,7 @@ class UserAdmin(ModelView, model=User):
@action(
name="block_user",
label="계정 차단",
- confirmation_message="선택한 사용자를 차단하시겠습니까? 로그인이 불가해집니다.",
+ confirmation_message="선택한 사용자를 차단하시겠습니까?",
add_in_list=True,
)
async def block_user_action(self, request: Request) -> RedirectResponse:
@@ -134,27 +134,10 @@ class UserAdmin(ModelView, model=User):
async def unblock_user_action(self, request: Request) -> RedirectResponse:
return await handle_block_users(request, self.identity, block=False)
- @action(
- name="set_role_admin",
- label="권한: admin으로 변경",
- confirmation_message="선택한 사용자를 admin으로 변경하시겠습니까?",
- add_in_list=True,
- )
- async def set_role_admin_action(self, request: Request) -> RedirectResponse:
- return await handle_set_role(request, self.identity, role="admin")
-
- @action(
- name="set_role_user",
- label="권한: user로 변경",
- confirmation_message="선택한 사용자를 일반 user로 변경하시겠습니까?",
- add_in_list=True,
- )
- async def set_role_user_action(self, request: Request) -> RedirectResponse:
- return await handle_set_role(request, self.identity, role="user")
@action(
name="grant_credits_1",
- label="크레딧 +1 충전",
+ label="크레딧 +1",
confirmation_message="선택한 사용자에게 크레딧 1개를 충전하시겠습니까?",
add_in_list=True,
)
@@ -164,7 +147,7 @@ class UserAdmin(ModelView, model=User):
@action(
name="grant_credits_5",
- label="크레딧 +5 충전",
+ label="크레딧 +5",
confirmation_message="선택한 사용자에게 크레딧 5개를 충전하시겠습니까?",
add_in_list=True,
)
@@ -174,7 +157,7 @@ class UserAdmin(ModelView, model=User):
@action(
name="grant_credits_10",
- label="크레딧 +10 충전",
+ label="크레딧 +10",
confirmation_message="선택한 사용자에게 크레딧 10개를 충전하시겠습니까?",
add_in_list=True,
)
@@ -184,7 +167,7 @@ class UserAdmin(ModelView, model=User):
@action(
name="deduct_credits_1",
- label="크레딧 -1 차감",
+ label="크레딧 -1",
confirmation_message="선택한 사용자의 크레딧 1개를 차감하시겠습니까?",
add_in_list=True,
)
@@ -193,70 +176,12 @@ class UserAdmin(ModelView, model=User):
return await handle_deduct_credits(request, self.identity, amount=1, admin_id=admin_id)
-class RefreshTokenAdmin(ModelView, model=RefreshToken):
- name = "리프레시 토큰"
- name_plural = "리프레시 토큰 목록"
- icon = "fa-solid fa-key"
- category = "사용자 관리"
- page_size = 20
-
- column_list = [
- "id",
- "user_id",
- "is_revoked",
- "expires_at",
- "created_at",
- ]
-
- column_details_list = [
- "id",
- "user_id",
- "token_hash",
- "expires_at",
- "is_revoked",
- "created_at",
- "revoked_at",
- "user_agent",
- "ip_address",
- ]
-
- form_excluded_columns = ["created_at", "user"]
-
- column_searchable_list = [
- RefreshToken.user_id,
- RefreshToken.token_hash,
- RefreshToken.ip_address,
- ]
-
- column_default_sort = (RefreshToken.created_at, True)
-
- column_sortable_list = [
- RefreshToken.id,
- RefreshToken.user_id,
- RefreshToken.is_revoked,
- RefreshToken.expires_at,
- RefreshToken.created_at,
- ]
-
- column_labels = {
- "id": "ID",
- "user_id": "사용자 ID",
- "token_hash": "토큰 해시",
- "expires_at": "만료일시",
- "is_revoked": "폐기됨",
- "created_at": "생성일시",
- "revoked_at": "폐기일시",
- "user_agent": "User Agent",
- "ip_address": "IP 주소",
- }
-
-
-class SocialAccountAdmin(ModelView, model=SocialAccount):
+class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
name = "소셜 계정"
name_plural = "소셜 계정 목록"
icon = "fa-solid fa-share-nodes"
category = "사용자 관리"
- page_size = 20
+ page_size = 30
column_list = [
"id",
diff --git a/docs/database-schema/migration_2026_04_29_add_admin_table.sql b/docs/database-schema/migration_2026_04_29_add_admin_table.sql
index f895b64..596ccf1 100644
--- a/docs/database-schema/migration_2026_04_29_add_admin_table.sql
+++ b/docs/database-schema/migration_2026_04_29_add_admin_table.sql
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS `admin` (
username VARCHAR(50) NOT NULL COMMENT '로그인 ID',
password VARCHAR(255) NOT NULL COMMENT '비밀번호',
name VARCHAR(50) NULL COMMENT '표시 이름',
+ role VARCHAR(20) NOT NULL DEFAULT 'superadmin' COMMENT '권한 (superadmin: 전체, viewer: 조회만)',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성화 상태 (0: 비활성화)',
last_login_at DATETIME NULL COMMENT '마지막 로그인 일시',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',