Files
fanuzu/app/firebase/seed-demo.py
T

481 lines
24 KiB
Python

#!/usr/bin/env python3
"""검수용 시연 데이터를 Firestore 에 넣는다.
발주사가 설치본으로 화면을 확인하실 수 있어야 하는데(별첨 2 2주차 「앱 설치·로그인·
탭 이동 확인」), 빈 프로젝트에서는 모든 화면이 빈 상태로 뜬다. 그래서 검수 프로젝트에만
시연 데이터를 넣는다. **운영 데이터가 아니다.**
인증은 firebase-tools 가 보관한 dev@sweech.io 액세스 토큰을 쓴다. 별도 서비스 계정
키를 만들지 않으려는 것이며, 토큰은 파일에 남기지 않는다.
python3 seed-demo.py [--uid <가입한 uid>]
uid 를 주면 그 사용자의 지갑·행성·소속을 함께 만든다. 주지 않으면 공용 컬렉션만 넣는다.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
PROJECT = "fanuzu-dev-b020d"
BASE = f"https://firestore.googleapis.com/v1/projects/{PROJECT}/databases/(default)/documents"
KST = timezone(timedelta(hours=9))
def access_token(email: str = "dev@sweech.io") -> str:
"""firebase-tools 토큰 저장소에서 액세스 토큰을 꺼낸다."""
path = os.path.expanduser("~/.config/configstore/firebase-tools.json")
store = json.load(open(path, encoding="utf-8"))
if store.get("user", {}).get("email") == email:
return store["tokens"]["access_token"]
for account in store.get("additionalAccounts", []):
if account.get("user", {}).get("email") == email:
return account["tokens"]["access_token"]
raise SystemExit(f"{email} 토큰을 찾지 못했습니다. firebase login:add 를 먼저 하십시오.")
class GeoPoint:
"""좌표. Firestore 는 지도(map)가 아니라 geoPointValue 로 받아야 하며, 서버가
`place.get('geo').latitude` 로 읽으므로 형이 다르면 반경 판정이 조용히 실패한다."""
def __init__(self, lat: float, lng: float) -> None:
self.lat = lat
self.lng = lng
def value(v):
"""파이썬 값 → Firestore REST 의 Value 표현."""
if isinstance(v, GeoPoint):
return {"geoPointValue": {"latitude": v.lat, "longitude": v.lng}}
if isinstance(v, bool):
return {"booleanValue": v}
if isinstance(v, int):
return {"integerValue": str(v)}
if isinstance(v, float):
return {"doubleValue": v}
if isinstance(v, datetime):
return {"timestampValue": v.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")}
if isinstance(v, list):
return {"arrayValue": {"values": [value(x) for x in v]}}
if isinstance(v, dict):
return {"mapValue": {"fields": {k: value(x) for k, x in v.items()}}}
return {"stringValue": str(v)}
def put(token: str, path: str, fields: dict) -> None:
"""문서 하나를 통째로 덮어쓴다. 여러 번 돌려도 결과가 같도록 PATCH 를 쓴다."""
url = f"{BASE}/{path}"
body = json.dumps({"fields": {k: value(v) for k, v in fields.items()}}).encode()
req = urllib.request.Request(url, data=body, method="PATCH")
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")
try:
urllib.request.urlopen(req, timeout=30).read()
print(f" ✔ {path}")
except urllib.error.HTTPError as e:
print(f" ✖ {path} — {e.code} {e.read().decode()[:200]}", file=sys.stderr)
raise
NOW = datetime.now(KST)
FANDOM = "fanuzu-demo"
# FPI 5 축 — 별첨 B 「FPI 5 축」 확정. 순서를 여기서 한 번 고정하고 아래 표가 따른다.
AXES = ["bpm", "health", "referral", "community", "contribution"]
# 갤럭시에 뜨는 행성들. `fpiScore` 는 5 축 단순 합산이다(별첨 B · 설계서 §4.1,
# 가중치 초기 1.0). 앱이 TOTAL FPI 를 축의 합으로 그리므로 여기서 어긋나면
# 지표 카드의 합과 TOTAL FPI 가 갈린다 — 발주사 검토회신서 4절의 지적 자리다.
#
# **축마다 1 위가 다르도록 짰다.** 「서로 다른 지표 화면에 동일한 Ranking 데이터가
# 반복된다」가 지적이었으므로, 시연 데이터에서 지표를 바꾸면 순서가 바뀌는 것이
# 눈으로 확인돼야 한다.
FANDOMS = [
# id, 이름, 축값[5], 멤버, 접속, 티커, 직전 축별 순위(순위 동향 ▲▼ 용)
(FANDOM, "FANUZU 데모 행성", [24_800, 31_200, 18_400, 27_600, 26_400],
12_840, 431, "오늘의 기여가 132% 늘었습니다", [3, 1, 4, 1, 2]),
("aurora", "오로라", [31_400, 12_800, 22_600, 15_200, 14_200],
8_310, 214, "", [1, 4, 1, 3, 2]),
("nova-crew", "노바 크루", [9_800, 24_600, 11_200, 19_400, 9_800],
6_002, 158, "", [3, 2, 4, 2, 3]),
("stellar", "스텔라", [7_200, 8_400, 25_800, 6_300, 3_600],
4_115, 96, "", [4, 5, 2, 5, 5]),
("lumen", "루멘", [5_100, 6_200, 4_300, 11_400, 6_700],
2_884, 61, "", [5, 5, 5, 4, 4]),
]
# 데모 행성의 멤버. 사용자 지표 랭킹(Figma 546:14668·14674)의 원천이다.
# 국가 랭킹(546:14713)은 이 표를 국가별로 합산해 만든다 — 따로 손으로 적으면
# 두 화면의 수치가 갈린다.
MEMBERS = [
# uid, 닉네임, 국가, 축값[5]
("demo-bunnies", "별빛수집가", "KR", [4_820, 2_110, 1_240, 980, 1_530]),
("demo-carat", "달빛", "KR", [3_960, 3_480, 620, 2_140, 870]),
("demo-moomin", "코스모스", "JP", [1_880, 4_020, 940, 3_050, 760]),
("demo-bora", "은하수", "JP", [1_210, 870, 2_260, 1_180, 2_980]),
("demo-dive", "네뷸라", "US", [940, 1_640, 1_720, 610, 1_120]),
("demo-nova", "혜성", "", [520, 780, 430, 340, 610]),
]
def axis_map(values):
"""축값 5개를 `fpiAxes` 맵으로."""
return dict(zip(AXES, values))
# (문서 ID, 제목, 보상 POP, 일일 한도, 진행 라벨, Quest Type)
#
# `type` 은 앱이 **어느 검증 함수를 부를지** 고르는 값이다(설계서 §2.8). 비워 두면 앱이
# 판정 경로를 몰라 참여 버튼을 잠그고 사유를 표시한다 — 임의로 붙이면 검증 없는 지급
# 경로가 생기므로 비워 두는 쪽이 맞다. 라운지·응원처럼 Community 유형은 목표 수치 정의가
# 회신 대기(Q-QC-08)라 실제로 비어 있는 상태다.
QUESTS = [
("daily-checkin", "출석 체크", 50, 1, "1/1", "attend"),
("daily-steps", "8,000보 걷기", 120, 1, "6,240 / 8,000", "walk"),
("daily-place", "홍대 팝업스토어 방문", 100, 1, "0 / 1", "location"),
("daily-post", "라운지에 글 남기기", 100, 1, "0 / 1", ""),
("daily-cheer", "다른 행성 응원하기", 60, 3, "1 / 3", ""),
]
# 행성 성장 단계 — 설계서 §2.3 `planetStages/{stageNo}`.
#
# 값의 출처는 **발주사 제공 수치표 v1.4 「Stage Summary」** 시트다(구간 기본 POP·sFPI).
# 설계서의 `requiredPop` 은 「해당 Level 진입 **누적** 기여 POP」이므로 시트의 구간값을
# 누계로 바꿔 적었다. 합계는 시트의 합계 행과 같다 — 50,000,000 POP · 7,500,000 sFPI.
#
# Stage 번호는 별첨 B **B-53** 을 따른다. 성운은 Stage0 으로 빼고 정식 행성이 1~6 이다
# (시트의 1~7 번호와 한 칸 어긋난다). 성운 → 정식 행성 전환 조건은 이 표가 아니라
# B-10(10 명 + 1,000 POP + 7 일)이라 Stage0 의 문턱은 0 이다.
#
# (stageNo, levelFrom, levelTo, 진입 누적 POP, 진입 누적 sFPI)
PLANET_STAGES = [
(0, None, None, 0, 0), # 태동 — 성운(Pre-Planet)
(1, 1, 20, 0, 0), # 시작
(2, 21, 40, 100_000, 15_000), # 성장
(3, 41, 60, 500_000, 75_000), # 함께
(4, 61, 80, 2_000_000, 300_000), # 동행
(5, 81, 99, 10_000_000, 1_500_000), # 빛
(6, 100, 100, 50_000_000, 7_500_000), # 영원
]
# AI Daily Briefing 표본 — 정책 원장 B-47 · B-75.
# 문안은 발주사 Agent 엔진이 만들고 우리는 수신·승인·노출만 한다(B-48). 표본도
# 그 규격대로 5 요소(출처명·게시시각·원본 URL·원문보기·AI 요약)를 전건 채운다 —
# 하나라도 비면 앱이 그 줄을 버리므로 데모에서 카드가 사라진다.
BRIEFINGS = [
("News", 1, "데뷔 10주년 단독 콘서트 서울 이틀 확정",
"연합뉴스", "https://example.com/news/anniversary-concert",
"서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다."),
("News", 2, "신곡, 주요 음원 차트 상위권 진입",
"스포티파이 차트", "https://example.com/news/chart-entry",
"발매 당일 글로벌 차트 상위권에 올랐습니다."),
("SNS", 1, "공식 계정, 팬미팅 비하인드 공개 예고",
"공식 X 계정", "https://example.com/sns/behind-teaser",
"비하인드 영상 공개 일정이 예고되었습니다."),
]
QUESTIONS = [
("데뷔 초기 무대 영상 어디서 볼 수 있나요", "공식 채널에 남아 있는 영상이 있는지 찾고 있습니다.", "별빛수집가", 500, 4),
("응원봉 배터리 교체 방법 아시는 분", "공식 안내가 없어서 직접 해보신 분 계신지 여쭙니다.", "달빛", 300, 7),
("팬미팅 대기 순서는 어떻게 정해지나요", "선착순인지 추첨인지 정확히 아는 분 계실까요.", "코스모스", 200, 2),
]
POSTS = [
("오늘 방송 보신 분", "마지막 곡 편곡이 완전히 달라졌더라고요. 다시 듣고 왔습니다.", "별빛수집가", 42, 8),
("행성 3단계 축하합니다", "다들 고생 많으셨어요. 다음 목표도 금방일 것 같습니다.", "달빛", 128, 23),
("굿즈 실물 후기", "생각보다 크기가 커서 놀랐습니다. 마감도 깔끔해요.", "코스모스", 76, 14),
("걷기 퀘스트 같이 하실 분", "혼자 하니 자꾸 까먹네요. 인증 스레드 만들어요.", "은하수", 55, 31),
]
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--uid", help="가입한 사용자 uid — 지갑·소속·행성을 함께 만든다")
args = ap.parse_args()
token = access_token()
# 별첨 B v1.4 — STATION 은 1차 비노출. 문서를 미리 만들어 두어야
# 나중에 "앱 재배포 없이" 값 하나만 뒤집을 수 있다.
print("▸ 기능 스위치(config/features)")
put(token, "config/features", {"stationEnabled": False})
# 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값).
# 서버 `DEFAULT_QUEST_CATALOG`(functions/src/quest/ugc.ts)와 같은 값이다. 운영 변경은 fn-admin-write.
print("▸ 퀘스트 분류 마스터(config/questCatalog)")
field = lambda f: [{"field": f, "required": True}] if f else []
put(token, "config/questCatalog", {
"categories": [
{"key": k, "label": label, "order": i + 1, "visible": True}
for i, (k, label) in enumerate([
("health", "건강"), ("planet", "행성 활동"),
("community", "커뮤니티"), ("location", "특정 위치"),
])
],
"activities": [
{"key": k, "categoryKey": c, "order": o, "visible": True, "ugcAllowed": u,
"inputSchema": field(f), **({"minReactions": 1} if k == "community" else {})}
for k, c, o, u, f in [
("walk", "health", 1, False, "goal"), ("shake", "health", 2, False, "goal"),
("streaming", "planet", 1, False, "link"), ("youtube", "planet", 2, False, "link"),
("vote", "planet", 3, True, "link"), ("sns", "planet", 4, True, "link"),
("community", "community", 1, True, None),
("location", "location", 1, True, "place"),
# 위치 무관 QR / NFC — 검증 명세 확정 전 사용자 생성 비노출(C-03).
("qr", "location", 2, False, "code"), ("nfc", "location", 3, False, "tag"),
]
],
})
print("▸ 행성(fandoms)")
# 축별 순위는 서버(fn-fpi-aggregate)가 매기는 값이다. 시드도 같은 방식으로
# 계산해 넣는다 — 손으로 적으면 값과 순위가 갈린다.
ranks = {
axis: {
fid: i + 1
for i, (fid, *_) in enumerate(
sorted(FANDOMS, key=lambda f: -f[2][a])
)
}
for a, axis in enumerate(AXES)
}
# 생성 팔레트 키(C-50 · 앱 planetColors) — 행성마다 돌려 쓴다.
colors = ["blue", "purple", "gold", "green", "red", "orange", "white", "black"]
for i, (fid, name, axes, members, online, ticker, prev) in enumerate(FANDOMS):
color = colors[i % len(colors)]
# 팬덤당 행성 1건 — 문서 ID 고정 `planet`(fn-planet-create 와 같은 자리 · 설계서 §2.4).
put(token, f"fandoms/{fid}/planets/planet", {
"ownerUid": "seed", "fandomId": fid, "planetType": "shape1", "colorKey": color,
"createdAt": NOW,
})
# 이름 중복 판정 문서(fn-nebula-create) — 시드 이름으로 성운을 또 만들지 못하게.
put(token, f"fandomNames/{name.lower()}", {"fandomId": fid, "createdAt": NOW})
put(token, f"fandoms/{fid}", {
"planetType": "shape1", "colorKey": color,
"name": name,
# TOTAL FPI = 5 축 단순 합산.
"fpiScore": sum(axes),
"fpiAxes": axis_map(axes),
"fpiRanks": {axis: ranks[axis][fid] for axis in AXES},
"fpiRanksPrev": axis_map(prev),
# 별첨 B 「FPI 랭킹」 모집단 — 정식 행성(Stage 1 이상) 중
# 최근 7 일 유효 활동 존재 행성.
"stage": 1, "rankingEligible": True,
"memberCount": members, "onlineCount": online,
"liveTicker": ticker, "countryCode": "KR",
"fpiUpdatedAt": NOW,
})
print("▸ Daily FPI Snapshot — 일 1 회(별첨 B). 지표 카드의 「최근 동향」 기준선")
# 앱의 최근 동향 = 현재 `fpiAxes` − 오늘 스냅샷. 스냅샷을 조금 낮게 두어
# 상승·하락이 섞여 보이게 한다.
day_key = NOW.strftime("%Y-%m-%d")
for fid, _name, axes, *_rest in FANDOMS:
base = [v - d for v, d in zip(axes, [1_700, -1_150, 500, 1_800, 1_500])]
put(token, f"fandoms/{fid}/fpiSnapshots/{day_key}", {
"createdAt": NOW.replace(hour=0, minute=0, second=0, microsecond=0),
"fpiAxes": axis_map(base), "fpiScore": sum(base),
})
print("▸ 데모 행성 멤버 — 사용자 지표 랭킹의 원천")
for muid, nickname, country, axes in MEMBERS:
put(token, f"fandoms/{FANDOM}/members/{muid}", {
"uid": muid, "fandomId": FANDOM, "nickname": nickname,
# 국가 랭킹 집계가 읽는 비정규화 필드. 빈 값은 ZZ(미지정) 버킷이다.
"countryCode": country,
"fpiAxes": axis_map(axes),
"contributedPop": axes[4],
"joinedAt": NOW - timedelta(days=20),
})
print("▸ 국가별 지표 랭킹 — 축마다 문서 1건(설계서 §10)")
for a, axis in enumerate(AXES):
totals = {}
for _muid, _nick, country, axes in MEMBERS:
totals[country or "ZZ"] = totals.get(country or "ZZ", 0) + axes[a]
rows = sorted(totals.items(), key=lambda kv: -kv[1])
put(token, f"rankings/country-{axis}", {
"axis": axis, "updatedAt": NOW,
# 표시명은 넣지 않는다 — 앱의 `countryName` 한 곳이 코드를 이름으로
# 옮긴다. 여기에도 이름을 적으면 표기가 두 벌이 된다.
"rows": [
{
"code": code, "value": value, "rank": i + 1,
# 시드에는 직전 집계가 없으므로 동향은 0 이다.
"change": 0,
}
for i, (code, value) in enumerate(rows)
],
})
print("▸ 퀘스트")
for qid, title, reward, limit, label, qtype in QUESTS:
fields = {
"title": title, "rewardPop": reward, "dailyLimit": limit,
"progressLabel": label, "active": True,
}
if qtype:
fields["type"] = qtype
if qid == "daily-place":
# 위치 퀘스트는 장소 문서를 가리켜야 서버가 반경을 판정할 수 있다.
fields["placeId"] = "hongdae-popup"
put(token, f"quests/{qid}", fields)
print("▸ 행성 성장 단계 — 발주사 수치표 v1.4 「Stage Summary」")
for stage_no, level_from, level_to, pop, sfpi in PLANET_STAGES:
fields = {"stageNo": stage_no, "requiredPop": pop, "requiredSfpi": sfpi}
if level_from:
fields["levelFrom"] = level_from
fields["levelTo"] = level_to
put(token, f"planetStages/{stage_no}", fields)
print("▸ 위치 퀘스트 장소 — 반경은 Admin 운영값(별첨 B B-27 기본 100m)")
put(token, "places/hongdae-popup", {
"name": "홍대 팝업스토어",
"geo": GeoPoint(37.5563, 126.9236),
"radiusM": 100,
"rewardPop": 100,
"verifyMethods": ["gps", "qr"],
})
print("▸ 브리핑")
# 앱은 `status == published` 이고 `dayKey` 가 당일(KST)인 것만 읽는다(B-47).
# 시드가 그 두 필드를 채우지 않으면 데모에서 브리핑 블록이 숨는다.
day_key = NOW.strftime("%Y-%m-%d")
for section, rank, headline, source_name, url, summary in BRIEFINGS:
put(token, f"briefings/{FANDOM}_{day_key}_{section}_{rank}", {
"fandomId": FANDOM, "dayKey": day_key, "section": section,
"rank": rank, "headline": headline, "status": "published",
"sourceName": source_name, "sourceAt": NOW - timedelta(hours=rank),
"sourceUrl": url, "articleUrl": url, "aiSummary": summary,
"aiGenerated": True,
"createdAt": NOW, "publishedAt": NOW,
})
print("▸ 캠페인")
put(token, "campaigns/demo-campaign", {
"fandomId": FANDOM, "status": "funding", "campaignType": "support",
"title": "데뷔 5주년 지하철 광고", "authorName": "별빛수집가",
"description": "역사 스크린에 5주년 축하 영상을 올립니다. 모인 POP 만큼 노출 일수가 늘어납니다.",
"goalPop": 500_000, "raisedPop": 341_200, "donorCount": 284,
"commentCount": 47, "likeCount": 512, "achievedRate": 68,
"createdAt": NOW - timedelta(days=6), "endsAt": NOW + timedelta(days=9),
})
print("▸ 질문(INSIDE)")
for i, (title, body, author, bounty, answers) in enumerate(QUESTIONS):
put(token, f"questions/q-{i}", {
"fandomId": FANDOM, "status": "open", "category": "general",
"title": title, "body": body, "authorName": author,
"bountyPop": bounty, "answerCount": answers,
"createdAt": NOW - timedelta(hours=2 + i * 5),
})
print("▸ 라운지 글")
for i, (title, body, author, likes, comments) in enumerate(POSTS):
put(token, f"posts/post-{i}", {
# 설계서 §2.6 — visible | hidden | deleted. 규칙이 새 글을 visible 로만 만든다.
"fandomId": FANDOM, "visibility": "visible",
"authorUid": f"demo-author-{i}",
"title": title, "body": body, "authorName": author,
"likeCount": likes, "commentCount": comments,
"createdAt": NOW - timedelta(hours=1 + i * 4),
})
print("▸ STATION 큐레이션 링크")
for i, (title, url, category, desc, verified) in enumerate([
("FANUZU 공식 사이트", "https://fanuzu.co.kr", "official", "공지와 일정이 가장 먼저 올라옵니다.", True),
("공식 스토어", "https://fanuzu.co.kr", "shop", "앨범과 응원봉을 파는 공식 판매처입니다.", True),
("팬미팅 예매", "https://fanuzu.co.kr", "ticket", "예매 일정과 대기 순서를 확인하십시오.", False),
("공식 미디어 채널", "https://fanuzu.co.kr", "media", "무대 영상과 비하인드가 올라옵니다.", True),
]):
put(token, f"stationLinks/link-{i}", {
"fandomId": FANDOM, "rank": i + 1, "title": title, "url": url,
"category": category, "description": desc, "verified": verified,
})
print("▸ 갤럭시 이벤트")
put(token, "galaxyEvents/evt-latest", {
"kind": "itemUsed", "message": "오로라가 성장 촉진 아이템을 사용했습니다",
"createdAt": NOW - timedelta(minutes=18),
})
print("▸ 이클립스 운영값 — Admin 「관리 마스터_이클립스 관리」 항목")
# **`enabled` 를 끈 채 심는다.** 켜 두면 `fn-eclipse-trigger` 가 발동 시각에
# 이클립스를 하나 더 만들어, 아래에서 손으로 심는 시연용 이클립스와 둘이 동시에
# 활성이 된다. 발동 스케줄을 확인할 때 이 값을 켠다.
put(token, "config/eclipse", {
"enabled": False,
# 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[].
"schedule": [{"hhmm": "07:50"}, {"hhmm": "17:50"}],
"erosionPercent": 0.25, # C-23 실패 감소율(%)
"warnSeconds": 180, # C-22 발생 3분 전 푸시
"windowSec": 60, # C-22 공통 방어 창(초)
"targetRate": 0.05, # C-25 7일 평균 DAU 대비 목표율
"dauWindowDays": 7, # C-21 DAU 평균 기간(일)
"qualifyShakes": 60, # C-25 개인 인정 유효 Shake
"perCallMaxShakes": 50, "perUserMaxDefense": 500, "minIntervalMs": 300,
})
print("▸ 활성 이클립스 — 경보 배너·침식 HUD·ZAP 방어의 대상")
# 샤드는 비워 둔다. `fn-eclipse-defend` 가 채우고 `fn-eclipse-aggregate` 가
# 합산하는 것이 확인 항목이므로, 여기서 채워 두면 방어가 반영되는지를 못 본다.
# 시연 편의로 창을 30분 열어 둔다(운영 기본은 60초). 목표 2명 · 인정 60회(C-25·C-26).
put(token, f"fandoms/{FANDOM}/eclipseRounds/ecl-demo", {
"status": "active",
"fandomName": "BTS",
"target": 2, "qualifyShakes": 60, "windowSec": 1800,
"defense": 0, "defenseCount": 0,
"erosionRate": 100, "defended": False,
"startAt": NOW - timedelta(minutes=6),
"endsAt": NOW + timedelta(minutes=24),
})
print("▸ 라이브 소셜 프루프 — 홈 라이브 띠·갤럭시 섬광 배너")
# 별첨 B 「Global Ticker」 — 이 컬렉션은 이클립스 전용이 아니다. `kind` 로 갈린다.
for i, (nickname, defense, minutes) in enumerate([
("달빛", 30, 1), ("오로라", 10, 4), ("버니즈", 50, 9),
]):
at = NOW - timedelta(minutes=minutes)
put(token, f"fandoms/{FANDOM}/liveEvents/live-{i}", {
"kind": "eclipse_defense", "eclipseId": "ecl-demo",
"uid": f"seed-{i}", "nickname": nickname, "defense": defense,
"priority": 20, "linkTo": f"/eclipse/{FANDOM}/ecl-demo",
"createdAt": at, "expiresAt": at + timedelta(hours=1),
})
if args.uid:
uid = args.uid
print(f"▸ 사용자 {uid}")
# 대표 팬덤(C-15) — primaryFandomId 가 정본, fandomId 는 옛 독자용 같은 값.
put(token, f"users/{uid}", {
"primaryFandomId": FANDOM, "fandomId": FANDOM, "status": "active", "createdAt": NOW,
})
put(token, f"users/{uid}/wallet/main", {"activityBalance": 12_400, "supportBalance": 3_000})
# GALAXY 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
put(token, f"users/{uid}/memberships/{FANDOM}", {
"fandomId": FANDOM, "joinedAt": NOW - timedelta(days=12),
})
put(token, f"fandoms/{FANDOM}/members/{uid}", {
"uid": uid, "fandomId": FANDOM, "nickname": "나",
"countryCode": "KR",
# 지표마다 내 순위가 달라야 한다 — REFERRAL·CONTRIBUTION 은 1 위,
# BPM 은 3 위가 되도록 잡았다.
"fpiAxes": axis_map([2_740, 1_290, 3_180, 1_620, 4_210]),
# 지표 카드의 「내 기여 상위 n%」. 서버 산출값이며 멤버 순위에서 나온다.
"topPercentByAxis": axis_map([43, 72, 15, 43, 15]),
"contributedPop": 4_210,
"joinedAt": NOW - timedelta(days=12),
})
for qid, *_ in QUESTS[:2]:
put(token, f"users/{uid}/questProgress/{qid}", {"count": 1, "dayKey": NOW.strftime("%Y-%m-%d")})
print("\n완료했습니다.")
if __name__ == "__main__":
main()