400 lines
14 KiB
Python
400 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 해제
|
|
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("<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],
|
|
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}
|