fix(enhance-v4): APK QR 업로드 버그 수정 + 웹메일 라우터 패키지 경로 수정

- app_deploy.py: AppVersion.tenant_id 제거, file_size→file_size_mb, qr_data 필드 정렬
- app_deploy.py: sa_update import 방식 수정, AppDownloadLog 필드명 수정
- smart_notify.py: channels Field description 타입 오류 수정 (list→str)
- contacts.py: 상대 임포트로 변경 (.auth), SQLite 독립 저장소 방식
- signature.py: 상대 임포트로 변경 (.auth), SQLite 독립 저장소 방식

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-02 20:20:44 +09:00
parent 0ebac500f5
commit b5a4f7a397
5 changed files with 243 additions and 205 deletions

View File

@ -5457,7 +5457,8 @@ class AppVersion(Base):
android_url = Column(String(1000), nullable=True) android_url = Column(String(1000), nullable=True)
ios_url = Column(String(1000), nullable=True) ios_url = Column(String(1000), nullable=True)
qr_image_path = Column(String(500), nullable=True) qr_image_path = Column(String(500), nullable=True)
landing_token = Column(String(36), nullable=True, unique=True) qr_data = Column(Text, nullable=True) # base64 PNG
landing_token = Column(String(64), nullable=True, unique=True)
download_count = Column(Integer, default=0) download_count = Column(Integer, default=0)
is_latest = Column(Boolean, default=False) is_latest = Column(Boolean, default=False)
release_notes = Column(Text, nullable=True) release_notes = Column(Text, nullable=True)

View File

