카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 user_id 와 관계가 없다. 다른 엔드포인트는 전부 place_crud.get_place(s, owner_user_id, place_id) 로 소유자 범위를 지키는데 채널 발화에는 그 owner_user_id 를 줄 근거가 없다 — 매핑이 없으면 채널 진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다. - postgres-init: owner_kakao_links(0021 + init.sql). 부분 유니크 셋 중 uq_kakao_link_channel_key(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다 - services/kakao_link_service: 일회성은 코드 값이 아니라 WHERE status='PENDING' CAS 한 문장이 보장한다. 실패는 전부 같은 에러 — 없는 코드·만료·시도초과를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다 - 코드는 sha256 만 저장. 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧 연결 권한이다. 글자에서 0·O·1·I·L 제외 — 잘못 읽으면 원인이 화면에 안 보인다 - router/v1/agent/kakao: 셋 다 no-store·no-referrer·noindex. ★ 소비(redeem) 엔드포인트는 일부러 없다 — 웹훅 서명 검증 전에 공개 소비 경로를 열면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 - config/agent_config: social_config 와 일부러 가름. SNS 게재는 되돌릴 수 없는 대외 발화, 에이전트는 자기 사이트를 고치는 창구 — 승인 강도가 다르다 - frontend/features/agent: /sites 의 Threads 카드 옆. 연결은 사람 단위라 같은 자리다 - docs/AGENT.md 신설, CLAUDE.md 색인·함정, DEVLOG test_kakao_link.py 15 passed. 전체 780 passed / 50 failed — 그 50건은 HEAD 에서도 동일(워크트리 대조), 기존 이슈로 이번 변경과 무관. npm run lint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
6.0 KiB
Python
143 lines
6.0 KiB
Python
import time
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request, Response
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from sqlalchemy import text
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType, DBWRType
|
|
from common.logger import LOG
|
|
from common.utils.gtime import GTime
|
|
from config.server_configs import web_server_config
|
|
from scheduler import shutdown_scheduler, start_scheduler
|
|
import router.v1.auth.account
|
|
import router.v1.place.place
|
|
import router.v1.fact.fact
|
|
import router.v1.faq.faq
|
|
import router.v1.media.media
|
|
import router.v1.media.relay
|
|
import router.v1.job.job
|
|
import router.v1.site.site
|
|
import router.v1.site.showcase
|
|
import router.v1.site.booking_request
|
|
import router.v1.site.post
|
|
import router.v1.site.review
|
|
import router.v1.local.local
|
|
import router.v1.social.social
|
|
import router.v1.social.oauth
|
|
import router.v1.agent.kakao
|
|
|
|
API_SERVER_START_TIME = GTime.UTCStr()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# startup: 배치 스케줄러 기동(SCHEDULER_ENABLED=1 인 프로세스에서만)
|
|
start_scheduler()
|
|
yield
|
|
# shutdown: 스케줄러 정지 + DB 엔진 커넥션 풀 정리
|
|
shutdown_scheduler()
|
|
await DB_SESSION_MNG.dispose_all()
|
|
|
|
|
|
app = FastAPI(title="Web4Ai API", lifespan=lifespan)
|
|
|
|
# CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외).
|
|
#
|
|
# ★ client_url 은 쉼표로 여러 오리진을 받는다. 로컬 개발에서 vite 는 3000 이 막혀 있으면
|
|
# 3001, 3002… 로 옮겨 뜨는데(--port 는 희망값이지 고정이 아니다), 그때마다 서버 설정을
|
|
# 고치게 하면 원인이 CORS 라는 걸 알아내는 데만 반나절이 든다. 개발 포트 몇 개를 한 줄에 적어 둔다.
|
|
# 운영은 실제 도메인 하나만 적으면 된다.
|
|
def _origins(*values: str) -> list[str]:
|
|
seen: list[str] = []
|
|
for value in values:
|
|
for origin in value.split(","):
|
|
origin = origin.strip().rstrip("/")
|
|
if origin and origin not in seen:
|
|
seen.append(origin)
|
|
return seen
|
|
|
|
|
|
ALLOWED_ORIGINS = _origins(web_server_config.client_url, web_server_config.landing_url)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=ALLOWED_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축.
|
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def log_time(request: Request, call_next):
|
|
start_time = time.time()
|
|
response = await call_next(request)
|
|
if request.url.path.startswith('/v1/social/'):
|
|
response.headers['Cache-Control'] = 'no-store'
|
|
response.headers['Referrer-Policy'] = 'no-referrer'
|
|
response.headers['X-Robots-Tag'] = 'noindex, nofollow'
|
|
elapsed = time.time() - start_time
|
|
# status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게).
|
|
LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s")
|
|
return response
|
|
|
|
|
|
@app.get(path="/healthz", responses={404: {"description": "Not found"}})
|
|
async def healthz():
|
|
return API_SERVER_START_TIME
|
|
|
|
|
|
@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}})
|
|
async def readyz(response: Response):
|
|
"""★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200),
|
|
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
|
|
|
|
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
|
|
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
|
|
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
|
|
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
|
|
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다."""
|
|
try:
|
|
async def _ping(s):
|
|
await s.execute(text("SELECT 1"))
|
|
return True
|
|
|
|
await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _ping)
|
|
return {"ok": True, "db": "up"}
|
|
except Exception as ex: # noqa: BLE001 — 준비 안 됐다는 것 자체가 이 엔드포인트의 응답이다
|
|
LOG.w(f"[readyz] DB 연결 확인 실패: {type(ex).__name__}: {ex}")
|
|
response.status_code = 503
|
|
return {"ok": False, "db": "down"}
|
|
|
|
|
|
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
|
app.include_router(router.v1.auth.account.router)
|
|
app.include_router(router.v1.place.place.router)
|
|
app.include_router(router.v1.fact.fact.router)
|
|
app.include_router(router.v1.faq.faq.router)
|
|
app.include_router(router.v1.media.media.router)
|
|
# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 —
|
|
# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py).
|
|
app.include_router(router.v1.media.relay.router)
|
|
app.include_router(router.v1.job.job.router)
|
|
app.include_router(router.v1.site.site.router)
|
|
app.include_router(router.v1.site.site.my_router)
|
|
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
|
|
app.include_router(router.v1.site.showcase.router)
|
|
app.include_router(router.v1.site.booking_request.router)
|
|
app.include_router(router.v1.site.post.router)
|
|
app.include_router(router.v1.site.post.owner_router)
|
|
app.include_router(router.v1.site.review.router)
|
|
app.include_router(router.v1.local.local.router)
|
|
app.include_router(router.v1.local.local.weather_router)
|
|
|
|
app.include_router(router.v1.social.social.router)
|
|
app.include_router(router.v1.social.oauth.router)
|
|
app.include_router(router.v1.agent.kakao.router)
|