""" 모바일 앱 직접 배포 — 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("
⚠️ 버전 정보가 없습니다
' if not ver.android_url and not ver.ios_url else ''} {f'