AI용 분할본 · 지도 1단계(준비·결정서) · 이 페이지 전에 읽을 페이지: PART 0 사용법·AI 시작 지침, PART 4 결정서·마스터 스펙 · 코드 파일은 여기 없다 — 코드 자리의 줄을 따른다(폴더에 책 파일이 하나도 없는 처음이면 부록 A-2 의 curl 한 줄부터)
핵심 규칙 — AI 는 어느 페이지에서든 이것을 지킨다
node scripts/get-book-code.mjs 가 책 그대로 만든다(첫 실행은 부록 A-2 의 curl 한 줄). AI용 분할본의 코드 자리는 " 경로" 한 줄이다 — 그 파일을 cat 으로 읽는다. 고쳐도 되는 파일은 PART 0 [AI 시작 지침] 5-b 의 목록뿐이다.node scripts/verify-book-code.mjs 를 실행해 "불일치 0개" 출력을 그대로 보인다.사주 상담 1회 = 질문 ~300자 + 사주 컨텍스트(무료) + 답변 ~1,500자
lib/api-cache.ts, 5-1). 출생 정보를 고치면 새로 한 번 부릅니다only: 'ziwei')은 토큰 값 + 명반 ₩50 이라 1회 약 ₩63~84only: 'astro')은 토큰 값 + 점성술 항목 ₩20 이라 1회 약 ₩33~54costPoints). 상담(only: 'tarot')은 토큰 값 + 타로 1회 요금only: 'dream')은 토큰 값 + ₩30 이라 1회 약 ₩43~64(못 찾으면 토큰 값만)예비 고객이 자주 묻습니다 — "이거로 사주보기, 연애운, 금전운, 2027년 신년운세 같은 걸 다 만들 수 있나요?" 만들 수 있습니다. 다만 HRUM 에 「연애운」「금전운」「신년운세」라는 이름의 API 가 따로 있는 것은 아니고, 아래 API 를 조합해 만듭니다. 그리고 같은 사람의 만세력·결과는 한 번 받아 저장해 두고 다시 씁니다 — 계산형은 같은 입력이면 같은 답이라 두 번째부터는 HRUM 을 부르지 않아도 되고, 그만큼 크레딧이 덜 듭니다.
| 상품 | 코드(API)로 — 이 책의 길 | 바로 편집기로 |
|---|---|---|
| 사주보기 (기본) | POST /manseryeok(사주판 — 5-1) · 풀이 문단은 POST /lifetime(성격·재물·직업·연애·건강·인간관계·조언 주제별) | 만세력 블록 |
| 연애운 | 연애운 전용 API 는 없습니다. 타고난 연애 성향 = /lifetime 에 topic: "love". 올해·특정 해의 흐름 = /saju-flow(그해 세운) + AI 리딩 POST /reading 의 question 으로 주제를 정합니다 | 평생 사주 블록 + 운 흐름 블록 |
| 금전운 | 금전운 전용 API 도 없습니다. /lifetime 에 topic: "wealth" + /saju-flow + /reading 의 question — 연애운과 같은 조합 | 평생 사주 블록 + 운 흐름 블록 |
| 신년운세 (예: 2027) | /saju-flow 의 range 를 그해로, monthsOfYear 를 그해로 → 그해 세운과 달마다의 월운. 문장은 /reading(question 에 "2027년 한 해") 또는 리포트 /reading/report 의 sections 에 원하는 제목(B-6) | 운 흐름 블록 |
| 오늘의 운세 | POST /today — date 는 한국 날짜 YYYY-MM-DD (생략하면 한국 시간 오늘) | 오늘의 운세 블록 |
| 궁합 | POST /compat (B-2) | 궁합 블록 |
운 흐름은 숫자입니다: /saju-flow 는 그래프용 숫자와 운·원국 사이의 관계 목록을 돌려주고 문장 풀이는 하지 않습니다(좋고 나쁨을 재는 값도 아닙니다). 문장은 AI 리딩이 씁니다. monthsOfYear 는 양력 1월이 아니라 그해 입춘부터 다음 해 입춘 전까지의 사주 연도입니다. 요청 필드도 만세력과 모양이 다릅니다 — 만세력·평생 사주·오늘의 운세는 year·month·day·hour, 운 흐름은 birthDate·birthTime 입니다. 필드 전체는 운 흐름·평생 사주·오늘의 운세·AI 사주 리딩 레퍼런스에 있습니다.
바로 편집기로는 어디까지 되나: 코드 없이 블록을 골라 만드는 바로 편집기에서 만세력·오늘의 운세·궁합·꿈해몽·평생 사주·운 흐름·자미두수 등 엔진 블록과 AI 대화를 모두 쓸 수 있어서, 연애운·금전운·신년운세도 블록을 조합해 만들 수 있습니다. 더 세밀하게 다듬고 싶으면 이 책처럼 코드로 만듭니다. AI 대화 블록은 이름·인사·지시문·지식 텍스트로 성격을 정하는 대화라 생년월일을 받지 않습니다 — 지시문에 "연애 고민 상대"라고 적을 수는 있지만 사주를 근거로 푸는 연애운은 아닙니다. 또 바로 편집기 앱에는 결과를 저장해 두는 단계가 없어, 같은 사람이 다시 눌러도 HRUM 을 한 번 더 부릅니다. 저장해서 아끼는 것은 코드로 만든 앱에서 합니다.
저장해서 다시 쓰기 — 원칙
app/api/float/[...path]/route.ts)가 만세력·궁합·자미두수 명반·네이탈·꿈해몽의 성공 응답을 회원·경로·입력별로 앱 전용 DB 의 api_cache 테이블에 저장하고, 같은 입력이면 HRUM 을 부르지 않고 저장본을 돌려줍니다(lib/api-cache.ts, 7-2). DB 는 RIDM Apps 가 앱마다 하나씩 주는 전용 PostgreSQL 이고, 연결 주소 DATABASE_URL 은 플랫폼이 넣어 줍니다(8-2) — 따로 가입할 DB 서비스는 없습니다range·monthsOfYear)가 들어가니 키에도 연도가 들어갑니다. 해가 바뀌면 새 키라 새해 것만 한 번 부르고, 지난해 것은 그대로 남아 다시 볼 수 있습니다date 에 넣어 날짜가 키가 되게 합니다(서버 시간대가 아니라 Asia/Seoul 기준 날짜)lang 을 바꿨을 때뿐이고, 그러면 본문이 달라져 저절로 새로 한 번 부릅니다/reading 은 같은 질문이어도 문장이 달라질 수 있어 "같은 입력 = 같은 답"이 아닙니다. 그래서 사용자에게 보여 준 답을 저장해 두었다가 다시 보여 줍니다(부록 E-2 리포트 보관과 같은 방식). 상담 채팅처럼 매번 새로 묻는 대화는 저장으로 줄일 수 없습니다책 뼈대 없이 직접 짠 Node 서버라면 아래처럼 하면 됩니다(pg 하나만 씁니다). 이 책의 뼈대로 만든 앱은 이 코드를 넣지 말고 그 아래 프롬프트를 쓰세요 — 같은 일을 이미 있는 lib/api-cache.ts·lib/float-client.ts 로 합니다.
// fortune-cache.mjs — keep HRUM results in this app's own PostgreSQL (RIDM Apps DATABASE_URL) and reuse them.
// Calculation results (manseryeok, lifetime, saju-flow, today, compat) are the same for the same input,
// so the first call goes to HRUM and every repeat is read from the DB. Errors are never saved.
import { createHash } from 'node:crypto';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
const HRUM = 'https://api.hrum.ai/api/v1/partners/v1/';
await pool.query(`CREATE TABLE IF NOT EXISTS fortune_cache (
user_id text NOT NULL,
cache_key text NOT NULL,
result jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, cache_key)
)`); // member withdraws → DELETE FROM fortune_cache WHERE user_id = $1
// Same fields in any order = same key (keys sorted at every level, undefined dropped)
const canon = (v) => Array.isArray(v) ? v.map(canon)
: v && typeof v === 'object'
? Object.fromEntries(Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => [k, canon(v[k])]))
: v;
export const cacheKey = (path, body) =>
createHash('sha256').update(path + '\n' + JSON.stringify(canon(body))).digest('hex');
export async function cachedCall(userId, path, body) {
const key = cacheKey(path, body);
const { rows } = await pool.query(
'SELECT result FROM fortune_cache WHERE user_id = $1 AND cache_key = $2', [userId, key]);
if (rows[0]) return rows[0].result; // saved: HRUM is not called
const res = await fetch(HRUM + path, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Partner-Api-Key': process.env.FLOAT_API_KEY },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`HRUM ${path} ${res.status}`); // not saved
await pool.query(
'INSERT INTO fortune_cache (user_id, cache_key, result) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[userId, key, JSON.stringify(json.data)]);
return json.data;
}
// usage (user = the signed-in member from your own session code) — the period is in the body, so it is in the key
const year = 2027; // 신년운세: 2027 and 2028 are two keys
const flow = await cachedCall(user.id, 'saju-flow', {
birthDate: '1990-05-15', birthTime: '14:30', calendar: 'solar', gender: 'male', timezone: 'Asia/Seoul',
range: { fromYear: year, toYear: year }, monthsOfYear: year,
});
const kstDate = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Seoul' }).format(new Date()); // YYYY-MM-DD
const today = await cachedCall(user.id, 'today', { // 오늘의 운세: one key per Korean date
year: 1990, month: 5, day: 15, hour: 14, minute: 30, gender: 'male', timezone: 'Asia/Seoul', date: kstDate,
});
const love = await cachedCall(user.id, 'lifetime', { // 타고난 연애 문단: same birth = same key for good
year: 1990, month: 5, day: 15, hour: 14, minute: 30, gender: 'male', timezone: 'Asia/Seoul', topic: 'love',
});
-- AI reading (/reading) can word the same question differently each time: save what was SHOWN, show it again CREATE TABLE IF NOT EXISTS fortune_answers ( user_id text NOT NULL, kind text NOT NULL, -- 'newyear' | 'love' | 'money' period text NOT NULL, -- '2027' for 신년운세, '' when there is no period answer text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id, kind, period) ); -- open the screen: SELECT answer FROM fortune_answers WHERE user_id = $1 AND kind = $2 AND period = $3 -- row found → show it, do not call /reading -- no row → call /reading once, show the answer, then INSERT it (ON CONFLICT DO NOTHING)
운세 상품 페이지(/fortune)를 추가해 주세요 — 사주보기·연애운·금전운·신년운세 탭. 이 책 뼈대의 파일을 재사용하고 새 외부 서비스는 넣지 않는다.
1. 서버 라우트 app/api/fortune/route.ts (7-2 범용 프록시가 아니라 B-6 리포트처럼 전용 라우트 — 프록시 allowlist 는 그대로):
- getSessionUser(req) 로 로그인 확인(비로그인 401). 입력은 { kind: "basic" | "love" | "money" | "newyear", year?, requestId } 만 받는다
- 생년월일은 서버가 DB birth_profiles 에서 읽는다(브라우저가 보낸 원국을 믿지 않는다). HRUM 본문도 서버가 만든다
- 계산형 호출은 lib/float-client.ts 의 callFloat 로 하고, 그 전에 lib/api-cache.ts 의 inputHash·readCache 로 저장본을 찾고 성공 응답만 saveCache 로 저장한다(route 이름 = "lifetime" · "saju-flow" · "today"). 저장본이 있으면 HRUM 을 부르지 않는다
· basic: 만세력은 이미 5-1 사주판이 저장한다. 여기선 POST /lifetime (topic 생략 = 모든 주제)
· love / money: POST /lifetime 에 topic "love" / "wealth"
· newyear: POST /saju-flow — 본문은 birthDate "YYYY-MM-DD"·birthTime "HH:mm"(모르면 빼기)·calendar·gender·timezone "Asia/Seoul"·range { fromYear: year, toYear: year }·monthsOfYear: year. 만세력과 필드 모양이 다르니 birth_profiles 값을 이 모양으로 바꿔 보낸다. year 는 지금 한국 연도~다음 해만 받는다
- 문장 풀이가 필요하면 POST /reading 을 한 번 부른다. question 에 주제와 해를 적는다(예: "2027년 한 해의 연애운을 봐 주세요"). 회원별 하루 횟수 제한(dedup.allow)과 dedup.once(`${user.id}:fortune:${requestId}`, …)를 B-6 처럼 건다
- /reading 답은 같은 질문이라도 문장이 달라질 수 있으니 api_cache 에 넣지 않는다. lib/schema-extra.ts 에 fortune_answers(user_id uuid REFERENCES users(id) ON DELETE CASCADE, kind, period, answer, created_at, PRIMARY KEY(user_id, kind, period)) 를 만들고, 보여 준 답을 저장했다가 다음에 그대로 보여 준다(신년운세는 period = 해, 나머지는 '')
2. 화면: 탭 4개. 숫자·그래프(운 흐름의 세운·월운 delta)는 저장된 계산 결과로 그리고, 문장은 components/AssistantMessage.tsx 로만 표시(plain text 금지)
3. 완료 기준: 같은 회원이 같은 탭을 다시 열면 HRUM 호출이 늘지 않는다(콘솔 사용량). 생년월일을 고치면 새로 한 번 부른다. 신년운세 2027 → 2028 로 바꾸면 2028 만 새로 부르고 2027 은 저장본으로 보인다. 비로그인 401
완료 기준: 같은 탭을 두 번 열어도 콘솔 사용량의 호출 수가 늘지 않고, 신년운세의 해를 바꾸면 바꾼 해만 한 번 늘어납니다.
앱에서 돈을 받으려면 여러분이 카드 결제 대행사(PG — 예: 토스페이먼츠, 포트원)와 직접 계약해 붙입니다. RIDM Apps 는 이것을 막지 않습니다. 돈을 받는 순간 판매자는 여러분이라 보통 사업자 등록·통신판매업 신고와 환불 책임이 따릅니다 — 계약 조건은 PG사마다 다르니 그 PG사 안내를 따르세요. PG 를 직접 붙이지 않고 HRUM 이 판매 대금을 정산해 보내 주는 지급대행으로도 받을 수 있습니다(9-4-1).
TOSS_SECRET_KEY 처럼 다른 이름을 씁니다 — 코드·GitHub 에는 넣지 않습니다. PG가 결제 결과를 서버로 알려주는 웹훅 주소를 만든다면, 요청이 올 때마다 PG의 서명을 검증하거나(검증 방법이 없으면 PG에 결제를 다시 조회) 진짜 PG가 보낸 것인지 확인하세요 — 안 하면 누구나 "결제 완료" 요청을 흉내 내 보낼 수 있습니다.PG 를 직접 붙이는 대신, HRUM 을 거쳐 정산되는 판매 대금은 HRUM 지급대행으로 받습니다. 판매 대금에서 판매 수수료와 카드 수수료를 뺀 금액을 HRUM 이 토스페이먼츠 지급대행을 통해 여러분이 등록한 정산 계좌로 보냅니다.
등록 순서, 상태별 할 일, 지급 실패·환불 처리는 호스팅 책 9-2-1에 자세히 있습니다.
이 구조는 임시방편이 아니라 "첫 수만 명까지의 최단 경로"입니다:
그래도 앱이 커지면 아래 신호에 맞춰 늘립니다:
| 신호 | 증축 내용 | 어디에 |
|---|---|---|
| 첫 방문 대기(잠듦)가 거슬림, 메모리·전송량 80% 알림 | 플랜 올리기 (베이직 512MB·10GB, 프로 1GB·50GB) — 콘솔 앱 카드의 플랜 변경, 코드 변경 없음 | 콘솔 |
| "엄마 사주도 저장하고 싶어요" | 원국 여러 개 (saju_charts 테이블) | 부록 E-0 |
| 리포트를 다시 열어보고 싶다는 요청 | 리포트 보관 테이블 | 부록 E-2 |
| DB 용량 80% 알림 | 필요 없는 행 정리 또는 플랜 올리기 — 한도를 넘긴 채 24시간이 지나면(150%를 넘으면 즉시) DB가 읽기 전용이 됩니다 | 부록 E-3 |
HRUM 크레딧 사용량이 확 늘면 콘솔에서 구독(보너스)으로 전환하고, 월 수백만 원 규모면 엔터프라이즈 문의로 볼륨 단가를 협의하세요.
AI용 분할본 · 지도 1단계(준비·결정서) · 이 페이지 전에 읽을 페이지: PART 0 사용법·AI 시작 지침, PART 4 결정서·마스터 스펙 · 코드 파일은 여기 없다 — 코드 자리의 줄을 따른다(폴더에 책 파일이 하나도 없는 처음이면 부록 A-2 의 curl 한 줄부터)