diff --git a/.env.example b/.env.example index b9ca461..a887b06 100644 --- a/.env.example +++ b/.env.example @@ -100,7 +100,8 @@ KAKAO_BOT_ID= KAKAO_BOT_REST_API_KEY= # 1 이면 개발 채널로 보낸다(봇 ID 뒤에 "!"). 운영 채널이면 0. KAKAO_EVENT_DEV=0 -# 미니블로그 승인 알림을 메일에 더해 연결된 카톡으로도 보낸다(Event API). +# 카톡이 연결된 사장님에게는 미니블로그 승인 알림을 메일 대신 카톡으로 보낸다(Event API). +# 카톡이 실패했을 때만 메일로 대체하고, 연결이 없으면 예전처럼 메일이다. # ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면 # 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐. KAKAO_APPROVAL_PUSH_ENABLED=0 diff --git a/docs/AGENT.md b/docs/AGENT.md index a42f6bb..296d1dd 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -471,7 +471,7 @@ https://<발행호스트>/v1/agent/kakao/webhook | | 결정 | |---|---| -| 발송 | **카톡과 메일 둘 다.** 카톡이 연결돼 있어도 메일을 같이 보낸다 — 카톡 발송은 채널 친구가 아니거나 차단했으면 실패하므로 메일이 누락을 막는다 | +| 발송 | **카톡이 연결돼 있으면 카톡으로만 보내고, 카톡이 실패했을 때만 메일로 대체한다**(2026-09-30 변경 — 처음엔 '카톡과 메일 둘 다'였는데 같은 알림이 두 통 가는 게 번거롭다는 지시. 카톡만 보내면 채널 친구 아님·차단·Event API 장애인 날 알림이 통째로 사라져 실패 때만 메일로 받쳤다). 연결이 없거나 스위치가 꺼져 있으면 예전처럼 메일. 카톡도 메일도 못 보내면 SENT 로 표시하지 않아 다음 스윕이 다시 시도하고 '승인 알림보내기' 는 실패로 답한다 | | 승인 방식 | 메시지의 **[승인] [수정] 인라인 버튼** + 누른 사람이 **연결된 본인인지·그 글이 본인 가게 것인지 서버가 확인**. 링크가 없어 메신저 미리보기가 먼저 열어 승인되는 문제가 처음부터 없다. 승인은 기존 `PostService.approve_by_owner` 를 그대로 탄다 | | 진행 | **1단계(이 절)**: 클라이언트 + 테스트 발송으로 규격 확인. 2단계: 버튼 응답·승인 처리·발송 연결 | @@ -529,7 +529,7 @@ cd solution/backend && .venv/bin/python scripts/kakao_event_send_test.py --key < ## 2-1 진행 — 발송과 메시지 그리기 (2026-09-29) ``` -blog_jobs._send_one 메일 + 카톡 Event API(params: post_id, edit_token) — 하나라도 나가면 SENT +blog_jobs._send_one 카톡 Event API(params: post_id, edit_token) — 실패했을 때만 메일로 대체, 나가면 SENT 오픈빌더 이벤트 블록 스킬 데이터 응답 → 우리 웹훅(POST /v1/agent/kakao/webhook) kakao_bot._handle userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리 channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수정하기] 링크 @@ -542,8 +542,8 @@ channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수 - [수정하기] 는 메일의 '고쳐서 올리려면' 과 **같은 일회용 코드**다(`/v1/site/post/edit?t=`). 평문은 발송 시점에만 알아서 Event API `params.edit_token` 으로 넘긴다 — 어느 쪽이든 먼저 누른 쪽이 쓴다. 코드는 카카오 서버를 지나가므로 로그에는 params 의 **키만** 남기고 값은 남기지 않는다. -- 메일이 나갔으면 카톡보다 먼저 SENT 로 표시한다(웹훅이 곧바로 글을 읽는다). 카톡만 나가는 - 경우는 발송 성공 뒤에 표시하고, 웹훅은 REVIEWED 글도 읽는다. +- 카톡 발송이 성공한 **뒤에** SENT 로 표시한다(카톡이 실패하면 메일 발송 뒤). 그 사이에 웹훅이 이 글을 읽어도 되는 이유는 웹훅이 + REVIEWED 글도 읽고, 사장님이 버튼을 누르기까지의 시간이 그 간격보다 훨씬 길어서다. - 링크 버튼은 `textCard` 로 본문(`simpleText`)과 따로 둔다 — 카드 설명 길이 제한에 글 문구가 걸리지 않게. ### 콘솔에서 바꿔야 하는 것 diff --git a/docs/MINI_BLOG.md b/docs/MINI_BLOG.md index ed70f90..5a359b2 100644 --- a/docs/MINI_BLOG.md +++ b/docs/MINI_BLOG.md @@ -130,11 +130,13 @@ 수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id` 까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가 `place_id` 불일치를 걸러낸다) -- **카톡 병행**(2026-09-29 결정: 카톡과 메일 **둘 다**): 사장님이 카카오톡을 연결했고 - `KAKAO_APPROVAL_PUSH_ENABLED=1` 이면 메일에 더해 Event API 로도 보낸다 - (`blog_jobs._push_kakao`, 규격·콘솔 준비는 `docs/AGENT.md` 5단계). 카톡은 채널 친구가 - 아니거나 차단했으면 실패하므로 메일을 빼지 않는다. **하나라도 나갔으면 SENT**, 아무 데도 - 안 나갔으면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 글 본문과 [수정하기] 링크는 +- **카톡 알림**(2026-09-30 변경: 연결돼 있으면 **카톡으로만, 실패했을 때만 메일로 대체**): 사장님이 + 카카오톡을 연결했고 `KAKAO_APPROVAL_PUSH_ENABLED=1` 이면 메일 대신 Event API 로 보낸다 + (`blog_jobs._push_kakao`, 규격·콘솔 준비는 `docs/AGENT.md` 5단계). 처음(2026-09-29)엔 '둘 다' + 였는데 같은 알림이 두 통 가는 게 번거롭다는 지시로 바꿨다. 카톡만 보내면 채널 친구 아님·차단· + Event API 장애인 날 알림이 통째로 사라져서 **카톡이 실패했을 때만 메일로 대체**한다. 연결이 + 없거나 스위치가 꺼져 있으면 예전처럼 메일이다. 카톡도 메일도 못 보내면 SENT 로 표시하지 않아 + 다음 스윕이 다시 시도하고 '승인 알림보내기' 는 실패로 답한다. 글 본문과 [수정하기] 링크는 오픈빌더 이벤트 블록의 스킬(우리 웹훅)이 그린다 — params 로 글 ID 와 수정용 일회용 코드가 가고, 웹훅이 **연결된 본인 가게의 글인지 다시 확인한 뒤에만** 본문을 준다 (`channel.approval_notice`). diff --git a/solution/backend/config/agent_config.py b/solution/backend/config/agent_config.py index 4a4e611..d14a82e 100644 --- a/solution/backend/config/agent_config.py +++ b/solution/backend/config/agent_config.py @@ -49,7 +49,8 @@ class AgentConfig(BaseSettings): # "1" 이면 봇 ID 뒤에 "!" 를 붙여 **개발 채널**로 보낸다(운영 채널과 요청 주소가 다르다). # ★ KAKAO_BOT_ID 자체를 고쳐 쓰지 않는다 — 웹훅이 그 값으로 요청의 bot.id 를 대조한다. KAKAO_EVENT_DEV: str = "0" - # 미니블로그 승인 알림을 메일에 더해 카톡으로도 보낸다. ★ 기본 꺼짐 — 오픈빌더에 이벤트 + # 카톡이 연결된 사장님에게는 미니블로그 승인 알림을 메일 대신 카톡으로 보낸다(카톡이 실패했을 + # 때만 메일로 대체, 연결이 없으면 메일). ★ 기본 꺼짐 — 오픈빌더에 이벤트 # 블록(스킬 연결)과 배포가 끝나기 전에 켜면 사장님 카톡에 빈 말풍선이 간다. KAKAO_APPROVAL_PUSH_ENABLED: str = "0" # 오픈빌더에 정의한 이벤트 이름. 말풍선을 그리는 블록이 이 이름에 걸려 있다. diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index df72dda..6ccbbb5 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -267,45 +267,46 @@ async def _push_kakao(channel_user_key: str, post, edit_token: str) -> bool: async def _send_one(place, user, post) -> bool: - """토큰 발급 → 메일·카톡 발송 → 하나라도 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 - DB 를 안 건드린다(다시 시도할 길을 막지 않는다). + """토큰 발급 → 발송 → 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 DB 를 안 건드린다 + (다시 시도할 길을 막지 않는다). - ★ 카톡과 메일은 **둘 다** 보낸다(2026-09-29 결정). 카톡은 채널 친구가 아니거나 차단했으면 - 실패하므로, 카톡이 붙어도 메일을 빼지 않는다.""" + ★ 카톡이 연결돼 있으면 **카톡으로만** 보내고, 카톡이 **실패했을 때만** 메일로 대체한다 + (2026-09-30). 처음(2026-09-29)엔 '둘 다'였는데 같은 알림이 두 통 가는 게 번거롭다는 지시로 + 바꿨고, 카톡만 보내면 채널 친구가 아니거나 차단했거나 Event API 가 죽은 날 알림이 통째로 + 사라져서 실패 때만 메일로 받치게 했다. 연결이 없거나 스위치가 꺼져 있으면 예전처럼 메일이다. + ★ 카톡도 메일도 못 보내면 SENT 로 표시하지 않는다 — 다음 스윕이 다시 시도하고 '승인 알림보내기' + 는 실패로 답한다.""" token, token_hash, expires = blog_service.issue_token() # ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고, # 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023). edit_token, edit_hash, _edit_expires = blog_service.issue_token() - mail_ok = False - if _can_mail(place, user): - body = _mail_body( - place_name=place.name, post=post, user=user, - origin=_app_origin(), approve_token=token, edit_token=edit_token, - ) - mail_ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body) - async def mark_sent(): await DB_SESSION_MNG.execute_lambda_run( [place_posts.DBType()], [lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)], ) - # ★ 메일이 나갔으면 카톡보다 **먼저** SENT 로 표시한다. 이벤트가 나가자마자 웹훅이 이 글을 - # 읽는데, 그때 수정 코드 해시가 저장돼 있으면 [수정하기] 가 바로 먹는다. 카톡만 나가는 - # 경우는 발송이 성공해야 표시할 수 있어 그 뒤에 한다 — 사장님이 버튼을 누르기까지의 - # 시간이 그 간격보다 훨씬 길다(웹훅은 REVIEWED 글도 읽는다). - if mail_ok: - await mark_sent() - kakao_key = await _kakao_target(user) - if kakao_key is None: - return mail_ok + if kakao_key is not None: + # 카톡 발송이 성공해야 SENT 로 표시할 수 있어 그 뒤에 한다. 이벤트가 나가자마자 웹훅이 이 + # 글을 읽어도 되는 이유: 웹훅은 REVIEWED 글도 읽고, 사장님이 버튼을 누르기까지의 시간이 + # 이 간격보다 훨씬 길다. + if await _push_kakao(kakao_key, post, edit_token): + await mark_sent() + return True + LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림이 안 나가 메일로 대체한다") - kakao_ok = await _push_kakao(kakao_key, post, edit_token) - if kakao_ok and not mail_ok: - await mark_sent() - return mail_ok or kakao_ok + if not _can_mail(place, user): + return False + body = _mail_body( + place_name=place.name, post=post, user=user, + origin=_app_origin(), approve_token=token, edit_token=edit_token, + ) + if not mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body): + return False + await mark_sent() + return True async def send_reviewed() -> int: @@ -327,7 +328,8 @@ async def send_reviewed() -> int: if not target: continue place, user = target - # 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 하나라도 되면 _send_one 이 갈래를 정한다. + # 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 어느 쪽으로 보낼지는 _send_one 이 정한다 + # (카톡이 연결돼 있으면 카톡, 실패했을 때만 메일 — 연결이 없으면 메일). if not _can_mail(place, user) and await _kakao_target(user) is None: continue if await _send_one(place, user, post): diff --git a/solution/backend/tests/test_blog_owner.py b/solution/backend/tests/test_blog_owner.py index 85c9c4c..b9462b3 100644 --- a/solution/backend/tests/test_blog_owner.py +++ b/solution/backend/tests/test_blog_owner.py @@ -459,8 +459,8 @@ async def test_send_now_cannot_be_triggered_for_someone_elses_place(client, db_e assert res.json()["result"]["success"] is False -async def _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, login_id, name): - """발행된 사이트 + 메일 주소 + 카톡이 연결된 사장님. 카톡 Event API 는 켜 둔다.""" +async def _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, login_id, name, link=True): + """발행된 사이트 + 메일 주소 + (link=True 면) 카톡이 연결된 사장님. 카톡 Event API 는 켜 둔다.""" from services import kakao_link_service monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh") @@ -477,7 +477,8 @@ async def _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypa ) await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": f"{login_id}@example.com", "id": login_id}) user_id = (await conn.execute(text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id})).scalar_one() - await kakao_link_service.redeem((await kakao_link_service.issue_code(user_id))["code"], f"speaker-{login_id}") + if link: + await kakao_link_service.redeem((await kakao_link_service.issue_code(user_id))["code"], f"speaker-{login_id}") return place_id, f"speaker-{login_id}", h @@ -496,8 +497,9 @@ def _record_kakao_send(monkeypatch, *, fail=False): return calls -async def test_send_reviewed_also_pushes_to_the_linked_kakao(client, db_engine, auth_headers, monkeypatch): - """승인 알림은 메일에 더해 연결된 카카오톡으로도 나간다(2026-09-29, 결정: 카톡과 메일 둘 다). +async def test_send_reviewed_sends_only_kakao_when_linked(client, db_engine, auth_headers, monkeypatch): + """카톡이 연결돼 있으면 승인 알림은 카톡으로만 나가고 메일은 안 나간다(2026-09-30 변경 — + 처음엔 '둘 다'였는데 사장님에게 같은 알림이 두 통 가는 게 번거롭다고 했다). 카톡에는 글 ID 와 수정 링크용 일회용 코드가 params 로 실려, 웹훅이 메시지를 그린다.""" from services import blog_jobs, mail_service @@ -511,7 +513,7 @@ async def test_send_reviewed_also_pushes_to_the_linked_kakao(client, db_engine, sent = await blog_jobs.send_reviewed() assert sent == 1 - assert len(mails) == 1 + assert mails == [] assert len(kakao) == 1 assert kakao[0]["key"] == speaker assert kakao[0]["event"] == "post_approval" @@ -520,8 +522,9 @@ async def test_send_reviewed_also_pushes_to_the_linked_kakao(client, db_engine, assert await _status(db_engine, post_id) == PostStatus.SENT.value -async def test_a_failed_kakao_push_does_not_block_the_mail(client, db_engine, auth_headers, monkeypatch): - """카톡은 채널 친구가 아니거나 차단했으면 실패한다 — 그래서 메일도 같이 보낸다.""" +async def test_a_failed_kakao_push_falls_back_to_mail(client, db_engine, auth_headers, monkeypatch): + """카톡은 채널 친구가 아니거나 차단했거나 Event API 가 죽으면 실패한다 — 그때만 메일로 대체한다 + (2026-09-30: 연결돼 있으면 카톡만, 실패했을 때만 메일). 알림이 통째로 사라지지 않게.""" from services import blog_jobs, mail_service monkeypatch.setattr(mail_service, "is_configured", lambda: True) @@ -587,12 +590,13 @@ async def test_kakao_push_is_off_unless_switched_on(client, db_engine, auth_head assert kakao == [] -async def test_send_now_also_pushes_to_kakao(client, db_engine, auth_headers, monkeypatch): - """'승인 알림보내기' 버튼도 같은 경로다 — 메일만 가고 카톡은 빠지면 안 된다.""" +async def test_send_now_sends_only_kakao_when_linked(client, db_engine, auth_headers, monkeypatch): + """'승인 알림보내기' 버튼도 같은 경로다 — 연결돼 있으면 카톡으로만 간다.""" from services import mail_service monkeypatch.setattr(mail_service, "is_configured", lambda: True) - monkeypatch.setattr(mail_service, "send", lambda **kwargs: True) + mails = [] + monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True) kakao = _record_kakao_send(monkeypatch) place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend6", "카톡즉시펜션") await _seed_post(db_engine, place_id, scheduled=date.today()) @@ -601,6 +605,61 @@ async def test_send_now_also_pushes_to_kakao(client, db_engine, auth_headers, mo assert res.json()["result"]["success"] is True assert len(kakao) == 1 + assert mails == [] + + +async def test_send_now_falls_back_to_mail_when_kakao_fails(client, db_engine, auth_headers, monkeypatch): + """'승인 알림보내기' 도 같은 정책이다 — 카톡이 실패하면 메일로 나가 사장님 화면에는 성공으로 보인다.""" + from services import mail_service + + monkeypatch.setattr(mail_service, "is_configured", lambda: True) + mails = [] + monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True) + kakao = _record_kakao_send(monkeypatch, fail=True) + place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend7", "카톡즉시실패펜션") + await _seed_post(db_engine, place_id, scheduled=date.today()) + + res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h) + + assert res.json()["result"]["success"] is True + assert len(kakao) == 1 + assert len(mails) == 1 + + +async def test_send_now_reports_failure_when_both_kakao_and_mail_fail(client, db_engine, auth_headers, monkeypatch): + """둘 다 안 나갔으면 사장님 화면에 실패로 보여야 한다 — 성공처럼 보이면 알림이 안 간 걸 모른다.""" + from services import mail_service + + monkeypatch.setattr(mail_service, "is_configured", lambda: False) + _record_kakao_send(monkeypatch, fail=True) + place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend9", "카톡메일둘다실패펜션") + post_id = await _seed_post(db_engine, place_id, scheduled=date.today()) + + res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h) + + assert res.json()["result"]["success"] is False + assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value + + +async def test_mail_is_still_sent_when_kakao_is_not_linked(client, db_engine, auth_headers, monkeypatch): + """카톡을 연결하지 않은 사장님은 스위치가 켜져 있어도 예전처럼 메일을 받는다.""" + from services import blog_jobs, mail_service + + monkeypatch.setattr(mail_service, "is_configured", lambda: True) + mails = [] + monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True) + kakao = _record_kakao_send(monkeypatch) + place_id, _speaker, _h = await _publish_site_and_link_kakao( + client, db_engine, auth_headers, monkeypatch, "kakaosend8", "카톡미연결펜션", link=False, + ) + post_id = await _seed_post(db_engine, place_id, scheduled=date.today()) + + sent = await blog_jobs.send_reviewed() + + assert sent == 1 + assert len(mails) == 1 + assert kakao == [] + assert await _status(db_engine, post_id) == PostStatus.SENT.value async def _day_pass_headers(db_engine, login_id: str) -> dict: