Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
27c4926
feat: policy api 구현
qkwltkwkd1 May 25, 2026
9b64403
feat: policy 카드뉴스 템플릿 json으로
qkwltkwkd1 May 26, 2026
6585cea
feat: policy api 구현
qkwltkwkd1 May 26, 2026
e3e1170
fix: 충돌 해결
qkwltkwkd1 May 26, 2026
f549c4b
refactor: 카드뉴스 템프릿 수정
qkwltkwkd1 May 26, 2026
f8129d8
refactor: 병합 대상 슬라이드 수정
qkwltkwkd1 May 27, 2026
e08c97b
refactor: 단순 슬라이싱 수정
qkwltkwkd1 May 27, 2026
71002cc
fix: 중복 코드 제거
qkwltkwkd1 May 27, 2026
635ec77
fix: 서비스별 독립 키로, config 설명 수정
qkwltkwkd1 May 27, 2026
07b8a38
fix: 불필요한 매개변수를 제거
qkwltkwkd1 May 27, 2026
ce3a0c0
fix: UTF-8 한글 문자열로 수정
qkwltkwkd1 May 27, 2026
0dcc5fe
fix: BOM 없이 UTF-8로
qkwltkwkd1 May 27, 2026
c752af9
fix: 충돌 해결
qkwltkwkd1 May 29, 2026
2699982
refactor: 데코레이터를 추가
qkwltkwkd1 Jun 2, 2026
eda612f
refactor: 중복 코드를 제거
qkwltkwkd1 Jun 2, 2026
bc9c55f
refactor: 비동기적으로 병렬 처리
qkwltkwkd1 Jun 2, 2026
7fbb8e7
refactor: AsyncClient 수정
qkwltkwkd1 Jun 2, 2026
6f7d7ec
refactor: 유효한 날짜인지 검증
qkwltkwkd1 Jun 2, 2026
077d082
refactor: 처리 속도 향상
qkwltkwkd1 Jun 2, 2026
4dc0f06
refactor: 중복 제거
qkwltkwkd1 Jun 2, 2026
2a226e7
fix: 충돌 해결
qkwltkwkd1 Jun 2, 2026
5032b91
fix: ci 해결
qkwltkwkd1 Jun 2, 2026
73d99ed
fix: 충돌 해결
qkwltkwkd1 Jun 4, 2026
1a82afe
refactor: 중복 블록 제거
qkwltkwkd1 Jun 9, 2026
3aed1aa
refactor: 폰트 하드코딩 수정
qkwltkwkd1 Jun 9, 2026
b4d0e68
refactor: 임포트 정리
qkwltkwkd1 Jun 9, 2026
345b892
refactor: 임포트 정리
qkwltkwkd1 Jun 9, 2026
daab579
refactor: fallback wrapping 되도록 보완
qkwltkwkd1 Jun 9, 2026
52142b7
feat: 정책 핀 sync·배치 가공·DB 적재 파이프라인 및 관리자 API 추가
selnem Jun 12, 2026
1a29a06
fix: 정책 핀 파이프라인 코드리뷰 지적 4건 반영
selnem Jun 12, 2026
6eec2df
fix: import_handoff_batch 메타데이터 오타 수정 및 정책 파이프라인 테스트 추가
selnem Jun 12, 2026
dc70187
fix: 정책 핀 sync 날짜 검증·handoff 적재 drain·모델 unique 동기화
selnem Jun 12, 2026
89e4707
fix: 정책 관리자 API 리뷰 지적 보완
selnem Jun 12, 2026
1b97e63
fix: 정책 관리자 리뷰 후속 지적 반영
selnem Jun 12, 2026
f1fc0a3
fix: 정책 파이프라인 N+1 쿼리·동기 I/O 리뷰 반영
selnem Jun 12, 2026
5f4bade
fix: 정책 sync 루프·handoff 비어 있을 때 DB 완료 상태 인식
selnem Jun 12, 2026
bdf3ab6
Merge pull request #118 from IssueIssyu/feat/policy-api-db-insert
selnem Jun 12, 2026
386f58a
Merge pull request #86 from IssueIssyu/feat/72-policy-api
selnem Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added app/assets/fonts/Pretendard-Bold.otf
Binary file not shown.
Binary file added app/assets/fonts/Pretendard-ExtraBold.otf
Binary file not shown.
Binary file added app/assets/fonts/Pretendard-Medium.otf
Binary file not shown.
8 changes: 8 additions & 0 deletions app/assets/mascots/mascots.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"files": [
"기본핀.png",
"행복함핀.png",
"뿌듯핀.png",
"캐릭터_음흉씨핀.png"
]
}
Binary file added app/assets/mascots/기본핀.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/mascots/뿌듯핀.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/mascots/캐릭터_음흉씨핀.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/mascots/행복함핀.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 51 additions & 0 deletions app/assets/policy_cardnews_palettes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"palettes": {
"issueissyu_blue": {
"outer": [29, 135, 255],
"offset": [255, 230, 90],
"accent": [29, 135, 255]
},

"fresh_mint": {
"outer": [52, 199, 153],
"offset": [214, 255, 236],
"accent": [36, 148, 108]
},

"soft_purple": {
"outer": [132, 94, 247],
"offset": [229, 219, 255],
"accent": [96, 64, 201]
},

"sunset_orange": {
"outer": [255, 126, 95],
"offset": [255, 225, 180],
"accent": [220, 88, 52]
},

"modern_slate": {
"outer": [58, 72, 95],
"offset": [210, 220, 235],
"accent": [35, 45, 65]
},

"rose_pink": {
"outer": [238, 108, 133],
"offset": [255, 220, 228],
"accent": [198, 72, 98]
},

"deep_ocean": {
"outer": [0, 119, 182],
"offset": [202, 240, 248],
"accent": [2, 62, 138]
},

"lime_energy": {
"outer": [140, 198, 62],
"offset": [240, 255, 204],
"accent": [92, 148, 32]
}
}
}
35 changes: 35 additions & 0 deletions app/assets/policy_cardnews_templates/template_cover.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "template_cover",
"canvas": { "width": 1080, "height": 1350 },
"frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 },
"layout": {
"text_bottom_ratio_with_hero": 0.44,
"mascot_zone_ratio": 0.52,
"mascot_min_h": 420
},
"fonts": {
"eyebrow_start": 34,
"eyebrow_min": 26,
"eyebrow_max_lines": 2,
"headline_start": 72,
"headline_min": 48,
"headline_max_lines": 3,
"highlight_start": 68,
"highlight_min": 44,
"highlight_max_lines": 2
},
"highlight": {
"type": "accent_badge",
"radius": 8,
"pad_x": 18,
"pad_y": 5,
"tape_extra_h": 14,
"text_fill": [20, 24, 32]
},
"hero": {
"enabled": true,
"box_inset": 22,
"corner_radius": 28,
"fill_mode": "contain"
}
}
53 changes: 53 additions & 0 deletions app/assets/policy_cardnews_templates/template_cta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "template_cta",
"canvas": { "width": 1080, "height": 1350 },
"frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 },
"defaults": {
"eyebrow": "마무리",
"headline": "자세한 내용은 원문에서",
"cta": "원문 뉴스 보기",
"speech": "궁금하면 원문 봐!",
"speech_max_len": 18
},
"layout": {
"gap_above_mascot": 36,
"panel_inset": 22,
"content_scale_base": 180,
"content_scale_min": 1.08,
"content_scale_max": 1.48,
"separator_width": 2
},
"fonts": {
"url_start": 20,
"url_min": 18,
"body_start": 24,
"body_min": 20,
"term_title": 22,
"term_body": 20
},
"cta_panel": {
"height_start": 96,
"height_min": 76,
"height_max": 108,
"radius": 14,
"label_start": 40,
"label_min": 28,
"inset_panel": 22
},
"url": {
"display_max": 34,
"prefix": "앱에서 링크 · "
},
"term_guide": {
"title": "쉬운 말로",
"panel_radius": 12,
"max_guides": 2,
"max_body_lines": 3
},
"header": {
"eyebrow_start": 32,
"eyebrow_min": 24,
"headline_start": 52,
"headline_min": 36
}
}
24 changes: 24 additions & 0 deletions app/assets/policy_cardnews_templates/template_grid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "template_grid",
"canvas": { "width": 1080, "height": 1350 },
"frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 },
"defaults": {
"headline": "이렇게 확인하세요",
"label_prefix": "포인트"
},
"layout": {
"max_items": 4,
"gap_tile": 18,
"tile_min_h": 120,
"tile_pad": 24,
"panel_radius": 14,
"ribbon_ratio": 0.28,
"ribbon_min_h": 52
},
"fonts": {
"label_start": 42,
"label_min": 30,
"body_start": 44,
"body_min": 30
}
}
30 changes: 30 additions & 0 deletions app/assets/policy_cardnews_templates/template_numbered.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "template_numbered",
"canvas": { "width": 1080, "height": 1350 },
"frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 },
"defaults": {
"eyebrow": "한 장 요약",
"speech": "핵심만!",
"speech_max_len": 18
},
"layout": {
"max_items": 4,
"mascot_max_items": 3,
"panel_inset": 22,
"gap_row": 14,
"row_min_h": 48,
"scale_base_per_row": 64,
"scale_max": 1.65,
"separator_width": 2,
"separator_inset_left": 56,
"max_wrap_lines": 3
},
"fonts": {
"row_title_start": 36,
"row_title_min": 28,
"row_title_max": 48,
"num_start": 30,
"num_min": 24,
"num_max": 38
}
}
29 changes: 29 additions & 0 deletions app/assets/policy_cardnews_templates/template_three_col.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "template_three_col",
"canvas": { "width": 1080, "height": 1350 },
"frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 },
"defaults": {
"headline": "핵심만 정리",
"speech": "핵심포인트!",
"speech_max_len": 18,
"label_prefix": "항목"
},
"layout": {
"max_items": 3,
"col_gap": 20,
"panel_radius": 14,
"panel_inset": 22,
"badge_min": 44,
"badge_max": 56,
"badge_ratio": 0.13,
"label_h_ratio": 0.18,
"label_h_min": 36
},
"fonts": {
"badge_num_start": 22,
"label_start": 32,
"label_min": 24,
"body_start": 30,
"body_min": 22
}
}
92 changes: 92 additions & 0 deletions app/clients/PolicyNewsClient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from __future__ import annotations

