guardia-itsm/routers/app_deploy.py

395 lines
14 KiB
Python

"""
모바일 앱 직접 배포 — QR 코드 기반
APK 업로드 → QR 자동 생성 → 랜딩 페이지 → 앱스토어 없이 설치.
공공기관 내부망 배포에 최적화.
엔드포인트:
POST /api/app/upload — APK 파일 업로드 + QR 생성
POST /api/app/url — 외부 URL(EAS 등)로 QR 생성
GET /api/app/latest — 최신 버전 정보 + QR URL
GET /api/app/qr — QR 코드 이미지 (PNG)
GET /api/app/landing — 앱 다운로드 랜딩 페이지 (HTML)
GET /api/app/download — APK 파일 다운로드
GET /api/app/versions — 버전 이력
DELETE /api/app/versions/{id} — 구버전 삭제
GET /api/app/stats — 다운로드 통계
"""
from __future__ import annotations
import base64
import io
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import select, desc, 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, AppVersion, AppDownloadLog
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/app", tags=["앱 배포"])
APK_DIR = Path("/opt/guardia/app/uploads/apk")
APK_DIR.mkdir(parents=True, exist_ok=True)
BASE_URL = "https://zioinfo.co.kr:8443" # 랜딩 페이지 베이스 URL
def _generate_qr(url: str) -> bytes:
"""QR 코드 PNG 생성."""
try:
import qrcode
from qrcode.image.styledpil import StyledPilImage
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="#003366", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
return buf.getvalue()
except ImportError:
# qrcode 미설치 시 빈 1x1 PNG 반환 + 경고
logger.warning("qrcode 라이브러리 미설치. `pip install qrcode[pil]` 실행 필요.")
# 최소 PNG (1x1 흰 픽셀)
min_png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
return min_png
class UrlRequest(BaseModel):
android_url: str
ios_url: Optional[str] = None
version: str = "latest"
release_notes: Optional[str] = None
@router.post("/upload")
async def upload_apk(
file: UploadFile = File(...),
version: str = Form(..., description="버전 (예: 1.2.3)"),
release_notes: str = Form("", description="업데이트 내용"),
ios_url: str = Form("", description="iOS TestFlight URL"),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""APK 파일 업로드 → QR 코드 자동 생성."""
if not file.filename or not file.filename.endswith('.apk'):
raise HTTPException(400, "APK 파일만 업로드 가능합니다 (.apk)")
file_bytes = await file.read()
if len(file_bytes) > 200 * 1024 * 1024: # 200MB
raise HTTPException(413, "파일 크기 초과 (최대 200MB)")
# 고유 파일명으로 저장
filename = f"guardia_messenger_{version}_{uuid.uuid4().hex[:8]}.apk"
file_path = APK_DIR / filename
file_path.write_bytes(file_bytes)
# 기존 latest 해제
from sqlalchemy import update as sa_update
await db.execute(
sa_update(AppVersion)
.where(AppVersion.is_latest == True)
.values(is_latest=False)
)
# 랜딩 페이지 URL → QR
token = uuid.uuid4().hex
landing_url = f"{BASE_URL}/api/app/landing?token={token}"
qr_bytes = _generate_qr(landing_url)
qr_b64 = base64.b64encode(qr_bytes).decode()
app_ver = AppVersion(
version=version,
platform="ANDROID",
file_path=str(file_path),
file_size_mb=round(len(file_bytes) / 1024 / 1024, 2),
android_url=f"{BASE_URL}/api/app/download?token={token}",
ios_url=ios_url or None,
landing_token=token,
qr_data=qr_b64,
release_notes=release_notes,
download_count=0,
is_latest=True,
uploaded_by=user.id,
created_at=datetime.utcnow(),
)
db.add(app_ver)
await db.commit()
await db.refresh(app_ver)
return {
"ok": True,
"version_id": app_ver.id,
"version": version,
"qr_url": f"{BASE_URL}/api/app/qr?token={token}",
"landing_url": landing_url,
"download_url": app_ver.android_url,
"file_size_mb": round(len(file_bytes) / 1024 / 1024, 1),
}
@router.post("/url")
async def set_app_url(
req: UrlRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""외부 URL(EAS 빌드 등)로 QR 코드 생성."""
from sqlalchemy import update as sa_update
await db.execute(
sa_update(AppVersion)
.where(AppVersion.is_latest == True)
.values(is_latest=False)
)
token = uuid.uuid4().hex
landing_url = f"{BASE_URL}/api/app/landing?token={token}"
qr_bytes = _generate_qr(landing_url)
app_ver = AppVersion(
version=req.version,
platform="BOTH" if req.ios_url else "ANDROID",
android_url=req.android_url,
ios_url=req.ios_url,
landing_token=token,
qr_data=base64.b64encode(qr_bytes).decode(),
release_notes=req.release_notes,
download_count=0,
is_latest=True,
uploaded_by=user.id,
created_at=datetime.utcnow(),
)
db.add(app_ver)
await db.commit()
await db.refresh(app_ver)
return {
"ok": True,
"version_id": app_ver.id,
"qr_url": f"{BASE_URL}/api/app/qr?token={token}",
"landing_url": landing_url,
}
@router.get("/latest")
async def get_latest(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""최신 버전 정보 조회."""
row = await db.execute(
select(AppVersion).where(AppVersion.is_latest == True)
)
ver = row.scalar_one_or_none()
if not ver:
return {"has_version": False}
return {
"has_version": True,
"version": ver.version,
"platform": ver.platform,
"download_count": ver.download_count,
"qr_url": f"{BASE_URL}/api/app/qr?token={ver.landing_token}",
"landing_url": f"{BASE_URL}/api/app/landing?token={ver.landing_token}",
"release_notes": ver.release_notes,
"created_at": ver.created_at,
}
@router.get("/qr")
async def get_qr_image(
token: str,
db: AsyncSession = Depends(get_db),
):
"""QR 코드 이미지 반환 (PNG). 인증 불필요 — 공유 가능."""
row = await db.execute(
select(AppVersion).where(AppVersion.landing_token == token)
)
ver = row.scalar_one_or_none()
if not ver or not ver.qr_data:
raise HTTPException(404, "QR 코드 없음")
img_bytes = base64.b64decode(ver.qr_data)
return Response(content=img_bytes, media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"})
@router.get("/landing", response_class=HTMLResponse)
async def app_landing(
token: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""앱 다운로드 랜딩 페이지 (사용자가 QR 스캔 시 보는 화면). 인증 불필요."""
row = await db.execute(
select(AppVersion).where(AppVersion.landing_token == token)
)
ver = row.scalar_one_or_none()
if not ver:
return HTMLResponse("<h1>잘못된 QR 코드입니다</h1>", status_code=404)
# User-Agent로 플랫폼 감지
ua = request.headers.get("User-Agent", "").lower()
is_ios = "iphone" in ua or "ipad" in ua
android_btn = ""
ios_btn = ""
if ver.android_url:
android_btn = f'<a href="{ver.android_url}" class="btn android-btn">📱 Android 다운로드 (APK)</a>'
if ver.ios_url:
ios_btn = f'<a href="{ver.ios_url}" class="btn ios-btn">🍎 iOS (TestFlight)</a>'
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GUARDiA Messenger 다운로드</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0 }}
body {{ font-family: 'Pretendard', -apple-system, sans-serif; background: #f0f4ff; min-height: 100vh; display: flex; align-items: center; justify-content: center }}
.card {{ background: #fff; border-radius: 20px; padding: 40px 32px; max-width: 400px; width: 90%; text-align: center; box-shadow: 0 8px 32px rgba(0,51,102,.12) }}
.logo {{ font-size: 48px; margin-bottom: 16px }}
h1 {{ color: #003366; font-size: 24px; font-weight: 800; margin-bottom: 8px }}
.version {{ color: #64748b; font-size: 14px; margin-bottom: 24px }}
.btn {{ display: block; padding: 14px 24px; border-radius: 12px; font-size: 16px; font-weight: 700; text-decoration: none; margin-bottom: 12px; transition: transform .1s }}
.btn:active {{ transform: scale(0.98) }}
.android-btn {{ background: #003366; color: #fff }}
.ios-btn {{ background: #1d1d1f; color: #fff }}
.guide {{ background: #f8fafc; border-radius: 10px; padding: 16px; margin-top: 20px; text-align: left; font-size: 13px; color: #475569 }}
.guide h3 {{ font-size: 13px; font-weight: 700; margin-bottom: 8px; color: #003366 }}
.guide li {{ margin-left: 16px; margin-bottom: 4px }}
.notes {{ margin-top: 16px; font-size: 13px; color: #64748b; line-height: 1.6 }}
</style>
</head>
<body>
<div class="card">
<div class="logo">🛡️</div>
<h1>GUARDiA Messenger</h1>
<div class="version">버전 {ver.version}</div>
{'<div style="background:#fef3c7;border-radius:8px;padding:10px;margin-bottom:16px;font-size:13px;color:#92400e">📱 iOS 기기가 감지되었습니다</div>' if is_ios else ''}
{android_btn}
{ios_btn}
{'<p style="color:#ef4444;font-size:13px;margin-top:8px">⚠️ 버전 정보가 없습니다</p>' if not ver.android_url and not ver.ios_url else ''}
{f'<div class="notes">{ver.release_notes}</div>' if ver.release_notes else ''}
<div class="guide">
<h3>📋 Android 설치 가이드</h3>
<ol>
<li>위 "Android 다운로드" 클릭</li>
<li>APK 파일 다운로드 완료 대기</li>
<li>설정 → 보안 → "알 수 없는 소스" 허용</li>
<li>다운로드된 APK 파일 실행 → 설치</li>
</ol>
</div>
</div>
</body>
</html>"""
# 다운로드 로그
log = AppDownloadLog(
version_id=ver.id,
platform="IOS" if is_ios else "ANDROID",
user_agent=request.headers.get("User-Agent", "")[:200],
downloaded_at=datetime.utcnow(),
)
db.add(log)
await db.commit()
return HTMLResponse(content=html)
@router.get("/download")
async def download_apk(
token: str,
db: AsyncSession = Depends(get_db),
):
"""APK 파일 다운로드. 인증 불필요."""
row = await db.execute(
select(AppVersion).where(AppVersion.landing_token == token)
)
ver = row.scalar_one_or_none()
if not ver or not ver.file_path:
raise HTTPException(404, "파일 없음")
if not Path(ver.file_path).exists():
raise HTTPException(404, "APK 파일이 서버에 없습니다")
ver.download_count = (ver.download_count or 0) + 1
await db.commit()
return FileResponse(
ver.file_path,
filename=f"guardia_messenger_{ver.version}.apk",
media_type="application/vnd.android.package-archive",
)
@router.get("/versions")
async def list_versions(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(AppVersion).order_by(desc(AppVersion.created_at)).limit(20)
)
versions = rows.scalars().all()
return [
{
"id": v.id, "version": v.version, "platform": v.platform,
"download_count": v.download_count, "is_latest": v.is_latest,
"qr_url": f"{BASE_URL}/api/app/qr?token={v.landing_token}",
"landing_url": f"{BASE_URL}/api/app/landing?token={v.landing_token}",
"file_size_mb": round((v.file_size_mb or 0), 1),
"release_notes": v.release_notes,
"created_at": v.created_at,
}
for v in versions
]
@router.delete("/versions/{version_id}")
async def delete_version(
version_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
row = await db.execute(
select(AppVersion).where(AppVersion.id == version_id)
)
ver = row.scalar_one_or_none()
if not ver:
raise HTTPException(404)
if ver.is_latest:
raise HTTPException(400, "최신 버전은 삭제 불가. 새 버전 업로드 후 삭제하세요.")
if ver.file_path and Path(ver.file_path).exists():
Path(ver.file_path).unlink()
await db.delete(ver)
await db.commit()
return {"ok": True}
@router.get("/stats")
async def app_stats(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
total = (await db.execute(
select(func.sum(AppVersion.download_count))
)).scalar() or 0
android = (await db.execute(
select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "ANDROID")
)).scalar() or 0
ios = (await db.execute(
select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "IOS")
)).scalar() or 0
return {"total_downloads": total, "android": android, "ios": ios}