← 목록
기타 2026-07-13 11KB 읽기 13분

오피스 학습층 배선 (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]
NSString 기반(NSPathUtilities 카테고리, /로만 분리, ? 무지각). 대상은 manifest의 route 필드(우리가 직접 채운 값, 예: /office)라 애초에 쿼리가 없어 문제가 없다.
  • drawRect 실제 배지 매칭(2620~2634줄): NSString *badgeKey = [[NSURL URLWithString:urlVal] lastPathComponent];
NSURL 기반. 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.js
  • SERVICESoffice 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 등가 모델, 워드/시트/슬라이드
  • 3카드가 전부 "office"로 귀결됨을 자기테스트로 고정.
  • 자기테스트 21/21 통과(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.
  • 출력 대체/캐시서빙 전혀 없음 — 순수 활용집계 fire-and-forget(실패 무시).
  • /Users/ef/CrownyDoc/한선씨/정적/office-learn.한선 (신규, 동반)
  • 기록 바디생성·래핑대상 판정(내보내기 7형식/가져오기 2진입점) 로직 정본화. 자기테스트 15/15.
  • JSON.한선의 JSON생성()은 자체 배열기반 맵([키,값,..])만 받고 네이티브 맵생성()/맵넣어()
  • 해시맵은 못 받는 구조라(가드레일 "맵" 함정), 필드 3~4개뿐이라 수동 문자열 조립으로 우회 (참고 — 파일-내보내기.한선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.jsfetch('https://game.crowny.org/api/learn/record', ...)가 브라우저 CORS에서 거부됐다. 원인 = 이중 Access-Control-Allow-Origin 헤더 — 크라우니게이트웨이의 도메인 화이트리스트 CORS 처리 + crowny-game/server.js:17의 전역 '*' CORS가 겹쳐 응답에 헤더가 두 번 실리고, Fetch 스펙상 이는 무효(브라우저가 통째로 거부) 처리된다. 게이트웨이·game 서버는 손대지 말라는 지시(라이브 인프라 리스크 최소화)라, CORS 자체가 발동하지 않도록 동일오리진으로 바꾸는 프록시 방식을 택했다.

    변경 파일 (이번 라운드, 허용범위 3개만)

    1. /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으로 전수 확인).
    1. /Users/ef/CrownyDoc/한선씨/정적/office-learn.js
    - LEARN_ENDPOINT'https://game.crowny.org/api/learn/record''/api/learn/record' (상대경로, 동일오리진)로 변경. 그 외 wireSave/wireExport/wireDocxXlsxExport/ wireImport 로직 전부 불변.
    1. /Users/ef/CrownyDoc/한선씨/정적/office-learn.한선
    - 헤더 주석에 이번 CORS 수리 배경 추가(로직은 엔드포인트 주소와 무관해 불변 — 기록 바디생성/래핑대상판정 함수 자체는 무수정). 재컴파일 확인.

    검증 (같은 턴 실측)

    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'
    

    잔여 리스크

    • proxyToLearnreq.headers를 통째로 포워딩한다(host만 재작성). game 서버가 임의
    요청헤더에 민감하게 반응하는 라우트가 생기면 영향 가능 — 현재 /api/learn/*는 JSON 바디만 보므로 문제 없음.
    • game.crowny.org(:9775)가 다운되면 /api/learn/* 프록시는 5초 뒤 502를 반환한다 —
    office-learn.js는 .catch(function(){})로 실패를 무시하므로 오피스 사용 자체는 영향 없음(집계만 유실).
    • 게이트웨이/game server.js의 이중 CORS 헤더 자체는 근본 미수정 상태(이번 작업 범위
    밖) — 다른 크로스오리진 소비자가 game.crowny.org/api/learn/record를 직접 호출하면 여전히 CORS 거부될 수 있음. 필요 시 별도 세션에서 게이트웨이 화이트리스트 또는 server.js:17의 전역 CORS 헤더 정리 필요.