import asyncio
import logging
from typing import Any
from urllib.parse import urlencode

import httpx

from app.core.config import Settings, settings
from app.utils.policy_news_parse import parse_policy_news_xml, policy_result_ok

logger = logging.getLogger(__name__)

ENDPOINT_POLICY_NEWS_LIST = "policyNewsList"

class PolicyNewsClient:
# 문화체육관광부 정책브리핑 정책뉴스 OpenAPI (공공데이터포털)

def __init__(
self,
*,
service_key: str,
base_url: str = settings.policy_news_api_base_url,
timeout_seconds: float = settings.policy_news_request_timeout_seconds,
request_interval_seconds: float = settings.policy_news_request_interval_seconds,
http_client: httpx.AsyncClient | None = None,
) -> None:
self._service_key = service_key
self._base_url = base_url.rstrip("/")
self._timeout = timeout_seconds
self._interval = max(0.0, request_interval_seconds)
self._http = http_client
self._owns_client = http_client is None

@classmethod
def from_settings(cls, config: Settings | None = None) -> PolicyNewsClient:
s = config or settings
secret = s.policy_news_service_key
if secret is None:
raise RuntimeError(
"POLICY_NEWS_SERVICE_KEY가 설정되어 있지 않습니다.",
)
return cls(
service_key=secret.get_secret_value(),
base_url=s.policy_news_api_base_url,
timeout_seconds=s.policy_news_request_timeout_seconds,
request_interval_seconds=s.policy_news_request_interval_seconds,
)

