auto: 일일 백업 2026-08-05 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-05 02:00:06 +09:00
parent 7e7d759b34
commit c03b3f2659
148 changed files with 3428 additions and 1380 deletions
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""후잉 OpenAPI 잔액(bs.json) 조회.
"""후잉 OpenAPI 클라이언트 — 잔액(bs.json) 조회 + 거래(entries.json) 조회·감액.
Usage:
whooing_balance.py # 모든 섹션의 현재 잔액
@@ -11,11 +11,13 @@ from __future__ import annotations
import argparse
import json
import re
import secrets
import sys
import time
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from pathlib import Path
CRED_PATH = Path("/Users/snowoyh/.openclaw/credentials/whooing.json")
@@ -41,6 +43,130 @@ def api_get(endpoint: str, api_key: str, params: dict | None = None) -> dict:
return data["results"]
def api_key_from_cred() -> str:
"""credentials 에서 API 키 문자열 1회 생성."""
api_cfg = json.loads(CRED_PATH.read_text())["api"]
return build_api_key(api_cfg["app_id"], api_cfg["token"], api_cfg["signature"])
def api_request(method: str, endpoint: str, api_key: str,
params: dict | None = None, body: dict | None = None) -> dict:
"""후잉 OpenAPI 호출. GET 은 쿼리스트링, PUT 은 본문으로 파라미터를 보낸다.
⚠️ DELETE 는 쓸 수 없다. 후잉 서버가 DELETE 요청의 파라미터를 쿼리·본문(urlencoded/JSON)·
쿠키·경로 어디에서도 읽지 못해 항상 `section_id parameter is required` 로 실패한다
(2026-08-04 전수 실측. 같은 본문을 PUT 으로 보내면 정상이라 후잉 쪽 문제다).
거래를 지우는 대신 PUT 으로 금액을 줄이는 이유가 이것이다.
"""
url = f"{BASE}/{endpoint}"
if params:
url += "?" + urllib.parse.urlencode(params)
data = None
if body:
# 후잉은 '+' 를 공백으로 디코드하지 않는다. quote_via=quote 로 공백을 %20 으로 보낸다.
data = urllib.parse.urlencode(body, quote_via=urllib.parse.quote).encode()
req = urllib.request.Request(url, data=data, method=method, headers={"X-API-KEY": api_key})
if data:
req.add_header("Content-Type", "application/x-www-form-urlencoded")
with urllib.request.urlopen(req, timeout=15) as resp:
parsed = json.loads(resp.read().decode("utf-8"))
if parsed.get("code") != 200:
raise RuntimeError(
f"후잉 API error {parsed.get('code')}: {parsed.get('message')} "
f"{parsed.get('error_parameters') or ''} ({method} {endpoint})"
)
return parsed["results"]
def _section_list(api_key: str) -> list:
sections = api_request("GET", "sections.json", api_key)
if isinstance(sections, dict):
return sections.get("sections") or sections.get("rows") or list(sections.values())
return sections
def _name_to_account_id(api_key: str, section_id) -> dict[str, str]:
raw = api_request("GET", "accounts.json", api_key, {"section_id": section_id})
out: dict[str, str] = {}
for acc_list in raw.values():
if not isinstance(acc_list, list):
continue
for a in acc_list:
aid = str(a.get("account_id"))
out[a.get("title") or aid] = aid
return out
def _memo_has_merchant(memo: str, merchant: str) -> bool:
"""memo 에 merchant 가 토큰 단위로 들어있는지.
단순 substring 이면 '쿠팡''쿠팡페이주' 승인까지 잡아 엉뚱한 거래를 감액한다
(2026-08-04 실제로 두 상호가 같은 날 같은 금액으로 있었다). 카드 SMS 는 상호를
줄바꿈·공백으로 구분하므로 앞뒤가 공백이거나 문자열 끝일 때만 인정한다.
"""
if not memo or not merchant:
return False
for m in re.finditer(re.escape(merchant), memo):
before = memo[m.start() - 1] if m.start() > 0 else " "
after = memo[m.end()] if m.end() < len(memo) else " "
if before.isspace() and after.isspace():
return True
return False
def find_card_entries(card_account: str, money: int, merchant: str,
on_date: str, window_days: int = 30) -> list[dict]:
"""카드 취소의 원본 승인 거래 후보를 찾는다.
card_account 로 결제된 거래 중 memo 에 merchant 가 들어있고 금액이 취소액 이상인 것을
[on_date - window_days, on_date] 구간에서 찾는다. 금액 일치(전액취소)를 우선 반환하고,
없으면 초과분(부분취소 후보)을 반환한다.
⚠️ item 이 아니라 memo 로 매칭한다 — merchant_map 의 contains 룰이 item 을 재작성해서
(동행복권→'복권') item 은 상호와 다를 수 있지만, memo 는 항상 승인 SMS 원문이다.
⚠️ 판단은 호출측이 한다. 정확히 1건일 때만 손대는 게 안전하다.
"""
api_key = api_key_from_cred()
start = (datetime.strptime(on_date, "%Y%m%d") - timedelta(days=window_days)).strftime("%Y%m%d")
exact, over = [], []
for sec in _section_list(api_key):
sid = sec.get("section_id") or sec.get("id")
card_id = _name_to_account_id(api_key, sid).get(card_account)
if not card_id:
continue
rows = api_request("GET", "entries.json", api_key, {
"section_id": sid, "start_date": start, "end_date": on_date,
})
for row in (rows.get("rows") or []):
if str(row.get("r_account_id")) != card_id:
continue
if not _memo_has_merchant(row.get("memo") or "", merchant):
continue
row_money = row.get("money") or 0
if row_money == money:
exact.append({**row, "section_id": sid})
elif row_money > money:
over.append({**row, "section_id": sid})
return exact or over
def set_entry_money(entry: dict, money: int) -> None:
"""거래 금액을 money 로 바꾼다(PUT). money=0 도 후잉이 받는다(2026-08-04 실측).
조회한 나머지 필드를 그대로 되돌려보내 보존한다 — 부분 PUT 의 필드 보존 동작에
기대지 않는 편이 안전하다. entry_date 는 조회 시 '20260804.0000' 형태라 소수부를 뗀다.
"""
api_request("PUT", f"entries/{entry['entry_id']}.json", api_key_from_cred(), body={
"section_id": entry["section_id"],
"entry_date": str(entry.get("entry_date") or "").split(".")[0],
"l_account_id": entry.get("l_account_id"),
"r_account_id": entry.get("r_account_id"),
"item": entry.get("item") or "",
"money": str(money),
"memo": entry.get("memo") or "",
})
def fmt_won(n: int) -> str:
return f"{n:,}"
@@ -515,20 +515,9 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
return {**base, "left": cat, "right": carrier_acct}
return {**base, "left": "기타비용", "right": carrier_acct}
if kind == "card_cancel":
# 승인의 역분개. 승인이 {left: 비용, right: 카드} 이므로 좌우를 뒤집는다.
# 부분취소(취소액 != 승인액)도 취소 문자 금액 그대로 상쇄되어 맞는다.
if rule:
left = rule.get("left")
if left:
out = {**base, "left": rule.get("right") or carrier_acct, "right": left}
if rule.get("item"):
out["item"] = rule["item"]
return out
return None
if cat:
return {**base, "left": carrier_acct, "right": cat}
return {**base, "left": carrier_acct, "right": "기타비용"}
# card_cancel 은 여기서 payload 를 만들지 않는다 — 처리 루프의 _handle_card_cancel 이
# 원본 승인 거래를 찾아 PUT 으로 감액한다. 좌우를 뒤집은 역분개는 후잉이 거부한다
# (비용 계정은 차변 전용 → `r_account 잘못된 값입니다`) — 2026-08-03 회귀의 원인.
if kind == "withdrawal":
if rule:
@@ -559,6 +548,38 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
return None
def _handle_card_cancel(parsed, sender_info, accounts, dry_run=False):
"""카드 취소 → 원본 승인 거래를 찾아 취소액만큼 감액.
반환 (ok, detail). ok=False 원본을 특정하지 못한 것이니 호출측이 raw 폴백으로 넘긴다.
폴백은 자동 처리가 아니다 후잉은 취소 문자를 받아도 원본을 지워주지 않으므로
(공식 FAQ 환불은 수동 처리하라고 안내한다) 관리자님이 후잉 UI 에서 손봐야 한다.
그래서 폴백 건은 raw 폴백 알림에 그대로 실어 보낸다.
전액취소면 금액이 0 되어 줄은 남는다. 후잉 API 로는 거래를 지울 없기 때문이다
(whooing_balance.api_request 주석 참고). 잔액·합계에는 영향이 없다.
"""
carrier_acct = accounts.get("carrier_to_account", {}).get(sender_info.get("carrier"), "")
if not carrier_acct:
return False, "카드 계정 매핑 없음"
money = parsed["amount"]
cands = whooing_balance.find_card_entries(
carrier_acct, money, parsed["merchant"], parsed["entry_date"]
)
if len(cands) != 1:
return False, f"원본 승인 후보 {len(cands)}건 — 1건일 때만 감액"
orig = cands[0]
new_money = (orig.get("money") or 0) - money
detail = (f"{orig.get('item')} {orig.get('money'):,}원 → {new_money:,}"
f"(취소 {money:,}원, entry {orig['entry_id']})")
if dry_run:
return True, f"(dry-run) {detail}"
whooing_balance.set_entry_money(orig, new_money)
return True, detail
def _parse_iso_utc(iso: str) -> datetime:
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
@@ -754,6 +775,7 @@ def main():
sent_transfer = 0
sent_structured = 0
sent_raw = 0
sent_cancel = 0
failed = 0
new_failures = []
new_raw = [] # raw 폴백으로 성공 처리된 건 (구조화 실패 신호) — 텔레그램 알림용
@@ -811,8 +833,42 @@ def main():
blocked_at = c["created_at"]
break
# 카드 취소는 상쇄 줄을 더하지 않고 원본 승인 거래를 감액한다.
# 원본을 못 찾으면 아래 raw 폴백으로 흘러간다(build_structured 가 None 을 준다).
if parsed and parsed.get("kind") == "card_cancel":
try:
cancel_ok, cancel_detail = _handle_card_cancel(
parsed, c["info"], accounts, args.dry_run
)
except Exception as e:
failed += 1
detail = f"{type(e).__name__}: {e}"
new_failures.append({
"sender": c["sender"], "label": c["info"]["label"],
"created_at": c["created_at"], "text": c["text"],
"mode": "cancel_reduce", "payload": {},
"status": 0, "response": detail[:500],
"failed_at": datetime.now(KST).isoformat(),
})
print(f" ❌ [cancel] {c['info']['label']} {c['created_at']} | {detail[:120]}")
blocked_at = c["created_at"]
break
if cancel_ok:
sent_cancel += 1
carrier_key = c["info"].get("carrier")
if carrier_key:
recovery_hints[carrier_key] = cancel_detail
print(f" [cancel] {c['info']['label']} {c['created_at']} | {cancel_detail}")
if c["created_at"] > (latest or ""):
latest = c["created_at"]
continue
print(f" ↩︎ [cancel→raw] {c['info']['label']} {c['created_at']} | {cancel_detail}")
# card_cancel 이 여기까지 오면 감액 실패 → raw 폴백만 남았다.
# apply_overrides 는 match 에 kind 가 없는 룰을 모든 kind 에 적용하므로, 그런 룰이
# 추가되면 취소가 좌우 뒤집힌 payload 로 새어나갈 수 있어 여기서 끊는다.
structured = None
if parsed:
if parsed and parsed.get("kind") != "card_cancel":
structured = apply_overrides(overrides, parsed, c["info"], accounts, merchant_map, c["created_at"])
if structured is None:
structured = build_structured(parsed, c["info"], accounts, merchant_map)
@@ -1138,9 +1194,9 @@ def main():
save_json(SYNC_STATE_FILE, sync_state)
if blocked_at:
print(f"⚠️ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 실패 {failed}건 — {blocked_at} 에서 중단(다음 cron 재시도)")
print(f"⚠️ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 — {blocked_at} 에서 중단(다음 cron 재시도)")
else:
print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 실패 {failed}건 (last={final_latest or latest})")
print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 (last={final_latest or latest})")
# 가희 잔액 리마인더 & 답신 자동 분개 (격리 — 실패해도 결제 sync 결과는 위에서 이미 출력됨)
run_gahee_reminder()