오피스 학습층 배선 (B4)
개요
docs.crowny.org/office(워드·시트·슬라이드) 사용을 crowny-game의 "쓸수록 똑똑해지는" 학습래칫
(서비스학습.한선, learn.js)에 집계해 CrownyAI 런처 카드에 학습배지(지능 N·활용 M)가 뜨도록
배선했다. 오피스 엔진 자체(server-docs.js, docs.crowny.org:4100)는 crowny-game과 별도 서버라
기존 wrap() Express 미들웨어 체인을 못 타므로, 클라이언트 스크립트가 학습 API를 직접 호출하는
새 공개 엔드포인트를 추가했다.
배지매칭 분석 결론 (필수 확정 사항)
카드 3장(워드/시트/슬라이드)이 전부 https://docs.crowny.org/office?mode=doc|sheet|slide —
경로는 /office 동일, 쿼리(mode=)만 다르다.
native/crowny-browser.m 두 지점의 lastPathComponent 사용은 서로 다른 대상에 적용된다:
cbApplyManifestServices(536~549줄):NSString *route = svc[@"route"]; ... [route lastPathComponent]
/로만 분리, ? 무지각). 대상은 manifest의
route 필드(우리가 직접 채운 값, 예: /office)라 애초에 쿼리가 없어 문제가 없다.
drawRect실제 배지 매칭(2620~2634줄):NSString *badgeKey = [[NSURL URLWithString:urlVal] lastPathComponent];
urlVal은 앱목록.psv의 실제 카드 URL(.../office?mode=doc 등)이고,
NSURL이 path와 query를 분리 파싱하므로 lastPathComponent가 쿼리를 무시하고 "office"만
반환한다. 이쪽이 진짜 매칭 로직(값은 강건, 쿼리에 안 깨짐).→ 두 지점 모두 최종적으로 키 "office"로 수렴하려면 manifest route가 /office
단일 세그먼트여야 한다(워드/시트/슬라이드를 별도 route:'/word' 등으로 쪼개면 카드 3장의
NSURL 매칭 키가 전부 "office"라서 절대 못 맞는다). 그래서 office 서비스는 단일 항목으로
등록했다(learn.js SERVICES 주석, learn.한선 매니페스트라우트마지막세그먼트 자기테스트로 회귀 고정).
변경 파일
/Users/ef/crowny-game/src/learn.jsSERVICES에 office 1항목 추가(route:'/office', api:'/api/learn/record').POST /learn/record — 외부(별도서버) 클라이언트가 직접 기록하는 유일한 공개 게이트.svc==='office'로만 스코프 고정(다른 7개는 여전히 wrap()만 신뢰 — 외부에서 copy/ad 등으로
위장한 통계 주입을 차단).
/Users/ef/crowny-game/한선씨/learn.한선 (+.toau 재컴파일)학습기록허용판정(svc) — office만 1, 나머지 -1.매니페스트라우트마지막세그먼트(경로) — NSURL lastPathComponent 등가 모델, 워드/시트/슬라이드CROWNY_STRICT=1 경고 0)./Users/ef/CrownyDoc/한선씨/정적/office-learn.js (신규)#btn-save 클릭) / 내보내기(window.Crowny내보내기.{PDF,HTML,Markdown,CSV,CDFv2,SVG,PNG}window.Crowny가져오기.{열기,파일선택열기} 래핑, 내부 파싱_*는 이중집계
방지로 제외) 이벤트를 https://game.crowny.org/api/learn/record로 POST.
/Users/ef/CrownyDoc/한선씨/정적/office-learn.한선 (신규, 동반)JSON생성()은 자체 배열기반 맵([키,값,..])만 받고 네이티브 맵생성()/맵넣어()파일-내보내기.한선의 CDFv2생성()이 맵생성()+JSON직렬화()를 쓰는데
JSON직렬화라는 함수 자체가 어느 라이브러리에도 정의돼 있지 않음 — 컴파일된 적 없는
장식 코드로 추정, 이번에 실제로 컴파일 시도해서 적발).라이브 검증 (같은 턴 실측)
launchctl kickstart -k gui/501/org.crowny.game
GET https://game.crowny.org/api/ai/manifest
→ services 9개(기존 8 + office), copy/sales 등 기존 학습통계 무회귀(copy records=2 유지)
→ office 항목: route=/office, api=/api/learn/record, learn_stats={records:0,...}
POST https://game.crowny.org/api/learn/record {svc:office,action:save,mode:doc}
→ {"ok":1,"id":1,"hits":0}
POST 같은 바디 재전송(중복 키 upsert 확인)
→ {"ok":1,"id":1,"hits":1} (신규 레코드 아님 — hits만 증가)
POST {svc:office,action:export,mode:sheet,format:CSV}
→ {"ok":1,"id":2,"hits":0}
POST {svc:copy,...} (스코프 위반 시도)
→ {"ok":false,"error":"external record scoped to office only"} ← 차단 확인
GET https://game.crowny.org/api/learn/stats?svc=office
→ {"ok":true,"svc":"office","records":2,"avg_score":0,"total_hits":1}
data/학습/office.psv 실제 저장 확인(2줄, id/ts/입력키/출력/score/hits 6필드 정상)
POST /api/learn/feedback {svc:office,id:1,delta:1} → {"ok":1,"score":1} (재확인)
배선 미완 (라이브 무접촉 범위 — wiring 스펙만)
index.html(/Users/ef/CrownyDoc/한선씨/정적/index.html)에 <script> 태그 1줄 추가 필요.
이 파일은 허용 편집 범위 밖이라 직접 수정하지 않았다.
파일: /Users/ef/CrownyDoc/한선씨/정적/index.html
앵커: 171번째 줄 `<script src='/static/최근문서.js'></script>` 바로 다음(스크립트 목록 맨 끝, </body> 직전)
삽입: <script src='/static/office-learn.js'></script>
office-learn.js는 이미 /Users/ef/CrownyDoc/한선씨/정적/에 있으므로 static 라우트
(staticBase = 한선씨/정적, server-docs.js:4998)를 통해 배포 즉시 /static/office-learn.js로
서빙된다(server-docs.js 자체는 무수정, 재기동만으로 반영).
native/crowny-browser.m은 읽기 전용 지시라 실측만 하고 수정하지 않았음 — 위 배지매칭
분석대로 현재 코드는 이미 정상 동작(문제 없음, 이슈 아님).
잔여
- index.html 스크립트 태그 wiring 적용은 오피스 담당 세션 승인 후 진행.
- office-learn.js는 아직 프로덕션에 배선 안 됐으므로 실사용 이벤트는 0건(위 검증은 curl 직접
추가: CORS 이중헤더 근본수리 — 동일오리진 프록시 전환 (같은 날 후속)
문제
위 wiring 이후(index.html에 <script src='/static/office-learn.js'> 실제 삽입됨,
/office index.html 185번째 줄에서 확인) 실사용 시 office-learn.js의
fetch('https://game.crowny.org/api/learn/record', ...)가 브라우저 CORS에서 거부됐다.
원인 = 이중 Access-Control-Allow-Origin 헤더 — 크라우니게이트웨이의 도메인 화이트리스트
CORS 처리 + crowny-game/server.js:17의 전역 '*' CORS가 겹쳐 응답에 헤더가 두 번 실리고,
Fetch 스펙상 이는 무효(브라우저가 통째로 거부) 처리된다. 게이트웨이·game 서버는 손대지
말라는 지시(라이브 인프라 리스크 최소화)라, CORS 자체가 발동하지 않도록 동일오리진으로
바꾸는 프록시 방식을 택했다.
변경 파일 (이번 라운드, 허용범위 3개만)
/Users/ef/CrownyDoc/server-docs.js
proxyToProject() 뒤에 proxyToLearn(req, res) 함수 신규 추가 (LEARN_PORT=9775 고정 상수,
호스트/포트는 요청값과 무관 — SSRF 차단). 5초 setTimeout, 실패 시 502 JSON, 리다이렉트
미추종(node http.request가 3xx를 자동추종하지 않음 — 그대로 클라이언트에 전달만 함).
- 라우트: 한선씨 호환 API (/api/*) 캐치올(옛 4992번째 줄, handleApi 호출) 바로 앞에
if (req.url.startsWith('/api/learn/')) return proxyToLearn(req, res); 삽입 — 기존
/api/docs/·/api/proj/·/api/board/*·/api/md/*·/api/cdf/*·/api/rpn/* 라우트는
전부 이 지점보다 앞에서 이미 매치·리턴되므로 순서상 영향 없음(사전 grep으로 전수 확인).
/Users/ef/CrownyDoc/한선씨/정적/office-learn.js
LEARN_ENDPOINT를 'https://game.crowny.org/api/learn/record' → '/api/learn/record'
(상대경로, 동일오리진)로 변경. 그 외 wireSave/wireExport/wireDocxXlsxExport/
wireImport 로직 전부 불변.
/Users/ef/CrownyDoc/한선씨/정적/office-learn.한선
검증 (같은 턴 실측)
node --check server-docs.js → OK
node --check 한선씨/정적/office-learn.js → OK
CROWNY_STRICT=1 hanseonc_high office-learn.한선 → 경고 0, 3952 큐브
crownyc run office-learn.toau → "office-learn.한선 자기테스트: 15/15"
launchctl kickstart -k gui/501/org.crowny.docs → sleep 2
GET https://docs.crowny.org/office → 200
POST https://docs.crowny.org/api/learn/record
{"svc":"office","action":"proxy_smoke","mode":"doc"}
→ {"ok":1,"id":13,"hits":0}
동일 바디 재전송(upsert 확인) → {"ok":1,"id":13,"hits":1} ← 같은 id, hits만 증가(바이트 왕복 정합)
POST {"svc":"notoffice","action":"x"} → 400 {"ok":false,"error":"external record scoped to office only"}
(game 쪽 스코프게이트가 프록시 경유로도 그대로 관철됨)
GET https://docs.crowny.org/api/learn/stats?svc=office
→ {"ok":true,"svc":"office","records":13,"total_hits":6}
무회귀:
GET /docs/ → 200
GET /office?mode=sheet → 200
GET /static/export-docx.js → 200
GET /static/export-xlsx.js → 200
GET /static/mindmap.js → 200
GET /static/plan-tab.js → 200
GET /static/office-learn.js → 200
서빙본=로컬본 diff 없음 확인(office-learn.js), LEARN_ENDPOINT 두 곳 모두 '/api/learn/record'
잔여 리스크
proxyToLearn이req.headers를 통째로 포워딩한다(host만 재작성). game 서버가 임의
/api/learn/*는 JSON
바디만 보므로 문제 없음.
- game.crowny.org(:9775)가 다운되면
/api/learn/*프록시는 5초 뒤 502를 반환한다 —
.catch(function(){})로 실패를 무시하므로 오피스 사용 자체는
영향 없음(집계만 유실).
- 게이트웨이/game server.js의 이중 CORS 헤더 자체는 근본 미수정 상태(이번 작업 범위
game.crowny.org/api/learn/record를 직접 호출하면
여전히 CORS 거부될 수 있음. 필요 시 별도 세션에서 게이트웨이 화이트리스트 또는
server.js:17의 전역 CORS 헤더 정리 필요.