async def __aenter__(self) -> PolicyNewsClient:
if self._http is None:
self._http = httpx.AsyncClient(timeout=self._timeout)
return self

async def __aexit__(self, *args: object) -> None:
if self._owns_client and self._http is not None:
await self._http.aclose()

async def _get_xml(self, endpoint: str, **params: Any) -> str:
if self._http is None:
raise RuntimeError(
"PolicyNewsClient is not started; use async with PolicyNewsClient.from_settings()",
)
query_params = {
"serviceKey": self._service_key,
**{k: str(v) for k, v in params.items() if v is not None},
}
query = urlencode(query_params)
url = f"{self._base_url}/{endpoint}?{query}"
response = await self._http.get(url)
response.raise_for_status()
if self._interval > 0:
await asyncio.sleep(self._interval)
return response.text

async def policy_news_list(
self,
*,
start_date: str,
end_date: str,
) -> tuple[dict[str, str], list[dict[str, str]]]:
xml_text = await self._get_xml(
ENDPOINT_POLICY_NEWS_LIST,
startDate=start_date,
endDate=end_date,
)
header, items = parse_policy_news_xml(xml_text)
ok, msg = policy_result_ok(header)
if not ok:
raise RuntimeError(f"policyNewsList 실패 ({start_date}~{end_date}): {msg}")
return header, items
Loading
Loading