라우터 (667개 엔드포인트, P3 신규 69개):
- multimodal.py: llava 이미지 분석 + 에러 자동 분류
- learning_loop.py: Ollama 파인튜닝 + 품질 지표
- ai_insights.py: 주간 인사이트 + 반복 패턴 + 개선 권고
- container_alerts.py: Docker 이상 감지 → SR 자동 생성
- ncloud.py: NCloud API (서버/LB/스토리지/비용)
- billing.py: 구독 플랜 + 사용량 측정 + 청구서
- servicenow.py: ServiceNow CMDB/Incident 양방향 연동
- erp_connector.py: 그룹웨어/HR ERP 연동 + 결재 웹훅
- kakao_notify.py: 카카오 알림톡 + 대량 발송
- auto_report.py: Excel/PDF 보고서 자동 생성·다운로드
- benchmark.py: 기관 간 익명 벤치마킹 (완전 익명화)
- cohort_analysis.py: 도입 코호트 + 리텐션 + 기능 도입률
DB 모델 (14개 신규 테이블):
tb_learning_run, tb_container_alert_{rule,log},
tb_ncloud_config, tb_subscription, tb_invoice,
tb_servicenow_{config,mapping}, tb_erp_config,
tb_kakao_{config,notify_log}, tb_report_{record,schedule},
tb_benchmark_contrib
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
212 lines
6.6 KiB
Python
212 lines
6.6 KiB
Python
"""
|
|
구독·과금 시스템 — 플랜 관리 + 사용량 측정 + 청구서 생성
|
|
|
|
엔드포인트:
|
|
GET /api/billing/plans — 플랜 목록
|
|
GET /api/billing/subscription — 현재 구독 정보
|
|
POST /api/billing/subscription — 구독 플랜 변경
|
|
GET /api/billing/usage — 이번 달 사용량
|
|
GET /api/billing/invoices — 청구서 목록
|
|
GET /api/billing/invoices/{id} — 청구서 상세
|
|
POST /api/billing/invoices/generate — 청구서 수동 생성
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user, require_admin_role
|
|
from database import get_db
|
|
from models import User, Server, SRRequest, Subscription, Invoice # 신규
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/billing", tags=["Billing"])
|
|
|
|
PLANS = {
|
|
"COMMUNITY": {
|
|
"name": "커뮤니티",
|
|
"price_monthly": 0,
|
|
"max_servers": 20, "max_users": 10,
|
|
"features": ["SR 관리", "CMDB 기본", "대시보드"],
|
|
},
|
|
"STANDARD": {
|
|
"name": "스탠다드",
|
|
"price_monthly": 500000,
|
|
"max_servers": 200, "max_users": 100,
|
|
"features": ["COMMUNITY 포함", "AI 에이전트", "SLA 관리", "보고서"],
|
|
},
|
|
"ENTERPRISE": {
|
|
"name": "엔터프라이즈",
|
|
"price_monthly": None, # 협의
|
|
"max_servers": -1, "max_users": -1,
|
|
"features": ["STANDARD 포함", "무제한 서버", "FinOps", "전담 지원"],
|
|
},
|
|
}
|
|
|
|
|
|
class PlanChangeRequest(BaseModel):
|
|
plan: str
|
|
billing_cycle: str = "MONTHLY" # MONTHLY | YEARLY
|
|
|
|
|
|
@router.get("/plans")
|
|
async def list_plans():
|
|
return [
|
|
{
|
|
"code": k, **v,
|
|
"price_display": f"월 {v['price_monthly']:,}원" if v['price_monthly'] else "협의",
|
|
}
|
|
for k, v in PLANS.items()
|
|
]
|
|
|
|
|
|
@router.get("/subscription")
|
|
async def get_subscription(
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
row = await db.execute(
|
|
select(Subscription).where(
|
|
Subscription.tenant_id == user.tenant_id,
|
|
Subscription.is_active == True,
|
|
)
|
|
)
|
|
sub = row.scalar_one_or_none()
|
|
if not sub:
|
|
# 기본 COMMUNITY 플랜 반환
|
|
return {
|
|
"plan": "COMMUNITY", "billing_cycle": "MONTHLY",
|
|
"status": "ACTIVE", "price": 0,
|
|
"next_billing": None, "is_trial": True,
|
|
}
|
|
plan_info = PLANS.get(sub.plan, {})
|
|
return {
|
|
"plan": sub.plan, "plan_name": plan_info.get("name"),
|
|
"billing_cycle": sub.billing_cycle, "status": sub.status,
|
|
"price": plan_info.get("price_monthly", 0),
|
|
"start_date": sub.start_date, "next_billing": sub.next_billing_date,
|
|
"is_trial": sub.is_trial,
|
|
}
|
|
|
|
|
|
@router.post("/subscription")
|
|
async def change_plan(
|
|
req: PlanChangeRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_admin_role),
|
|
):
|
|
if req.plan not in PLANS:
|
|
raise HTTPException(400, f"유효하지 않은 플랜: {req.plan}")
|
|
|
|
row = await db.execute(
|
|
select(Subscription).where(Subscription.tenant_id == user.tenant_id, Subscription.is_active == True)
|
|
)
|
|
sub = row.scalar_one_or_none()
|
|
if sub:
|
|
sub.plan = req.plan
|
|
sub.billing_cycle = req.billing_cycle
|
|
sub.updated_at = datetime.utcnow()
|
|
else:
|
|
from datetime import timedelta
|
|
sub = Subscription(
|
|
tenant_id=user.tenant_id,
|
|
plan=req.plan, billing_cycle=req.billing_cycle,
|
|
status="ACTIVE", is_trial=(req.plan == "COMMUNITY"),
|
|
start_date=date.today(),
|
|
next_billing_date=date.today().replace(day=1) + timedelta(days=32),
|
|
is_active=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
|
)
|
|
db.add(sub)
|
|
await db.commit()
|
|
return {"ok": True, "plan": req.plan}
|
|
|
|
|
|
@router.get("/usage")
|
|
async def get_usage(
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""이번 달 사용량 측정."""
|
|
month_start = date.today().replace(day=1)
|
|
|
|
server_count = (await db.execute(
|
|
select(func.count(Server.id)).where(Server.institution_id == user.tenant_id)
|
|
)).scalar() or 0
|
|
|
|
user_count = (await db.execute(
|
|
select(func.count(User.id)).where(User.tenant_id == user.tenant_id, User.is_active == True)
|
|
)).scalar() or 0
|
|
|
|
sr_count = (await db.execute(
|
|
select(func.count(SRRequest.id)).where(SRRequest.created_at >= month_start)
|
|
)).scalar() or 0
|
|
|
|
# 현재 플랜 한도
|
|
sub = await get_subscription(db, user)
|
|
plan_code = sub.get("plan", "COMMUNITY")
|
|
plan = PLANS.get(plan_code, PLANS["COMMUNITY"])
|
|
|
|
return {
|
|
"period": month_start.isoformat(),
|
|
"servers": {"used": server_count, "limit": plan["max_servers"]},
|
|
"users": {"used": user_count, "limit": plan["max_users"]},
|
|
"sr_this_month": sr_count,
|
|
"plan": plan_code,
|
|
}
|
|
|
|
|
|
@router.post("/invoices/generate")
|
|
async def generate_invoice(
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_admin_role),
|
|
):
|
|
"""이번 달 청구서 수동 생성."""
|
|
usage = await get_usage(db, user)
|
|
sub = await get_subscription(db, user)
|
|
plan_info = PLANS.get(sub.get("plan", "COMMUNITY"), {})
|
|
price = plan_info.get("price_monthly", 0) or 0
|
|
|
|
invoice = Invoice(
|
|
tenant_id=user.tenant_id,
|
|
plan=sub.get("plan"),
|
|
period=date.today().replace(day=1).isoformat(),
|
|
amount=price,
|
|
servers_used=usage["servers"]["used"],
|
|
users_used=usage["users"]["used"],
|
|
sr_count=usage["sr_this_month"],
|
|
status="DRAFT",
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
db.add(invoice)
|
|
await db.commit()
|
|
await db.refresh(invoice)
|
|
return {
|
|
"ok": True, "invoice_id": invoice.id,
|
|
"amount": price, "period": invoice.period,
|
|
"status": "DRAFT",
|
|
}
|
|
|
|
|
|
@router.get("/invoices")
|
|
async def list_invoices(
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
rows = await db.execute(
|
|
select(Invoice).where(Invoice.tenant_id == user.tenant_id)
|
|
.order_by(Invoice.created_at.desc()).limit(24)
|
|
)
|
|
invoices = rows.scalars().all()
|
|
return [
|
|
{"id": i.id, "period": i.period, "amount": i.amount,
|
|
"plan": i.plan, "status": i.status, "created_at": i.created_at}
|
|
for i in invoices
|
|
]
|