From f0153f28bc0361554454a255324c921fe2c4eeae Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 7 Sep 2026 10:49:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=8B=9C=EC=84=A4=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=20=EC=9B=94=EA=B0=84=20=EB=A6=AC=EB=A7=88?= =?UTF-8?q?=EC=9D=B8=EB=8D=94=20+=20=EB=8F=99=EA=B2=B0=20=ED=96=89=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EB=B3=80=EA=B2=BD=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?(0033=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0033 §8 의 사람 의존 두 가지를 장치로 바꾼다. ① cron 'facility-resync-reminder' — 매월 1일 09:00 KST 에 ops_alarm_fire → 관리자 전원 알림. 이번 사고의 본질이 "잊혀서 6주 동결" 인데 재적재는 여전히 수동이라, 잊지 않게 하는 장치까지가 파이프라인이다. ② trg_facilities_frozen_addr_alert — owner_updated_at(이름 동결) 행의 주소가 재적재로 덮이는 순간 알림. 임시 개명 행이 "새 가게 이름 + 옮겨간 주소" 로 어긋나는 걸 재적재 때마다 사람이 기억해 확인하는 대신 그 UPDATE 에서 즉시 잡는다. 행 id 하드코딩 없이 동결 행 전체를 본다. trg_reports_alert 와 같은 예외 삼킴 — 알람 실패가 적재를 막지 않는다. 운영 적용 완료. 롤백 트랜잭션 실증(t26 과 같은 시나리오): fired=1 notif=1 detail=[옛 주소 1→새 주소 1] cooldown=1 total=1 ⚠️ 이 세션의 IP 가 DB allow_list 에 없어 스냅샷 갱신을 못 했다 — 머지 전에 ./scripts/dump_schema.sh 와 ./scripts/run_db_tests.sh 실행 필요. Co-Authored-By: Claude Fable 5 --- ...ility_resync_reminder_and_frozen_watch.sql | 81 +++++++++++++++++++ .../tests/t26_facility_frozen_addr_test.sql | 72 +++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 supabase/migrations/20260907090000_facility_resync_reminder_and_frozen_watch.sql create mode 100644 supabase/tests/t26_facility_frozen_addr_test.sql diff --git a/supabase/migrations/20260907090000_facility_resync_reminder_and_frozen_watch.sql b/supabase/migrations/20260907090000_facility_resync_reminder_and_frozen_watch.sql new file mode 100644 index 0000000..ec6aa85 --- /dev/null +++ b/supabase/migrations/20260907090000_facility_resync_reminder_and_frozen_watch.sql @@ -0,0 +1,81 @@ +-- 시설 데이터 운영 장치 두 가지 — 0033 §8 "남은 것" 의 사람 의존을 장치로 바꾼다. +-- +-- ① 월 1회 재적재 리마인더 (cron) +-- +-- 적재 파이프라인에는 cron 이 없다 — 함수가 CSV 를 직접 받지 않는 구조라(0033 +-- §4.2) 사람이 파일을 받아 스크립트를 돌려야 한다. 그런데 이번 사고의 본질이 +-- "잊혀서 6주 동결" 이었다. 잊지 않게 하는 장치까지가 파이프라인이다. +-- 매월 1일 09:00 KST 에 ops_alarm_fire → 관리자 전원 알림(→ 푸시). +-- +-- ② 이름 동결(owner_updated_at) 행의 주소 변경 감지 (trigger) +-- +-- 임시 개명 행(0033 §8): 이름은 owner_updated_at 으로 얼렸지만 **주소는 못 +-- 얼린다** — 주소는 인허가 데이터 영역이라 재적재가 항상 덮는다(의도된 정책, +-- 20260715130000). 원 매장의 이전이 LOCALDATA 에 반영되는 순간 "새 가게 이름 + +-- 옮겨간 주소" 라는 어긋난 행이 되는데, 지금은 재적재 때마다 사람이 기억해서 +-- 확인해야 한다. 어긋나는 그 UPDATE 순간에 알리면 기억할 필요가 없다. +-- +-- 특정 행 id 를 하드코딩하지 않는다 — owner_updated_at 을 가진 행 전부를 본다. +-- 그 행들은 전부 "간판명·전화가 동결된 행" 이고, 동결된 이름 아래에서 주소가 +-- 움직이면 어느 행이든 사람이 봐야 한다. 같은 성격의 행이 늘어도 그대로 커버된다. +-- +-- 알람 실패가 재적재를 막으면 안 된다 — 이 트리거는 upsert_facilities 배치 안에서 +-- 돈다. trg_reports_alert(20260831061325)와 같은 예외 삼킴 패턴을 쓴다. + +-- ── ② 동결 행 주소 변경 감지 ──────────────────────────────────────────── + +create function app.tg_facilities_frozen_addr_alert() +returns trigger +language plpgsql +security definer +set search_path to '' +as $$ +begin + perform app.ops_alarm_fire( + 'facility_frozen_addr:' || new.id, + 1440, -- 행별 하루 쿨다운 — 같은 배치를 다시 돌려도 하루 한 번만. + '이름 동결 시설의 주소가 바뀜', + format('%s — 재적재가 주소를 덮었습니다. 새 가게 분리 등록/이전 처리가 필요한지 확인하세요(0033 §8).', new.name), + jsonb_build_object( + 'facility_id', new.id, + 'name', new.name, + 'old_address', old.address, + 'new_address', new.address + ) + ); + return new; +exception when others then + -- 알림 실패가 적재 배치 자체를 막으면 안 된다. + raise warning 'tg_facilities_frozen_addr_alert failed: %', sqlerrm; + return new; +end $$; + +comment on function app.tg_facilities_frozen_addr_alert() is + '이름 동결(owner_updated_at) 시설의 주소가 재적재로 덮이는 순간 관리자에게 알린다(0033 §8 임시 개명 행).'; + +create trigger trg_facilities_frozen_addr_alert + after update on public.facilities + for each row + when (new.owner_updated_at is not null + and new.address is distinct from old.address) + execute function app.tg_facilities_frozen_addr_alert(); + +comment on trigger trg_facilities_frozen_addr_alert on public.facilities is + '동결 행 주소 변경 감지 — 재적재 때마다 사람이 확인하던 것을 UPDATE 시점 알림으로 대체.'; + +-- ── ① 월간 재적재 리마인더 ────────────────────────────────────────────── +-- 매월 1일 00:00 UTC = 09:00 KST. 쿨다운 1일 — 수동 발화 시험과 겹쳐도 중복 없음. + +do $$ begin + if exists (select 1 from cron.job where jobname = 'facility-resync-reminder') then + perform cron.unschedule('facility-resync-reminder'); + end if; +end $$; +select cron.schedule('facility-resync-reminder', '0 0 1 * *', $$ + select app.ops_alarm_fire( + 'facility_resync_due', 1440, + '시설 데이터 월간 재적재', + '재적재를 돌릴 때입니다 — 0033 §7 절차대로: CSV 4종(동물판매업 포함)·Excel 금지·최종수정시점 확인·--dry-run 먼저. 폐업·휴업 0건 파일은 필터본이니 쓰지 말 것.', + jsonb_build_object('doc', '0033 §7') + ) +$$); diff --git a/supabase/tests/t26_facility_frozen_addr_test.sql b/supabase/tests/t26_facility_frozen_addr_test.sql new file mode 100644 index 0000000..c0a372b --- /dev/null +++ b/supabase/tests/t26_facility_frozen_addr_test.sql @@ -0,0 +1,72 @@ +-- 동결 행 주소 변경 감지 — 사람 기억을 대체하는 장치가 실제로 울리는가. +-- +-- 이 트리거의 실패 방식도 t21 과 같다: "틀린 알람" 이 아니라 **"아무 알람도 안 옴"**. +-- 재적재는 한 달에 한 번이고 어긋나는 행은 한 개라, 안 울려도 아무도 모른다. +-- 그래서 울려야 할 때(동결 행 주소 변경)와 울리면 안 될 때(일반 행·주소 무변경)를 +-- 함께 못 박는다. +begin; +set local search_path = public, app, extensions; +\ir helpers/seed.sql +select plan(6); + +-- 관리자 한 명 — 알람 수신자(ops_alarm_fire 가 notifications 를 만든다). +with u as ( + insert into public.users (username, password_hash, nickname, user_type, status) + values ('t_admin26', 'x', '시드관리자', 'admin', 'active') + returning id +) insert into seed select 'admin', id from u; + +-- 시설 두 행: 동결(owner_updated_at 有) / 일반. +insert into public.facilities (category, source, ext_id, name, address, owner_updated_at) +values ('grooming', 'test', 't26-frozen', '동결샵', '옛 주소 1', now()); +insert into public.facilities (category, source, ext_id, name, address) +values ('grooming', 'test', 't26-normal', '일반샵', '옛 주소 2'); + +-- ── 1. 동결 행의 주소가 바뀌면 울린다 ───────────────────────────────────── +update public.facilities set address = '새 주소 1' where ext_id = 't26-frozen'; + +select is( + (select count(*)::int from app.ops_alarms + where alarm_key = 'facility_frozen_addr:' + || (select id from public.facilities where ext_id = 't26-frozen')), + 1, '동결 행 주소 변경은 알람 1건'); +select is( + (select count(*)::int from public.notifications n + where n.user_id = (select id from seed where k = 'admin') + and n.title = '이름 동결 시설의 주소가 바뀜'), + 1, '활성 관리자에게 알림이 간다'); +select is( + (select (a.detail->>'old_address') || '→' || (a.detail->>'new_address') + from app.ops_alarms a + where a.alarm_key like 'facility_frozen_addr:%' limit 1), + '옛 주소 1→새 주소 1', 'detail 에 변경 전후 주소가 남는다 — 알람만 보고 판단할 수 있어야 한다'); + +-- ── 2. 쿨다운 — 같은 행을 하루 안에 또 덮어도 중복으로 울리지 않는다 ────── +update public.facilities set address = '새 주소 1-2' where ext_id = 't26-frozen'; +select is( + (select count(*)::int from app.ops_alarms where alarm_key like 'facility_frozen_addr:%'), + 1, '행별 쿨다운(1일) 안의 재변경은 접힌다'); + +-- ── 3. 울리면 안 될 때 ──────────────────────────────────────────────────── +update public.facilities set address = '새 주소 2' where ext_id = 't26-normal'; +update public.facilities set phone = '0311234567' where ext_id = 't26-frozen'; +select is( + (select count(*)::int from app.ops_alarms where alarm_key like 'facility_frozen_addr:%'), + 1, '일반 행 주소 변경·동결 행 주소 외 변경은 울리지 않는다'); + +-- ── 4. 알람 실패가 적재를 막지 않는다 ───────────────────────────────────── +-- ops_alarms 에 같은 트랜잭션이 못 넣게 만들 방법이 마땅치 않으니, 함수를 직접 +-- 깨진 인자로 불러 예외 삼킴을 검증하는 대신 **트리거 함수의 예외 절이 UPDATE 를 +-- 살리는지**를 본다: ops_alarm_fire 를 일시적으로 실패하게 바꾼다. +create or replace function app.ops_alarm_fire( + p_key text, p_cooldown_min integer, p_title text, p_body text, p_detail jsonb) +returns integer language plpgsql security definer set search_path to '' as +$t26$ begin raise exception 't26: 강제 실패'; end $t26$; + +update public.facilities set address = '새 주소 1-3' where ext_id = 't26-frozen'; +select is( + (select address from public.facilities where ext_id = 't26-frozen'), + '새 주소 1-3', '알람이 죽어도 적재(UPDATE)는 산다 — 예외 삼킴'); + +select * from finish(); +rollback; From e30cdfcbcd2628a882df595dd0bacef55da28b08 Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 7 Sep 2026 11:05:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=E2=80=94=20=EB=8F=99=EA=B2=B0=20=ED=96=89?= =?UTF-8?q?=20=EA=B0=90=EC=A7=80=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=C2=B7?= =?UTF-8?q?=EC=9E=AC=EC=A0=81=EC=9E=AC=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=8D=94=20=ED=81=AC=EB=A1=A0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 pgTAP 스위트(t26 포함 26파일) 운영 DB 통과 확인 후 갱신. baseline 은 무변화 — 새 객체가 마이그레이션 산출물로 올바르게 분류됐다. Co-Authored-By: Claude Fable 5 --- supabase/schema/outofband.txt | 1 + supabase/schema/schema.sql | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/supabase/schema/outofband.txt b/supabase/schema/outofband.txt index f492888..c0ab006 100644 --- a/supabase/schema/outofband.txt +++ b/supabase/schema/outofband.txt @@ -13,6 +13,7 @@ CRON auth-cleanup [17 * * * *] active=true :: select app.cleanup_auth(); CRON business-docs-purge [13 4 * * *] active=true :: select net.http_post( url := (select function_url from app.business_purge_config), headers := jsonb_build_object( 'Content-Type', 'application/json', 'x-purge-secret', (select trigger_secret from app.business_purge_config)), body := '{}'::jsonb ); CRON care-report-hmac-purge [58 3 * * *] active=true :: update app.care_reports r set recipient_phone_hmac = null where r.recipient_phone_hmac is not null and r.claimed_by is null and exists (select 1 from app.share_links l where l.kind = 'care_report' and l.ref_id = r.id and l.expires_at < now()); update app.care_threads t set recipient_phone_hmac = null where t.recipient_phone_hmac is not null and t.claimed_by is null and coalesce(t.last_report_at, t.created_at) < now() - interval '30 days' CRON engagement-sweep [* * * * *] active=true :: select app.dispatch_engagement_notifications(); +CRON facility-resync-reminder [0 0 1 * *] active=true :: select app.ops_alarm_fire( 'facility_resync_due', 1440, '시설 데이터 월간 재적재', '재적재를 돌릴 때입니다 — 0033 §7 절차대로: CSV 4종(동물판매업 포함)·Excel 금지·최종수정시점 확인·--dry-run 먼저. 폐업·휴업 0건 파일은 필터본이니 쓰지 말 것.', jsonb_build_object('doc', '0033 §7') ) CRON funnel-events-retention [53 3 * * *] active=true :: delete from app.funnel_events where created_at < now() - interval '1 year' CRON ops-alarm-sweep [*/5 * * * *] active=true :: select app.ops_alarm_sweep(); CRON push-sweep [* * * * *] active=true :: select net.http_post( url := (select function_url from app.push_config), headers := jsonb_build_object('Content-Type','application/json','x-push-secret',(select trigger_secret from app.push_config)), body := '{}'::jsonb) where exists (select 1 from public.notifications where push_status = 'pending'); diff --git a/supabase/schema/schema.sql b/supabase/schema/schema.sql index 5675cb4..29e9d55 100644 --- a/supabase/schema/schema.sql +++ b/supabase/schema/schema.sql @@ -1616,6 +1616,42 @@ end; $$; +-- +-- Name: tg_facilities_frozen_addr_alert(); Type: FUNCTION; Schema: app; Owner: - +-- + +CREATE FUNCTION app.tg_facilities_frozen_addr_alert() RETURNS trigger + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +begin + perform app.ops_alarm_fire( + 'facility_frozen_addr:' || new.id, + 1440, -- 행별 하루 쿨다운 — 같은 배치를 다시 돌려도 하루 한 번만. + '이름 동결 시설의 주소가 바뀜', + format('%s — 재적재가 주소를 덮었습니다. 새 가게 분리 등록/이전 처리가 필요한지 확인하세요(0033 §8).', new.name), + jsonb_build_object( + 'facility_id', new.id, + 'name', new.name, + 'old_address', old.address, + 'new_address', new.address + ) + ); + return new; +exception when others then + -- 알림 실패가 적재 배치 자체를 막으면 안 된다. + raise warning 'tg_facilities_frozen_addr_alert failed: %', sqlerrm; + return new; +end $$; + + +-- +-- Name: FUNCTION tg_facilities_frozen_addr_alert(); Type: COMMENT; Schema: app; Owner: - +-- + +COMMENT ON FUNCTION app.tg_facilities_frozen_addr_alert() IS '이름 동결(owner_updated_at) 시설의 주소가 재적재로 덮이는 순간 관리자에게 알린다(0033 §8 임시 개명 행).'; + + -- -- Name: tg_facility_review_aggs(); Type: FUNCTION; Schema: app; Owner: - -- @@ -10305,6 +10341,20 @@ CREATE TRIGGER trg_comments_soft_delete_ts BEFORE UPDATE ON public.comments FOR CREATE TRIGGER trg_device_tokens_updated BEFORE UPDATE ON public.device_tokens FOR EACH ROW EXECUTE FUNCTION app.tg_set_updated_at(); +-- +-- Name: facilities trg_facilities_frozen_addr_alert; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_facilities_frozen_addr_alert AFTER UPDATE ON public.facilities FOR EACH ROW WHEN (((new.owner_updated_at IS NOT NULL) AND (new.address IS DISTINCT FROM old.address))) EXECUTE FUNCTION app.tg_facilities_frozen_addr_alert(); + + +-- +-- Name: TRIGGER trg_facilities_frozen_addr_alert ON facilities; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TRIGGER trg_facilities_frozen_addr_alert ON public.facilities IS '동결 행 주소 변경 감지 — 재적재 때마다 사람이 확인하던 것을 UPDATE 시점 알림으로 대체.'; + + -- -- Name: facility_reviews trg_facility_review_recall; Type: TRIGGER; Schema: public; Owner: - --