""" 모바일 앱 직접 배포 — 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 해제 await db.execute( __import__('sqlalchemy', fromlist=['update']).update(AppVersion) .where(AppVersion.tenant_id == user.tenant_id, 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( tenant_id=user.tenant_id, version=version, platform="ANDROID", file_path=str(file_path), file_size=len(file_bytes), 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 코드 생성.""" await db.execute( __import__('sqlalchemy', fromlist=['update']).update(AppVersion) .where(AppVersion.tenant_id == user.tenant_id, 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( tenant_id=user.tenant_id, 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.tenant_id == user.tenant_id, 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("

잘못된 QR 코드입니다

", 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'📱 Android 다운로드 (APK)' if ver.ios_url: ios_btn = f'🍎 iOS (TestFlight)' html = f""" GUARDiA Messenger 다운로드

GUARDiA Messenger

버전 {ver.version}
{'
📱 iOS 기기가 감지되었습니다
' if is_ios else ''} {android_btn} {ios_btn} {'

⚠️ 버전 정보가 없습니다

' if not ver.android_url and not ver.ios_url else ''} {f'
{ver.release_notes}
' if ver.release_notes else ''}

📋 Android 설치 가이드

  1. 위 "Android 다운로드" 클릭
  2. APK 파일 다운로드 완료 대기
  3. 설정 → 보안 → "알 수 없는 소스" 허용
  4. 다운로드된 APK 파일 실행 → 설치
""" # 다운로드 로그 log = AppDownloadLog( version_id=ver.id, platform="IOS" if is_ios else "ANDROID", user_agent=request.headers.get("User-Agent", "")[:200], ip_addr=request.client.host if request.client else "", accessed_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).where(AppVersion.tenant_id == user.tenant_id) .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 or 0) / 1024 / 1024, 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, AppVersion.tenant_id == user.tenant_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)).where(AppVersion.tenant_id == user.tenant_id) )).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}