18. 어드민 화면별 설명
이 문서는 htmx 어드민(/admin/*)을 화면별로 정리한 개발자용 지도입니다. 각 화면의 경로(GET/POST)·라우트 함수 위치(file:line)·템플릿·하는 일·필요 권한을 표로 정리하므로, 화면을 고치거나 디버깅할 때 어디를 봐야 하는지 바로 찾을 수 있습니다.
🍎쉽게 말하면 "이 어드민 화면은 어느 파일의 어느 함수가 그리는가"를 찾아 주는 찾아보기 표입니다. 화면에서 이상한 동작을 봤을 때 이 문서에서 경로를 찾아 해당
file:line으로 바로 이동하면 됩니다.참고: 라우터 등록 순서·렌더 헬퍼는
app/admin/__init__.py에 있습니다. 권한·CSRF·세션은app/admin/deps.py, 목록 공통(페이지네이션·정렬·검색)은app/admin/pagination.py에 있습니다.함께 보기: 관리자 콘솔(사용자용)
18.1 어드민 공통 구조
화면별 설명에 들어가기 전, 모든 화면이 공유하는 골격을 먼저 정리합니다.
인증·권한 (deps.py)
인증은 require_user(app/admin/deps.py:71)가 세션 쿠키(admin_session) → Redis 세션 → DB 사용자 확인 → AdminContext 주입의 순서로 처리합니다. AdminContext(app/admin/deps.py:48)는 user, session_id, csrf_token, service_ids를 담습니다. service_ids는 SYSTEM_ADMIN이면 None(전체 접근), SERVICE_MANAGER이면 담당 서비스 UUID 목록입니다.
권한 Depends는 require_role 팩토리(app/admin/deps.py:104)로 만든 두 축약을 씁니다.
| Depends | 정의 위치 | 허용 역할 | 쓰는 화면 |
|---|---|---|---|
require_admin |
app/admin/deps.py:118 |
SYSTEM_ADMIN 전용 | 서비스·계정·감사·전체설정·카드·공지 |
require_any |
app/admin/deps.py:120 |
SYSTEM_ADMIN + SERVICE_MANAGER | 대시보드·구독·결제·정산·요금제·내 정보 |
⚠️주의:
require_any화면이라도 데이터 범위는service_scope(ctx)(app/admin/deps.py:131, =ctx.service_ids)로 갈라집니다. SERVICE_MANAGER는 담당 서비스만 보이며, 비담당 리소스 직접 접근은 403이 아니라 404로 응답합니다(존재 여부 미노출).require_user는 또한GlobalSettings.admin_allowed_ips가 비어 있지 않으면 접속 IP를 검사합니다(루프백 IP는 항상 허용).
미인증 시 AdminAuthRequired(app/admin/deps.py:39)가 발생하고, register_admin_exception_handlers(app/admin/deps.py:190)가 일반 요청은 303 리다이렉트, htmx 요청은 HX-Redirect 헤더(204)로 /admin/login에 보냅니다.
CSRF
모든 admin POST는 validate_csrf(request, ctx)(app/admin/deps.py:123)를 첫 줄에서 호출해야 합니다. 폼 hidden 필드 csrf_token 또는 헤더 X-CSRF-Token을 세션 토큰과 상수시간 비교합니다. 불일치 시 PermissionDeniedError(403). 토큰 값은 템플릿에서 {{ ctx.csrf_token }}로 폼에 주입합니다.
htmx 부분 갱신 패턴 (init.py)
목록 화면은 render_list(app/admin/__init__.py:121)를 사용합니다. HX-Request 헤더가 있으면 리스트 partial(_table.html)만, 없으면 전체 페이지(list.html)를 렌더합니다.
def render_list(request, full_name, partial_name, ctx=None, **extra):
name = partial_name if request.headers.get("HX-Request") else full_name
return render(request, name, ctx=ctx, **extra)
서비스 상세처럼 한 화면에 탭이 여러 개인 경우는 HX-Target 헤더 값으로 partial을 갈라 렌더합니다(app/admin/routes/services.py:329).
render(app/admin/__init__.py)는 공통으로 ?flash/?saved/?alert 쿼리를 컨텍스트에 넣고, saved/alert가 있으면 HX-Trigger: showSaved/showAlert 헤더를 붙여 admin.js가 완료 모달(✓)/경고 모달(!)을 띄우게 합니다. DB 쓰기 성공 후에는 saved_redirect로 ?saved=를, 도메인 규칙 위반 안내는 alert_redirect(요청 033·036)로 ?alert=를 덧붙여 리다이렉트합니다.
또한 킬스위치가 켜져 있으면 require_user가 AdminContext.server_disabled_reason에 사유를 담고, base.html이 모든 어드민 페이지 상단에 점검 배너("결제서버 점검 중 — 사유", 요청 037)를 렌더합니다.
페이지네이션·정렬·검색 (pagination.py)
목록 라우트는 PageParams.from_request(app/admin/pagination.py:32)로 쿼리스트링을 파싱합니다.
| 항목 | 쿼리 파라미터 | 비고 |
|---|---|---|
| 페이지 | page(기본) |
page_param으로 변경 가능(한 화면 다중 페이저 분리) |
| 검색어 | q |
pp.q |
| 정렬 | sort, dir |
sortable 화이트리스트 밖이면 default로 보정 |
| 필터 | filter_keys로 지정 |
pp.filters dict |
paginate(app/admin/pagination.py:115)가 count 쿼리를 내부 생성(count_of)해 실행하며, flatten=True면 단일 엔티티 Row를 엔티티로 평탄화합니다. 날짜 범위 필터는 date_range(app/admin/pagination.py:150)가 from/to(YYYY-MM-DD)를 UTC 반개구간으로 변환합니다. 정렬 가능 컬럼 맵(_*_SORT)과 공유 쿼리 빌더 일부는 app/admin/filters.py에 있습니다.
라우터 등록·내비게이션
라우터 등록은 app/admin/__init__.py:148부터입니다. services_export/services_managers를 services보다 먼저 등록해 /services/export.xlsx가 /services/{service_id}(UUID 경로)에 잡히지 않게 합니다. 대시보드는 prefix="/admin"에서 GET /admin(트레일링 슬래시 없음)이 해석되도록 add_api_route("")로 직접 등록합니다(app/admin/__init__.py:167). 좌측 메뉴(LNB)는 app/admin/templates/base.html:56에 있으며, 관리 카테고리(서비스·계정·전체 설정·감사 로그·공지사항)는 ctx.user.role == 'SYSTEM_ADMIN'일 때만 렌더됩니다.
18.2 로그인·접근
💬라우트 파일:
app/admin/routes/auth.py· 템플릿:login.html,setup_password.html
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/login |
login_page app/admin/routes/auth.py:48 |
login.html |
없음 |
POST /admin/login |
login_submit app/admin/routes/auth.py:83 |
login.html(실패 시) |
없음 |
POST /admin/logout |
logout app/admin/routes/auth.py:128 |
— | require_any + CSRF |
GET /admin/setup-password |
setup_password_page app/admin/routes/auth.py:151 |
setup_password.html |
토큰 |
POST /admin/setup-password |
setup_password_submit app/admin/routes/auth.py:167 |
setup_password.html(실패 시) |
토큰 |
GET /admin/intro |
intro_page app/admin/routes/auth.py:74 |
(정적 HTML) | 없음 |
하는 일: 로그인은 IP당 분당 시도 제한(_login_rate_limited, app/admin/routes/auth.py:33)을 먼저 검사한 뒤 auth_service.login으로 인증하고, 성공 시 admin_session 쿠키(HttpOnly, SameSite=Lax, prod에서만 secure)를 발급하고 /admin으로 보냅니다. 로컬 개발(environment == "dev")에서만 폼에 dev_login_email/dev_login_password를 미리 채우며, 스테이징(stg)·운영(prod)에서는 채우지 않습니다. 비밀번호 설정/재설정은 메일로 받은 token을 hidden으로 전달받아 처리하며, 확인 불일치는 폼 단에서 즉시 오류로 막습니다.
18.3 대시보드
💬라우트 파일:
app/admin/routes/dashboard.py· 템플릿:dashboard.html(+_charts.html인클루드)
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin |
dashboard app/admin/routes/dashboard.py:25 |
dashboard.html |
require_any |
하는 일: build_dashboard(db, ctx.service_ids)(app/services/dashboard.py)로 집계 데이터를 조회해 렌더합니다. is_admin = ctx.user.role == SYSTEM_ADMIN 플래그로 전체 통계·관리자 전용 섹션을 조건부 노출합니다. SERVICE_MANAGER는 service_ids 스코프 내 데이터만 집계됩니다. 등록은 서브라우터가 아니라 __init__.py:167의 add_api_route("")로 직접 합니다.
18.4 서비스 (+등록 카드/키)
💬라우트 파일:
app/admin/routes/services.py,services_managers.py,services_export.py템플릿:services/디렉터리(list.html,_table.html,new.html,detail.html,keys.html,_keys_modal.html,_plans_table.html,_subs_table.html,_cards_table.html,_oneoff_table.html,_events_table.html)
모든 서비스 엔드포인트는 SYSTEM_ADMIN 전용(require_admin)입니다.
목록·등록·키
| 경로 | 라우트 함수 | 템플릿 |
|---|---|---|
GET /admin/services |
services_list app/admin/routes/services.py:61 |
services/list.html / _table.html(htmx) |
GET /admin/services/new |
services_new app/admin/routes/services.py:89 |
services/new.html |
POST /admin/services |
services_create app/admin/routes/services.py:97 |
services/keys.html(성공) |
GET /admin/services/export.xlsx |
services_export app/admin/routes/services_export.py:31 |
(xlsx) |
GET /admin/services/{id}/keys-modal |
services_keys_modal app/admin/routes/services.py:145 |
services/_keys_modal.html |
POST /admin/services/{id}/rotate-keys |
services_rotate app/admin/routes/services.py:356 |
services/keys.html |
하는 일: 등록 성공 시 평문 API 키·HMAC 시크릿을 일회성으로 keys.html에 표시합니다(키는 암호화 저장되므로 평문을 볼 수 있는 유일한 기회). 키 복사 모달과 재발급은 감사 로그를 남기고 Cache-Control: no-store로 캐시를 막습니다. 등록 폼(new.html)·상세(detail.html)에서 서비스별 토스 시크릿 키를 입력할 수 있으며(쓰기 전용 — 저장 후 재표시 안 함, 상세엔 "설정됨/미설정"만), AES 암호화 저장됩니다(아래 설정 변경 표의 toss-secret-key 참조).
상세 (탭)
| 경로 | 라우트 함수 | 템플릿 |
|---|---|---|
GET /admin/services/{id} |
services_detail app/admin/routes/services.py:291 |
services/detail.html / 탭 partial |
상세는 HX-Target 헤더로 탭 partial을 갈라 렌더합니다(app/admin/routes/services.py:329).
HX-Target |
partial | 탭 데이터 빌더 |
|---|---|---|
list-svc-plans |
services/_plans_table.html |
_plans_tab app/admin/routes/services.py:169 |
list-svc-subs |
services/_subs_table.html |
_subs_tab app/admin/routes/services.py:181 |
list-svc-cards |
services/_cards_table.html |
_cards_tab app/admin/routes/services.py:217 |
list-svc-oneoff |
services/_oneoff_table.html |
_oneoff_tab app/admin/routes/services.py:193 |
list-svc-events |
services/_events_table.html |
_events_tab app/admin/routes/services.py:244 |
💡참고: 등록 카드 탭은
cards테이블을(service_id, external_user_id)당 1건으로 페이징합니다(kpage, 10건). 단건결제 탭은kind == ONE_OFF고정(opage), 이벤트 탭은 이 서비스 관련 감사 로그(서비스·요금제·담당자 할당·카드)를 모읍니다(epage).
설정 변경 (POST)
| 경로 | 라우트 함수 | 하는 일 |
|---|---|---|
POST /admin/services/{id}/ips |
services_update_ips app/admin/routes/services.py:376 |
허용 IP 목록 갱신(줄바꿈/콤마 파싱) |
POST /admin/services/{id}/cancel-policy |
services_cancel_policy app/admin/routes/services.py:394 |
단건결제 취소 허용·수수료율 |
POST /admin/services/{id}/notification-url |
services_notification_url app/admin/routes/services.py:462 |
아웃고잉 웹훅 URL 저장(빈값=NULL) |
POST /admin/services/{id}/notification-test |
services_notification_test app/admin/routes/services.py:491 |
테스트 알림 동기 전송 |
POST /admin/services/{id}/toss-secret-key |
services_set_toss_secret_key app/admin/routes/services.py:425 |
서비스별 토스 시크릿 키 설정/교체(AES 암호화 저장). 빈 값이면 변경 없음. 감사: service.toss_secret_key.set/.changed(값 미기록). 키 미설정 서비스는 결제 시 TOSS_KEY_NOT_CONFIGURED(422) |
POST /admin/services/{id}/toss-secret-key/delete |
services_delete_toss_secret_key app/admin/routes/services.py:447 |
토스 시크릿 키 삭제(키 설정 시에만 버튼 노출). 감사: service.toss_secret_key.deleted |
POST /admin/services/{id}/status |
services_set_status app/admin/routes/services.py:514 |
서비스 상태(ACTIVE/INACTIVE) |
POST /admin/services/{id}/delete |
services_delete app/admin/routes/services.py:527 |
삭제(구독 있으면 DomainError 거부) |
담당자 관리 (services_managers.py)
| 경로 | 라우트 함수 |
|---|---|
POST /admin/services/{id}/assign-manager |
services_assign_manager app/admin/routes/services_managers.py:57 |
POST /admin/services/{id}/primary-manager |
services_set_primary_manager app/admin/routes/services_managers.py:76 |
POST /admin/services/{id}/managers/{user_id}/remove |
services_remove_manager app/admin/routes/services_managers.py:95 |
💡참고: 담당자 목록 헬퍼
service_managers(app/admin/routes/services_managers.py:30)는 서비스 상세 화면도 사용합니다. 대표 담당자는 해제할 수 없으며 이 규칙은 도메인(accounts.unassign_service)이ConflictError로 강제합니다.
서비스 상세 탭 엑셀 (services_export.py)
GET /admin/services/{id}/subs.xlsx(:45), /oneoff.xlsx(:67), /plans.xlsx(:91) — 각 탭의 현재 검색/필터를 그대로 적용해 다운로드합니다(공유 쿼리 빌더 사용, 행 상한 EXPORT_MAX_ROWS).
18.5 요금제
💬라우트 파일:
app/admin/routes/plans.py· 템플릿:plans/list.html,plans/_table.html,plans/form.html
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/plans |
plans_list app/admin/routes/plans.py:195 |
plans/list.html / _table.html(htmx) |
require_any |
GET /admin/plans/export.xlsx |
plans_export app/admin/routes/plans.py:169 |
(xlsx) | require_any |
GET /admin/plans/new |
plans_new app/admin/routes/plans.py:251 |
plans/form.html |
require_manager |
POST /admin/plans |
plans_create app/admin/routes/plans.py:268 |
plans/form.html(실패 시) |
require_manager |
GET /admin/services/{id}/plans/new |
service_plan_new app/admin/routes/plans.py:319 |
plans/form.html |
require_any + _can_manage |
POST /admin/services/{id}/plans |
service_plan_create app/admin/routes/plans.py:346 |
plans/form.html(실패 시) |
require_any + _can_manage |
GET /admin/plans/{id}/edit |
plans_edit app/admin/routes/plans.py:388 |
plans/form.html |
require_any |
POST /admin/plans/{id} |
plans_update app/admin/routes/plans.py:403 |
plans/form.html(실패 시) |
require_any |
POST /admin/plans/{id}/archive |
plans_archive app/admin/routes/plans.py:435 |
— | require_any |
POST /admin/plans/{id}/activate |
plans_activate app/admin/routes/plans.py:451 |
— | require_any |
POST /admin/plans/{id}/delete |
plans_delete app/admin/routes/plans.py:467 |
— | require_any |
POST /admin/plans/{id}/bonus-days |
plans_bonus_days app/admin/routes/plans.py:496 |
— | require_any |
하는 일: 목록은 각 Plan에 표시용 금액·툴팁(plan_first_amount/plan_recurring_amount/*_breakdown)을 동적으로 주입합니다. 폼 파싱은 _form_plan_fields와 추가정보 수집 _collect_extra_info가 담당합니다. 권한 분기(요청 040): 요금제 생성은 해당 서비스의 대표 담당자만 가능 — require_manager 진입점은 폼 첫 항목에서 _primary_services(자신이 Service.manager_email인 서비스 목록) 중 하나를 선택받아 검증하고, 서비스 상세 경유(require_any + _can_manage)도 담당자는 대표일 때만 허용(비대표는 403)합니다. SYSTEM_ADMIN은 제한 없음. _authorize_plan은 비담당 요금제를 404로 처리합니다. 가격은 전체 설정의 상한(plan_price_max, 기본 900,000,000원)까지 허용됩니다(요청 038·039).
⚠️주의: 결제 주기(
billing_cycle/cycle_days/cycle_minutes)는 수정 불가입니다 — 폼이 보내지 않고update_plan도 인자를 받지 않아 기존 주기가 유지됩니다.MINUTE(분) 주기는 자동연장 테스트용으로 비운영(environment != prod)에서만 선택칸이 노출되며cycle_minutes(최소 5)를 받습니다. 삭제/보너스일은 next URL을_safe_next(:44)로 open redirect 방어합니다(반드시/admin/로 시작).
18.6 구독
💬라우트 파일:
app/admin/routes/subscriptions.py· 템플릿:subscriptions/list.html,subscriptions/_table.html,subscriptions/detail.html
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/subscriptions |
subscriptions_list app/admin/routes/subscriptions.py:75 |
subscriptions/list.html / _table.html(htmx) |
require_any |
GET /admin/subscriptions/export.xlsx |
subscriptions_export app/admin/routes/subscriptions.py:51 |
(xlsx) | require_any |
GET /admin/subscriptions/{id} |
subscription_detail app/admin/routes/subscriptions.py:109 |
subscriptions/detail.html |
require_any(스코프) |
POST /admin/subscriptions/{id}/force-cancel |
subscription_force_cancel app/admin/routes/subscriptions.py:202 |
— | require_any + CSRF |
POST /admin/subscriptions/{id}/extend |
subscription_extend app/admin/routes/subscriptions.py:222 |
— | require_any + CSRF |
POST /admin/subscriptions/{id}/retry-payment |
subscription_retry_payment app/admin/routes/subscriptions.py:257 |
— | require_any + CSRF |
하는 일: 목록·엑셀·서비스 상세 탭은 공유 빌더 subscription_query(app/admin/filters.py)로 동일 필터를 보장합니다(스코프는 service_scope(ctx)). 상세는 최근 결제 200건 + DONE 건수, 연장 이력(subscription.extended 감사로그), 체험 사용 여부(subscription.create 감사 detail), 등록 카드(card_service.get_card로 cards 테이블 조회)를 함께 렌더합니다. 강제 해지/연장/재결제는 스코프·감사 기록을 서비스 레이어에 위임하고, 도메인 오류는 ?error=로 상세 페이지에 표시합니다(스코프 밖은 404 전파).
18.7 결제 (+취소)
💬라우트 파일:
app/admin/routes/payments.py· 템플릿:payments/list.html,payments/detail.html
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/payments |
payments_list app/admin/routes/payments.py:250 |
payments/list.html |
require_any |
GET /admin/payments/export.xlsx |
payments_export app/admin/routes/payments.py:104 |
(xlsx) | require_any |
GET /admin/payments/{id} |
payment_detail app/admin/routes/payments.py:208 |
payments/detail.html |
require_any(스코프) |
POST /admin/payments/{id}/cancel |
payment_cancel app/admin/routes/payments.py:131 |
— | require_any + CSRF |
POST /admin/payments/cancellations/{id}/memo |
cancellation_memo_save app/admin/routes/payments.py:177 |
— | require_any(스코프) + CSRF |
하는 일: 목록은 partial이 없어 render로 전체 페이지만 렌더합니다(htmx 부분 갱신 대상 아님). 목록 각 행에는 매출전표 열이 있어, 저장된 raw_response.receipt.url이 있으면 토스 매출전표(영수증)로 가는 새 탭 링크를, 없으면 -를 표시합니다(receipt_url(p) 템플릿 전역, app/admin/__init__.py). 공유 쿼리 _build_payments_query(:48)는 단건(ONE_OFF) 결제를 포함하려고 Subscription/Plan을 OUTER JOIN, Service는 INNER JOIN합니다.
목록은 페이지 내 결제들의 취소 상세 원장(payment_cancellations)을 1쿼리로 일괄 조회해, 취소 이력이 있는 결제 행 아래에 회차별 하위 행("↳ 취소 N회차 · 사유·주체 · 환불액·수수료 · 시각")을 붙입니다. 각 하위 행 끝의 메모 버튼은 공유 <dialog> 모달로 회차별 메모(최대 150자)를 입력/수정하고(cancellation_memo_save — 스코프 검사·150자 초과는 경고 리다이렉트·빈 값은 삭제), 메모가 있으면 버튼 호버 시 data-tip 팝업으로 내용이 보입니다. 모달이 열려 있는 동안 배경(main.content·사이드바)의 스크롤·휠 이벤트를 잠급니다.
상세는 구독 결제면 Subscription을 추가 조회하고, 결제 카드(card_service.get_card)·누적 환불액·잔여 환불가능액과 함께 취소 원장 행들을 시간순으로 조회해 전달합니다(:208). 상세의 「취소·환불 내역」 카드는 최초 결제금액·누적 환불·잔여 요약 아래 회차별 취소 표(회차·시각·환불액·수수료·주체·사유)를 보여주며, 요금제 변경 환불 등으로 취소 이력이 생긴 구독 결제에도 표시됩니다(관리자 취소 실행 카드는 단건 전용). 취소는 단건(ONE_OFF) 결제 대상이며 폼 cancel_amount가 비면 전액, 숫자면 부분(누적) 취소입니다. 어드민 취소는 수수료 없이 항상 허용되며 상태(DONE)·잔여 한도 검증은 도메인이 합니다. 취소 확정 시 누적 캐시 갱신과 함께 취소 원장에 회차 1행이 같은 트랜잭션으로 기록됩니다.
18.8 카드 상세/토글
💬라우트 파일:
app/admin/routes/cards.py· 템플릿:cards/detail.html
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/cards/{id} |
cards_detail app/admin/routes/cards.py:67 |
cards/detail.html |
require_admin |
POST /admin/cards/{id}/toggle |
cards_toggle app/admin/routes/cards.py:32 |
services/_cards_table.html(htmx) |
require_admin + CSRF |
하는 일: 카드 상세는 등록 카드 정보 + 이 카드로 결제한 내역을 보여줍니다. Payment에는 card_id가 없으므로 (service_id, external_user_id)가 일치하는 결제를 페이징합니다(구독·일반 모두 포함). 토글은 set_card_active로 활성↔비활성을 반전합니다(비활성화 시 해당 카드 결제 차단).
💡참고: 토글 응답은 호출 위치에 따라 다릅니다. 서비스 상세 '등록 카드' 리스트에서 호출(htmx)이면 갱신된
services/_cards_table.htmlpartial을, 카드 상세에서 호출(일반 요청)이면 카드 상세로 리다이렉트합니다. partial 재렌더에는services라우트의_cards_tab을 함수 내부에서 import해 씁니다(순환 import 방지).
18.9 정산
💬라우트 파일:
app/admin/routes/settlement.py· 템플릿:settlement/index.html
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/settlement |
settlement_view app/admin/routes/settlement.py:100 |
settlement/index.html |
require_any |
GET /admin/settlement/export.xlsx |
settlement_export app/admin/routes/settlement.py:165 |
(xlsx) | require_any |
하는 일: 두 가지 모드가 있습니다. 전체 모드(service_id 미지정)는 settlement_summary로 스코프 내 서비스별 요약 테이블을 만들고 구독/일반 매출·환불·순매출을 합산합니다. 서비스별 모드(service_id 지정)는 _settlement_payment_query(:35)로 그 서비스의 결제 건별 페이지를 추가 조회합니다(상태 DONE+CANCELED 포함). 기간·스코프·선택 서비스 판정은 공통 헬퍼 _settlement_context(:60)가 처리하며 기본 기간은 당월 1일~오늘입니다. SERVICE_MANAGER가 담당하지 않는 서비스 ID 지정 시 404입니다.
18.10 계정
💬라우트 파일:
app/admin/routes/users.py· 템플릿:users/list.html,users/_table.html,users/new.html,users/detail.html,users/edit.html
모든 계정 엔드포인트는 SYSTEM_ADMIN 전용(require_admin)입니다.
| 경로 | 라우트 함수 | 템플릿 |
|---|---|---|
GET /admin/users |
users_list app/admin/routes/users.py:80 |
users/list.html / _table.html(htmx) |
GET /admin/users/export.xlsx |
users_export app/admin/routes/users.py:100 |
(xlsx) |
GET /admin/users/new |
users_new app/admin/routes/users.py:116 |
users/new.html |
POST /admin/users |
users_create app/admin/routes/users.py:124 |
users/new.html(실패 시) |
GET /admin/users/{id} |
users_detail app/admin/routes/users.py:154 |
users/detail.html |
POST /admin/users/{id}/services |
users_assign_service app/admin/routes/users.py:175 |
— |
POST /admin/users/{id}/services/{service_id}/remove |
users_unassign_service app/admin/routes/users.py:193 |
— |
GET /admin/users/{id}/edit |
users_edit app/admin/routes/users.py:213 |
users/edit.html |
POST /admin/users/{id}/edit |
users_update app/admin/routes/users.py:224 |
users/edit.html(실패 시) |
POST /admin/users/{id}/disable |
users_disable app/admin/routes/users.py:243 |
— |
POST /admin/users/{id}/delete |
users_delete app/admin/routes/users.py:271 |
— |
POST /admin/users/{id}/reset-password |
users_reset_password app/admin/routes/users.py:288 |
— |
하는 일: 목록·엑셀 공유 쿼리 _build_users_query(:34)는 DELETED 계정을 기본 제외하고 Service를 LEFT OUTER JOIN합니다(담당 서비스 없는 계정 누락 방지). 계정 생성/비밀번호 재설정은 메일 발송 결과를 email_flash_qs로 토스트에 표시합니다. 비활성화·삭제는 Redis로 기존 세션을 즉시 무효화합니다. 비활성화는 체크박스가 아니라 hidden disabled("true"/"false") 문자열로 의도를 명확히 전달합니다(:249).
💡참고: 상세의
managed(현재 담당 서비스)·assignable(미담당 서비스) 구분은 추가 할당 드롭다운에 쓰입니다. 담당 서비스 카드는 각 서비스에 대해Service.manager_email == account.email여부로 대표/일반 배지를 표시하고, 서비스명은 서비스 상세로 링크됩니다(요청).
18.11 감사로그
💬라우트 파일:
app/admin/routes/audit.py· 템플릿:audit/list.html,audit/_table.html
모든 감사 엔드포인트는 SYSTEM_ADMIN 전용(require_admin)입니다.
| 경로 | 라우트 함수 | 템플릿 |
|---|---|---|
GET /admin/audit |
audit_list app/admin/routes/audit.py:138 |
audit/list.html / _table.html(htmx) |
GET /admin/audit/export.xlsx |
audit_export app/admin/routes/audit.py:156 |
(xlsx) |
POST /admin/audit/purge |
audit_purge app/admin/routes/audit.py:178 |
— |
하는 일: 목록·엑셀 공유 쿼리 _build_audit_query(:96)는 키워드(q)를 행위자 이메일·서비스명·target_id·detail JSON에서 검색하고, 행위자 유형(actor_type)·활동(action)으로 필터합니다. _resolve_names(:53)가 actor/target UUID를 배치 조회해 사람이 읽는 이름으로 바꾸고, _build_rows(:115)가 화면/엑셀 공용 dict로 변환합니다(라벨은 app/admin/audit_labels.py). purge는 기준일(UTC 자정) 이전 로그를 일괄 삭제하고 삭제 행위 자체를 audit.purge로 감사 기록합니다.
⚠️주의: audit 화면에는
?error=표시 블록이 없어 입력 오류는?flash=…&flash_type=error토스트로 통일합니다(:189).
18.12 전체 설정
💬라우트 파일:
app/admin/routes/settings.py· 템플릿:settings/index.html
모든 전체설정 엔드포인트는 SYSTEM_ADMIN 전용(require_admin)입니다. 단일 화면(settings/index.html)에 여러 폼이 섹션으로 들어가고, 각 섹션이 별도 POST로 저장됩니다.
| 경로 | 라우트 함수 | 하는 일 |
|---|---|---|
GET /admin/settings |
settings_page app/admin/routes/settings.py:26 |
현재 GlobalSettings 렌더 |
POST /admin/settings/retry |
settings_retry app/admin/routes/settings.py:48 |
자동결제 재시도(횟수·간격·유예일) |
POST /admin/settings/security-policy |
settings_security_policy app/admin/routes/settings.py:78 |
로그인 잠금 임계치·잠금시간·단건결제 상한·요금제 가격 상한(plan_price_max, 요청 039)·사용일 추가 최대(bonus_days_max, 요청 040)(런타임 즉시 적용) |
POST /admin/settings/admin-ips |
settings_admin_ips app/admin/routes/settings.py:113 |
어드민 접속 허용 IP(줄바꿈 구분) |
POST /admin/settings/server-toggle |
settings_server_toggle app/admin/routes/settings.py:151 |
결제서버 킬스위치(활성/비활성) |
하는 일: 저장값은 app/services/app_settings.py를 통해 GlobalSettings에 반영됩니다. 각 POST는 성공 시 ?saved=(완료 모달)로 돌아오고, 실패 시 재시도·보안/결제 정책은 ?alert=(경고 모달, 요청 036)로, 어드민 IP·킬스위치는 ?error=(인라인 메시지)로 같은 화면에 돌아옵니다.
⚠️주의: 어드민 IP 저장은 lockout 방지를 위해 현재 접속 IP가 목록에 없으면
InputValidationError로 거부합니다. 킬스위치 비활성화는 사유(reason)와 작업자 본인 비밀번호 재확인(password)이 필요하고, Redis로 킬스위치 캐시를 즉시 무효화해 전파 지연을 없앱니다.
18.13 가이드(관리자 매뉴얼)
💬라우트 파일:
app/admin/routes/guide.py· 템플릿:guide.html문서 소스:docs/admin_manual/디렉터리의 마크다운(.md)을 런타임에 HTML로 변환해 렌더
어드민 콘솔 안에서 관리자용 매뉴얼을 읽는 화면입니다. 라우터는 prefix="/guide"로 등록됩니다(app/admin/__init__.py:164).
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/guide |
guide_index app/admin/routes/guide.py:43 |
guide.html(기본 dashboard 가이드) |
require_any |
GET /admin/guide/{guide_id} |
guide_page app/admin/routes/guide.py:24 |
guide.html |
require_any |
하는 일: GUIDES 목록(app/admin/routes/guide.py:13)에 정의된 guide_id→파일 매핑으로 docs/admin_manual/ 아래 마크다운을 찾아 markdown(extensions: extra/toc/sane_lists/attr_list)으로 변환해 좌측 가이드 메뉴(guides)와 함께 렌더합니다. guide_id가 매핑에 없거나 파일이 없으면 404입니다. GET /admin/guide(index)는 기본값으로 dashboard 가이드를 그대로 위임 렌더합니다.
현재 제공 가이드(guide_id → 문서):
guide_id |
제목 | 문서 파일 |
|---|---|---|
dashboard |
대시보드 | docs/admin_manual/01-dashboard.md |
subscriptions |
구독 관리 | docs/admin_manual/02-subscriptions.md |
payments |
결제 관리 | docs/admin_manual/03-payments.md |
plans |
요금제 관리 | docs/admin_manual/04-plans.md |
services |
서비스 관리 | docs/admin_manual/05-services.md |
settlement |
정산 관리 | docs/admin_manual/06-settlement.md |
audit |
감사 및 계정 | docs/admin_manual/07-audit-and-users.md |
💡참고: 이 화면이 읽는
docs/admin_manual/은 관리자(사용자)용 매뉴얼이고, 지금 보고 있는docs/user_manual/(개발자용)과는 별개 디렉터리입니다. SERVICE_MANAGER도 접근할 수 있으나, 가이드 내용은 권한별로 분기하지 않습니다.
18.14 공지사항 게시판
💬라우트 파일:
app/admin/routes/notices.py· 템플릿:notices/list.html,notices/_table.html,notices/form.html
로그인 화면에 노출되는 공지(배포내역·서버작업내역 등)를 관리합니다. 모든 관리 엔드포인트는 SYSTEM_ADMIN 전용(require_admin)이고, 사이드바 표시용 조회인 GET /admin/deploy-title만 예외로 require_any입니다.
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/notices |
notices_list app/admin/routes/notices.py:74 |
notices/list.html / _table.html(htmx) |
require_admin |
GET /admin/notices/new |
notices_new app/admin/routes/notices.py:93 |
notices/form.html |
require_admin |
POST /admin/notices |
notices_create app/admin/routes/notices.py:99 |
notices/form.html(실패 시) |
require_admin |
GET /admin/notices/{id}/edit |
notices_edit app/admin/routes/notices.py:113 |
notices/form.html |
require_admin |
POST /admin/notices/{id}/edit |
notices_update app/admin/routes/notices.py:122 |
notices/form.html(실패 시) |
require_admin |
POST /admin/notices/{id}/delete |
notices_delete app/admin/routes/notices.py:139 |
— | require_admin |
GET /admin/deploy-title |
latest_deploy_title app/admin/routes/notices.py:31 |
(텍스트) | require_any |
하는 일: 공지 폼(_form_fields, :61)은 타입(notice_type — 배포 DEPLOY·작업 WORK·알림 ALERT·기타 ETC, app/models/notice.py의 NOTICE_TYPES)·제목·본문과 체크박스 3개 — 게시(is_published), 마크다운 렌더(is_markdown), 상단노출(is_pinned) — 을 받습니다. 작업(WORK) 타입만 시작·종료 일시(work_start_at/work_end_at, KST 입력 → UTC 저장)를 추가로 받습니다.
- 게시(
is_published): 켜진 글만 로그인 화면 아래 공지 리스트에 보입니다(끄면 임시 저장). - 상단노출(
is_pinned): 켜진 글이 화면 상단 배너 대상입니다. WORK 타입은 여기에 종료 전(work_end_at >= now) 조건이 추가되고, 다른 타입은 체크를 풀 때까지 계속 노출됩니다(app/services/notices.py의list_active_work). GET /admin/deploy-title: 게시 상태 최신 DEPLOY 공지 1건의 제목을 텍스트로 돌려줍니다 — 사이드바 브랜드 옆 표시용(htmx load). 없으면 빈 문자열입니다.
💡참고: 상단노출된 WORK 공지는 무인증 외부 API
GET /api/v1/work-notices(app/api/v1/services.py:43)로도 나갑니다 — 샘플 서비스의 주황색 작업 배너가 이 API를 씁니다(19장 참고).
18.15 내 정보(프로필)
💬라우트 파일:
app/admin/routes/profile.py· 템플릿:profile.html
역할과 무관하게(SYSTEM_ADMIN/SERVICE_MANAGER 모두) 자신의 이메일·전화번호를 고치고 비밀번호를 바꾸는 화면입니다. 사이드바 하단의 사용자 이메일을 누르면 이 화면으로 옵니다.
| 경로 | 라우트 함수 | 템플릿 | 권한 |
|---|---|---|---|
GET /admin/profile |
profile_page app/admin/routes/profile.py:23 |
profile.html |
require_any |
POST /admin/profile |
profile_update app/admin/routes/profile.py:29 |
profile.html(실패 시) |
require_any + CSRF |
POST /admin/profile/password |
profile_change_password app/admin/routes/profile.py:45 |
profile.html(실패 시) |
require_any + CSRF |
하는 일: 수정 대상은 항상 본인(ctx.user)입니다. 비밀번호 변경은 현재 비밀번호 확인 후 처리하며, 성공 시 현재 세션은 유지하고 다른 기기의 세션은 모두 파기합니다(auth_service.change_own_password).