@ -97,9 +97,10 @@ async def upload_apk(
file_path.write_bytes(file_bytes) file_path.write_bytes(file_bytes)
# 기존 latest 해제 # 기존 latest 해제
from sqlalchemy import update as sa_update
await db.execute( await db.execute(
__import__('sqlalchemy', fromlist=['update']).update(AppVersion) sa_update(AppVersion)
.where(AppVersion.tenant_id == user.tenant_id, AppVersion.is_latest == True) .where(AppVersion.is_latest == True)
.values(is_latest=False) .values(is_latest=False)
) )
@ -110,11 +111,10 @@ async def upload_apk(
qr_b64 = base64.b64encode(qr_bytes).decode() qr_b64 = base64.b64encode(qr_bytes).decode()
app_ver = AppVersion( app_ver = AppVersion(
tenant_id=user.tenant_id,
version=version, version=version,
platform="ANDROID", platform="ANDROID",
file_path=str(file_path), file_path=str(file_path),
file_size=len(file_bytes), file_size_mb=round(len(file_bytes) / 1024 / 1024, 2),
android_url=f"{BASE_URL}/api/app/download?token={token}", android_url=f"{BASE_URL}/api/app/download?token={token}",
ios_url=ios_url or None, ios_url=ios_url or None,
landing_token=token, landing_token=token,
@ -147,9 +147,10 @@ async def set_app_url(
user: User = Depends(require_admin_role), user: User = Depends(require_admin_role),
): ):
"""외부 URL(EAS 빌드 등)로 QR 코드 생성.""" """외부 URL(EAS 빌드 등)로 QR 코드 생성."""
from sqlalchemy import update as sa_update
await db.execute( await db.execute(
__import__('sqlalchemy', fromlist=['update']).update(AppVersion) sa_update(AppVersion)
.where(AppVersion.tenant_id == user.tenant_id, AppVersion.is_latest == True) .where(AppVersion.is_latest == True)
.values(is_latest=False) .values(is_latest=False)
) )
@ -158,7 +159,6 @@ async def set_app_url(
qr_bytes = _generate_qr(landing_url) qr_bytes = _generate_qr(landing_url)
app_ver = AppVersion( app_ver = AppVersion(
tenant_id=user.tenant_id,
version=req.version, version=req.version,
platform="BOTH" if req.ios_url else "ANDROID", platform="BOTH" if req.ios_url else "ANDROID",
android_url=req.android_url, android_url=req.android_url,
@ -190,10 +190,7 @@ async def get_latest(
): ):
"""최신 버전 정보 조회.""" """최신 버전 정보 조회."""
row = await db.execute( row = await db.execute(
select(AppVersion).where( select(AppVersion).where(AppVersion.is_latest == True)
AppVersion.tenant_id == user.tenant_id,
AppVersion.is_latest == True,
)
) )
ver = row.scalar_one_or_none() ver = row.scalar_one_or_none()
if not ver: if not ver:
@ -303,8 +300,7 @@ async def app_landing(
version_id=ver.id, version_id=ver.id,
platform="IOS" if is_ios else "ANDROID", platform="IOS" if is_ios else "ANDROID",
user_agent=request.headers.get("User-Agent", "")[:200], user_agent=request.headers.get("User-Agent", "")[:200],
ip_addr=request.client.host if request.client else "", downloaded_at=datetime.utcnow(),
accessed_at=datetime.utcnow(),
) )
db.add(log) db.add(log)
await db.commit() await db.commit()
@ -343,8 +339,7 @@ async def list_versions(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
rows = await db.execute( rows = await db.execute(
select(AppVersion).where(AppVersion.tenant_id == user.tenant_id) select(AppVersion).order_by(desc(AppVersion.created_at)).limit(20)
.order_by(desc(AppVersion.created_at)).limit(20)
) )
versions = rows.scalars().all() versions = rows.scalars().all()
return [ return [
@ -353,7 +348,7 @@ async def list_versions(
"download_count": v.download_count, "is_latest": v.is_latest, "download_count": v.download_count, "is_latest": v.is_latest,
"qr_url": f"{BASE_URL}/api/app/qr?token={v.landing_token}", "qr_url": f"{BASE_URL}/api/app/qr?token={v.landing_token}",
"landing_url": f"{BASE_URL}/api/app/landing?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), "file_size_mb": round((v.file_size_mb or 0), 1),
"release_notes": v.release_notes, "release_notes": v.release_notes,
"created_at": v.created_at, "created_at": v.created_at,
} }
@ -368,7 +363,7 @@ async def delete_version(
user: User = Depends(require_admin_role), user: User = Depends(require_admin_role),
): ):
row = await db.execute( row = await db.execute(
select(AppVersion).where(AppVersion.id == version_id, AppVersion.tenant_id == user.tenant_id) select(AppVersion).where(AppVersion.id == version_id)
) )
ver = row.scalar_one_or_none() ver = row.scalar_one_or_none()
if not ver: if not ver:
@ -388,7 +383,7 @@ async def app_stats(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
total = (await db.execute( total = (await db.execute(
select(func.sum(AppVersion.download_count)).where(AppVersion.tenant_id == user.tenant_id) select(func.sum(AppVersion.download_count))
)).scalar() or 0 )).scalar() or 0
android = (await db.execute( android = (await db.execute(
select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "ANDROID") select(func.count(AppDownloadLog.id)).where(AppDownloadLog.platform == "ANDROID")

View File

@ -42,7 +42,7 @@ class NotifyRuleCreate(BaseModel):
name: str = Field(..., max_length=200) name: str = Field(..., max_length=200)
trigger_type: str = Field(..., description="SR_CREATED|SR_UPDATED|INCIDENT|DRIFT|KPI_BREACH|CUSTOM") trigger_type: str = Field(..., description="SR_CREATED|SR_UPDATED|INCIDENT|DRIFT|KPI_BREACH|CUSTOM")
conditions: dict = Field(default_factory=dict) conditions: dict = Field(default_factory=dict)
channels: List[str] = Field(default_factory=list, description=["messenger","email","slack","kakao"]) channels: List[str] = Field(default_factory=list, description="messenger|email|slack|kakao")
priority_filter: str = Field("ALL", description="HIGH|MEDIUM|ALL") priority_filter: str = Field("ALL", description="HIGH|MEDIUM|ALL")
silence_hours: Optional[List[int]] = Field(None, description="무음 시간 목록 [22,23,0,1,...,7]") silence_hours: Optional[List[int]] = Field(None, description="무음 시간 목록 [22,23,0,1,...,7]")
digest_mode: bool = Field(False, description="묶음 발송 모드") digest_mode: bool = Field(False, description="묶음 발송 모드")

View File

@ -1,156 +1,144 @@
""" """웹메일 주소록 — SQLite 독립 저장소"""
웹메일 주소록 연락처 CRUD + 자동 저장
엔드포인트:
GET /api/mail/contacts 주소록 목록
POST /api/mail/contacts 연락처 추가
PUT /api/mail/contacts/{id} 수정
DELETE /api/mail/contacts/{id} 삭제
GET /api/mail/contacts/search 검색
POST /api/mail/contacts/auto-save 발신자 자동 저장
"""
from __future__ import annotations from __future__ import annotations
import json import sqlite3
import os
import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, or_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from auth import verify_token from .auth import current_user
from database import get_db_session as get_db
from models import MailContact
router = APIRouter(prefix="/api/mail/contacts", tags=["주소록"]) router = APIRouter(prefix="/api/contacts", tags=["주소록"])
DB_PATH = os.environ.get("MAIL_CONTACTS_DB", "/opt/mail/contacts.db")
class ContactCreate(BaseModel): def _get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
company TEXT,
use_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(username)")
conn.commit()
return conn
def _email_ok(email: str) -> bool:
return bool(re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email))
class ContactIn(BaseModel):
name: str name: str
email: str email: str
group: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
note: Optional[str] = None company: Optional[str] = None
class AutoSaveRequest(BaseModel): @router.get("")
name: str
email: str
@router.get("/search")
async def search_contacts(
q: str = Query(..., min_length=1),
db: AsyncSession = Depends(get_db),
username: str = Depends(verify_token),
):
rows = await db.execute(
select(MailContact).where(
MailContact.username == username,
or_(
MailContact.name.ilike(f"%{q}%"),
MailContact.email.ilike(f"%{q}%"),
)
).order_by(desc(MailContact.use_count)).limit(10)
)
return [
{"id": c.id, "name": c.name, "email": c.email, "group": c.group}
for c in rows.scalars().all()
]
@router.get("/")
async def list_contacts( async def list_contacts(
db: AsyncSession = Depends(get_db), q: str = Query(None),
username: str = Depends(verify_token), limit: int = Query(50, ge=1, le=200),
user=Depends(current_user),
): ):
rows = await db.execute( username = user[0]
select(MailContact).where(MailContact.username == username) conn = _get_conn()
.order_by(MailContact.name) try:
) if q:
return [ rows = conn.execute(
{"id": c.id, "name": c.name, "email": c.email, "SELECT * FROM contacts WHERE username=? AND (name LIKE ? OR email LIKE ?) ORDER BY use_count DESC, name LIMIT ?",
"group": c.group, "phone": c.phone, "use_count": c.use_count} (username, f"%{q}%", f"%{q}%", limit)
for c in rows.scalars().all() ).fetchall()
] else:
rows = conn.execute(
"SELECT * FROM contacts WHERE username=? ORDER BY use_count DESC, name LIMIT ?",
(username, limit)
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
@router.post("/") @router.post("", status_code=201)
async def create_contact( async def add_contact(body: ContactIn, user=Depends(current_user)):
req: ContactCreate, username = user[0]
db: AsyncSession = Depends(get_db), if not _email_ok(body.email):
username: str = Depends(verify_token), raise HTTPException(400, "이메일 형식 오류")
): conn = _get_conn()
# 중복 이메일 체크 try:
existing = await db.execute( cur = conn.execute(
select(MailContact).where(MailContact.username == username, MailContact.email == req.email) "INSERT INTO contacts (username,name,email,phone,company) VALUES (?,?,?,?,?)",
) (username, body.name, body.email, body.phone, body.company)
if existing.scalar_one_or_none(): )
raise HTTPException(409, "이미 등록된 이메일") conn.commit()
contact = MailContact( row = conn.execute("SELECT * FROM contacts WHERE id=?", (cur.lastrowid,)).fetchone()
username=username, name=req.name, email=req.email, return dict(row)
group=req.group, phone=req.phone, note=req.note, finally:
use_count=0, auto_saved=False, created_at=datetime.utcnow(), conn.close()
)
db.add(contact)
await db.commit()
await db.refresh(contact)
return {"ok": True, "id": contact.id}
@router.put("/{contact_id}") @router.put("/{contact_id}")
async def update_contact( async def update_contact(contact_id: int, body: ContactIn, user=Depends(current_user)):
contact_id: int, username = user[0]
req: ContactCreate, conn = _get_conn()
db: AsyncSession = Depends(get_db), try:
username: str = Depends(verify_token), conn.execute(
): "UPDATE contacts SET name=?,email=?,phone=?,company=?,updated_at=datetime('now') WHERE id=? AND username=?",
row = await db.execute( (body.name, body.email, body.phone, body.company, contact_id, username)
select(MailContact).where(MailContact.id == contact_id, MailContact.username == username) )
) conn.commit()
c = row.scalar_one_or_none() return {"ok": True}
if not c: raise HTTPException(404) finally:
c.name = req.name; c.email = req.email; c.group = req.group conn.close()
c.phone = req.phone; c.note = req.note
await db.commit()
return {"ok": True}
@router.delete("/{contact_id}") @router.delete("/{contact_id}")
async def delete_contact( async def delete_contact(contact_id: int, user=Depends(current_user)):
contact_id: int, username = user[0]
db: AsyncSession = Depends(get_db), conn = _get_conn()
username: str = Depends(verify_token), try:
): conn.execute("DELETE FROM contacts WHERE id=? AND username=?", (contact_id, username))
row = await db.execute( conn.commit()
select(MailContact).where(MailContact.id == contact_id, MailContact.username == username) return {"ok": True}
) finally:
c = row.scalar_one_or_none() conn.close()
if not c: raise HTTPException(404)
await db.delete(c); await db.commit()
return {"ok": True}
@router.post("/auto-save") @router.post("/auto-save")
async def auto_save_contact( async def auto_save(body: ContactIn, user=Depends(current_user)):
req: AutoSaveRequest, """메일 발송 시 수신자 자동 저장 (중복이면 use_count 증가)."""
db: AsyncSession = Depends(get_db), username = user[0]
username: str = Depends(verify_token), if not _email_ok(body.email):
): return {"ok": False}
"""메일 발신자 자동 저장 (사용 횟수 증가).""" conn = _get_conn()
row = await db.execute( try:
select(MailContact).where(MailContact.username == username, MailContact.email == req.email) existing = conn.execute(
) "SELECT id FROM contacts WHERE username=? AND email=?", (username, body.email)
c = row.scalar_one_or_none() ).fetchone()
if c: if existing:
c.use_count = (c.use_count or 0) + 1 conn.execute(
else: "UPDATE contacts SET use_count=use_count+1 WHERE id=?", (existing["id"],)
c = MailContact( )
username=username, name=req.name, email=req.email, else:
use_count=1, auto_saved=True, created_at=datetime.utcnow(), conn.execute(
) "INSERT INTO contacts (username,name,email) VALUES (?,?,?)",
db.add(c) (username, body.name or body.email.split("@")[0], body.email)
await db.commit() )
return {"ok": True} conn.commit()
return {"ok": True}
finally:
conn.close()

View File

@ -1,71 +1,125 @@
""" """웹메일 서명 관리 — SQLite 독립 저장소"""
웹메일 서명 편집기 from __future__ import annotations
엔드포인트: import sqlite3
GET /api/mail/signature 현재 서명 조회 import os
PUT /api/mail/signature 서명 저장 (HTML)
"""
import re import re
from datetime import datetime from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from auth import verify_token from .auth import current_user
from database import get_db_session as get_db
from models import MailSignature
router = APIRouter(prefix="/api/mail/signature", tags=["메일 서명"]) router = APIRouter(prefix="/api/signature", tags=["서명"])
ALLOWED_TAGS = {'b', 'i', 'u', 'br', 'p', 'span', 'div', 'a', 'font', 'strong', 'em', 'h1', 'h2', 'h3', 'img'} DB_PATH = os.environ.get("MAIL_CONTACTS_DB", "/opt/mail/contacts.db")
def _sanitize_html(html: str) -> str: def _sanitize_html(html: str) -> str:
"""기본 HTML sanitize (script 태그 제거).""" html = re.sub(r'on\w+\s*=\s*["\'][^"\']*["\']', '', html, flags=re.IGNORECASE)
html = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE) html = re.sub(r'javascript\s*:', '', html, flags=re.IGNORECASE)
html = re.sub(r'on\w+="[^"]*"', '', html, flags=re.IGNORECASE) return html[:5000]
html = re.sub(r"on\w+='[^']*'", '', html, flags=re.IGNORECASE)
return html
class SignatureUpdate(BaseModel): def _get_conn():
html_content: str conn = sqlite3.connect(DB_PATH)
is_active: bool = True conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE IF NOT EXISTS signatures (
@router.get("/") id INTEGER PRIMARY KEY AUTOINCREMENT,
async def get_signature( username TEXT NOT NULL,
db: AsyncSession = Depends(get_db), name TEXT NOT NULL,
username: str = Depends(verify_token), html_content TEXT NOT NULL DEFAULT '',
): is_default INTEGER DEFAULT 0,
row = await db.execute(select(MailSignature).where(MailSignature.username == username)) created_at TEXT DEFAULT (datetime('now')),
sig = row.scalar_one_or_none() updated_at TEXT DEFAULT (datetime('now'))
return {
"html_content": sig.html_content if sig else "",
"is_active": sig.is_active if sig else False,
}
@router.put("/")
async def update_signature(
req: SignatureUpdate,
db: AsyncSession = Depends(get_db),
username: str = Depends(verify_token),
):
clean_html = _sanitize_html(req.html_content)
row = await db.execute(select(MailSignature).where(MailSignature.username == username))
sig = row.scalar_one_or_none()
if sig:
sig.html_content = clean_html
sig.is_active = req.is_active
sig.updated_at = datetime.utcnow()
else:
sig = MailSignature(
username=username, html_content=clean_html,
is_active=req.is_active, updated_at=datetime.utcnow(),
) )
db.add(sig) """)
await db.commit() conn.execute("CREATE INDEX IF NOT EXISTS idx_sig_user ON signatures(username)")
return {"ok": True} conn.commit()
return conn
class SigIn(BaseModel):
name: str
html_content: str
is_default: bool = False
@router.get("")
async def list_signatures(user=Depends(current_user)):
username = user[0]
conn = _get_conn()
try:
rows = conn.execute(
"SELECT * FROM signatures WHERE username=? ORDER BY is_default DESC, id",
(username,)
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
@router.post("", status_code=201)
async def create_signature(body: SigIn, user=Depends(current_user)):
username = user[0]
html = _sanitize_html(body.html_content)
conn = _get_conn()
try:
if body.is_default:
conn.execute("UPDATE signatures SET is_default=0 WHERE username=?", (username,))
cur = conn.execute(
"INSERT INTO signatures (username,name,html_content,is_default) VALUES (?,?,?,?)",
(username, body.name, html, 1 if body.is_default else 0)
)
conn.commit()
row = conn.execute("SELECT * FROM signatures WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
finally:
conn.close()
@router.put("/{sig_id}")
async def update_signature(sig_id: int, body: SigIn, user=Depends(current_user)):
username = user[0]
html = _sanitize_html(body.html_content)
conn = _get_conn()
try:
if body.is_default:
conn.execute("UPDATE signatures SET is_default=0 WHERE username=?", (username,))
conn.execute(
"UPDATE signatures SET name=?,html_content=?,is_default=?,updated_at=datetime('now') WHERE id=? AND username=?",
(body.name, html, 1 if body.is_default else 0, sig_id, username)
)
conn.commit()
return {"ok": True}
finally:
conn.close()
@router.patch("/{sig_id}/default")
async def set_default(sig_id: int, user=Depends(current_user)):
username = user[0]
conn = _get_conn()
try:
conn.execute("UPDATE signatures SET is_default=0 WHERE username=?", (username,))
conn.execute(
"UPDATE signatures SET is_default=1 WHERE id=? AND username=?", (sig_id, username)
)
conn.commit()
return {"ok": True}
finally:
conn.close()
@router.delete("/{sig_id}")
async def delete_signature(sig_id: int, user=Depends(current_user)):
username = user[0]
conn = _get_conn()
try:
conn.execute("DELETE FROM signatures WHERE id=? AND username=?", (sig_id, username))
conn.commit()
return {"ok": True}
finally:
conn.close()