481 lines
24 KiB
Python
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()
|