diff --git a/.github/workflows/backend-migration-check.yaml b/.github/workflows/backend-migration-check.yaml index 4fcef5c18..246632404 100644 --- a/.github/workflows/backend-migration-check.yaml +++ b/.github/workflows/backend-migration-check.yaml @@ -27,6 +27,9 @@ on: jobs: verify: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write services: mysql: # 로컬·운영과 같은 8.0. 값은 CI 안에서만 사는 더미다 (운영 자격증명 아님). @@ -87,6 +90,46 @@ jobs: done echo "❌ 120초 안에 안 떴다"; cat boot.log; kill $pid; exit 1 + # 방금 빈 DB 에 Flyway 가 만든 스키마 = 이 커밋의 진짜 DB 구조. 그대로 읽어 Prober(ERD 지도)로 보낸다. + # PR 이면 beta 대비 바뀐 테이블·컬럼과 위험한 변경(컬럼 삭제 · default 없는 NOT NULL …)을 코멘트로 남긴다. + # 포크 PR 은 시크릿이 없어 건너뛴다. 실패해도 검증 게이트는 막지 않는다 + - name: DB 구조를 Prober 로 (ERD) + id: prober + if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + continue-on-error: true + env: + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PROBER_API: https://api.prober.im + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc + SOURCE: studyclub-engineering:backend + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + mysql --default-character-set=utf8mb4 -h 127.0.0.1 -P 3306 -u studyclub -pci-studyclub studyclub -N -B -r < scripts/prober/erd.sql > erd.json + branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + jq -n --slurpfile erd erd.json --arg source "$SOURCE" --arg repo "$GITHUB_REPOSITORY" --arg branch "$branch" \ + --arg sha "${HEAD_SHA:-$GITHUB_SHA}" --arg pr "$PR" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" \ + '{source:$source, repo:$repo, branch:$branch, baseBranch:"beta", sha:$sha, method:"introspect", erd:$erd[0]} + + (if $pr != "" then {pr:($pr|tonumber), prUrl:$url} else {} end)' > body.json + code=$(curl -s -o res.json -w '%{http_code}' -X POST "$PROBER_API/teams/$PROBER_PROJECT/schema/snapshots" \ + -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data-binary @body.json) + if [ "${code:0:1}" != 2 ]; then echo "::warning::Prober 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi + jq -r .comment res.json > comment.md + cat comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: ERD 변경 PR 코멘트 (있으면 고치고 없으면 만든다) + if: steps.prober.outcome == 'success' && github.event_name == 'pull_request' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + SOURCE: studyclub-engineering:backend + run: | + id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | (startswith(\"\") or startswith(\"\")) and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) + if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md >/dev/null + else gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md >/dev/null; fi + - name: 적용된 스키마 출력 (실패 원인 파악용) if: always() run: | diff --git a/.github/workflows/prober-map.yml b/.github/workflows/prober-map.yml new file mode 100644 index 000000000..8bfad42db --- /dev/null +++ b/.github/workflows/prober-map.yml @@ -0,0 +1,135 @@ +# 시스템 지도를 Prober 로 보낸다 — DB 구조 · 위층(문서 · 개념 · 흐름) · PR. prober:connect (Strata 연결을 다시 깐 것) +# 고친 파일에 따라 job 을 고른다(changes). PR 열림 · 고침 · 닫힘 · 머지는 pr job 이 Prober PR 화면에 올린다 +name: prober-map + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + push: + branches: [beta] + workflow_dispatch: + +jobs: + changes: + # 포크 PR 에는 시크릿이 없다. 닫힘은 지도를 다시 올리지 않는다 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + discord_schema: ${{ steps.f.outputs.discord_schema }} + knowledge: ${{ steps.f.outputs.knowledge }} + steps: + - id: f + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + ACTION: ${{ github.event.action }} + FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + PR: ${{ github.event.pull_request.number }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.event.after }} + run: | + if [ "$EVENT" = workflow_dispatch ]; then for kv in discord_schema=true knowledge=true; do echo "$kv" >> "$GITHUB_OUTPUT"; done; exit 0; fi + if [ "$EVENT" = pull_request ]; then + if [ "$FORK" = true ] || [ "$ACTION" = closed ]; then : > files.txt + else gh pr diff "$PR" --repo "$GITHUB_REPOSITORY" --name-only > files.txt; fi + else + gh api "repos/$GITHUB_REPOSITORY/compare/$BEFORE...$AFTER" --jq '.files[].filename' > files.txt || echo .github/workflows/prober-map.yml > files.txt + fi + echo "discord_schema=$(grep -qE '^discord/app/.*|^discord/\.prober/map/.*|^\.github/workflows/prober-map\.yml$' files.txt && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "knowledge=$(grep -qE '^specs/.*|^docs/erd/.*|^docs/flows/.*|^\.prober/map/concepts\.json$|^\.prober/map/flows/.*|^scripts/prober/collect-knowledge\.cjs$|^scripts/prober/collect-knowledge\.cjs$|^\.github/workflows/prober-map\.yml$' files.txt && echo true || echo false)" >> "$GITHUB_OUTPUT" + + # 디스코드 봇의 SQLite 구조(ERD)를 Prober 로 보낸다 — file 방식: DB 를 띄우지 않고 discord/.prober/map/schema.json 을 그대로 보낸다. + # 그 파일이 실제 CREATE TABLE 과 맞는지는 discord-PR-CI 의 pytest(tests/test_prober_schema.py)가 막는다. + # PR 이면 beta 대비 전·후를 코멘트로 남긴다. beta push 는 비교 기준 스냅샷이다. + discord-schema: + needs: changes + if: needs.changes.outputs.discord_schema == 'true' + runs-on: ubuntu-latest + env: + PROBER_API: https://api.prober.im + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc + SOURCE: studyclub-engineering:discord + SCHEMA: discord/.prober/map/schema.json + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Prober 에 올리기 + env: + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + jq -n --slurpfile erd "$SCHEMA" --arg source "$SOURCE" --arg repo "$GITHUB_REPOSITORY" --arg branch "$branch" \ + --arg sha "${HEAD_SHA:-$GITHUB_SHA}" --arg pr "$PR" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" \ + '{source:$source, repo:$repo, branch:$branch, baseBranch:"beta", sha:$sha, method:"file", erd:$erd[0]} + + (if $pr != "" then {pr:($pr|tonumber), prUrl:$url} else {} end)' > body.json + code=$(curl -s -o res.json -w '%{http_code}' -X POST "$PROBER_API/teams/$PROBER_PROJECT/schema/snapshots" \ + -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data-binary @body.json) + if [ "${code:0:1}" != 2 ]; then echo "::error::Prober 가 $code 로 거절 — $(head -c 300 res.json)"; exit 1; fi + jq -r .comment res.json > comment.md + cat comment.md >> "$GITHUB_STEP_SUMMARY" + - name: PR 코멘트 (있으면 고치고 없으면 만든다) + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | (startswith(\"\") or startswith(\"\")) and contains(\"### DB 구조 · $SOURCE\")) | .id" | head -1) + if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@comment.md >/dev/null + else gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@comment.md >/dev/null; fi + + # 지도 위층(문서 · 개념 · 흐름)을 Prober 로 보낸다 — specs/*/spec.md · docs/erd · docs/flows 와 .prober/map/concepts.json · .prober/map/flows/*.json. + # 테이블과 잇는 건 Prober 가 읽을 때 한다. 적힌 테이블이 DB 구조에 없으면 경고로 찍는다(실패는 아니다). + # beta push 가 지도의 기준이다. AGENT.md §작업 룰 「.prober/map」 + knowledge: + needs: changes + if: needs.changes.outputs.knowledge == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Prober 에 올리기 + env: + PROBER_API: https://api.prober.im + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc + BASE_BRANCH: beta + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/prober/collect-knowledge.cjs + + pr: + # PR 화면 — 열림 · 고침 · 닫힘 · 머지. PUT 은 같은 repo · number 면 한 행 + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + continue-on-error: true # Prober 가 죽어도 PR 을 막지 않는다 + permissions: + contents: read + pull-requests: read + steps: + - name: PR 올리기 + env: + GH_TOKEN: ${{ github.token }} + PROBER_API: https://api.prober.im + PROBER_TOKEN: ${{ secrets.PROBER_TOKEN }} + PROBER_PROJECT: 8a05d363-c757-4bba-90f1-680083d7527b/projects/47d37bd7-a00a-4c6b-9ae9-37aa60f43dfc + PR: ${{ github.event.pull_request.number }} + run: | + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate \ + --jq '[.[] | { sha, message: .commit.message[0:500], committedAt: .commit.author.date }]' | jq -s 'add | .[-500:]' > commits.json + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq \ + '{ repo: .base.repo.full_name, number, title, url: .html_url, body: ((.body // "")[0:200000]), + state: (if .merged_at then "merged" elif .state == "closed" then "closed" else "open" end), + baseBranch: .base.ref, headBranch: .head.ref, headSha: .head.sha, mergedAt: .merged_at }' > pr.json + jq --slurpfile c commits.json '. + { commits: $c[0] }' pr.json > body.json + curl -fsS -X PUT "$PROBER_API/teams/$PROBER_PROJECT/prs" \ + -H "authorization: Bearer $PROBER_TOKEN" -H 'content-type: application/json' --data @body.json >/dev/null \ + || echo "::warning::Prober PR 올리기 실패 — PR 화면 API 가 아직 없거나 Prober 가 응답하지 않음" diff --git a/.prober/map/concepts.json b/.prober/map/concepts.json new file mode 100644 index 000000000..f7d5e70b3 --- /dev/null +++ b/.prober/map/concepts.json @@ -0,0 +1,23 @@ +{ + "concepts": [ + { "name": "계정", "summary": "가입한 크루 한 명 — 로그인 수단(구글), 약관 동의, 탈퇴 사유", "tables": ["ACCOUNT", "ACCOUNT_IDENTITY", "ACCOUNT_CONSENT", "ACCOUNT_LEAVE_REASON"] }, + { "name": "스터디 운영", "summary": "프로그램(정체성) 아래 기수별 스터디, 스터디 안에 반과 모임, 모임마다 출석. 명부(참가자)는 반에 속한다", "tables": ["STUDY_PROGRAM", "STUDY", "STUDY_GROUP", "STUDY_MEETING", "STUDY_PARTICIPANT", "STUDY_ATTENDANCE"] }, + { "name": "모집", "summary": "기수의 모집 회차와 신청서. 신청 제출 = 기본 반 명부 편입", "tables": ["STUDY_RECRUITMENT", "STUDY_APPLICATION"] }, + { "name": "제안", "summary": "크루가 열고 싶은 스터디를 제안하고 다른 크루가 관심을 표시", "tables": ["STUDY_PROPOSAL", "STUDY_PROPOSAL_INTEREST"] }, + { "name": "활동", "summary": "스터디에 남기는 후기와 북마크", "tables": ["STUDY_REVIEW", "STUDY_BOOKMARK"] }, + { "name": "알림", "summary": "메일 등 발송 한 건과 그 문구 템플릿", "tables": ["NOTIFICATION", "NOTIFICATION_TEMPLATE"] }, + { "name": "운영", "summary": "관리자 감사 로그, 올린 파일, 스터디 ↔ 디스코드 카테고리 연결", "tables": ["ADMIN_AUDIT_LOG", "STORAGE_OBJECT", "STUDY_DISCORD_LINK"] }, + { "name": "디스코드 봇", "summary": "봇이 SQLite 에 따로 두는 것 — 스터디 이름 예약, 공부방 활동 시각", "tables": ["discord/study_reservation", "discord/voice_channel_activity"] } + ], + "relations": [ + { "from": "STUDY.CREATED_BY", "to": "ACCOUNT", "why": "이 기수를 등록한 계정 = 담당 캡틴 (docs/erd/STUDY.md)" }, + { "from": "NOTIFICATION.RECIPIENT_USER_ID", "to": "ACCOUNT", "why": "받는 사람. 애그리거트 사이라 FK 없음 (docs/erd/NOTIFICATION.md)" }, + { "from": "NOTIFICATION_TEMPLATE.UPDATED_BY_ADMIN_ID", "to": "ACCOUNT", "why": "마지막으로 고친 관리자 (docs/erd/NOTIFICATION_TEMPLATE.md)" }, + { "from": "ACCOUNT.DISCORD_ID", "to": "discord:user", "why": "디스코드 유저 snowflake — 출석 체크가 이걸로 계정을 찾는다" }, + { "from": "STUDY_DISCORD_LINK.DISCORD_STUDY_ID", "to": "discord:category", "why": "스터디 카테고리 채널 snowflake" }, + { "from": "STUDY_DISCORD_LINK.DISCORD_ROLE_ID", "to": "discord:role", "why": "스터디 전용 역할 snowflake" }, + { "from": "ACCOUNT_IDENTITY.PROVIDER_ACCOUNT_ID", "to": "google:account", "why": "OAuth sub" }, + { "from": "discord/study_reservation.study_id", "to": "discord:category", "why": "봇이 만든 카테고리 ID — backend STUDY 가 아니다" }, + { "from": "discord/study_reservation.role_id", "to": "discord:role", "why": "봇이 만든 역할 ID" } + ] +} diff --git a/.prober/map/flows/discord-attendance.json b/.prober/map/flows/discord-attendance.json new file mode 100644 index 000000000..927830c21 --- /dev/null +++ b/.prober/map/flows/discord-attendance.json @@ -0,0 +1,13 @@ +{ + "name": "디스코드 출석 체크", + "trigger": "POST /api/discord/studies/{discordStudyId}/attendances (봇, X-API-Key)", + "spec": "specs/discord-attendance/spec.md", + "steps": [ + { "external": "discord", "note": "반장이 공부방 채팅에서 !출석체크 → 봇이 접속자 스냅샷을 보낸다" }, + { "table": "STUDY_DISCORD_LINK", "op": "read", "note": "디스코드 카테고리 → 스터디" }, + { "table": "ACCOUNT", "op": "read", "note": "discordUserId → ACCOUNT.DISCORD_ID → 계정. 없으면 unmatched" }, + { "table": "STUDY_PARTICIPANT", "op": "read", "note": "그 반 참가자인가 (ACTIVE · PAUSED)" }, + { "table": "STUDY_MEETING", "op": "lock", "note": "지금 모이는 회차를 백엔드가 고른다. 예정 회차 자동 시작만 잠금" }, + { "table": "STUDY_ATTENDANCE", "op": "upsert", "note": "PRESENT — ABSENT·없는 행만. LATE·EXCUSED 는 덮지 않는다 (한 문장 upsert)" } + ] +} diff --git a/.prober/map/flows/study-apply.json b/.prober/map/flows/study-apply.json new file mode 100644 index 000000000..50306eaeb --- /dev/null +++ b/.prober/map/flows/study-apply.json @@ -0,0 +1,14 @@ +{ + "name": "스터디 신청", + "trigger": "POST /api/studies/{studyId}/applications", + "spec": "specs/study-application/spec.md", + "steps": [ + { "table": "ACCOUNT", "op": "lock", "note": "디스코드 연동 확인 · 같은 기수 기존 신청 검사 (계정 행 잠금)" }, + { "table": "STUDY_RECRUITMENT", "op": "lock", "note": "열린 모집 회차 · 정원 검사 (마지막 자리 동시 신청 방지)" }, + { "table": "STUDY_APPLICATION", "op": "insert", "note": "신청서 저장 (FORM_ANSWER)" }, + { "table": "STUDY_GROUP", "op": "read", "note": "기본 반 = id 가 가장 작은 반. 없으면 「기본반」을 만든다" }, + { "table": "STUDY_PARTICIPANT", "op": "upsert", "note": "명부 편입 — ACTIVE · MEMBER. 남은 행이 있으면 되살린다" }, + { "table": "ACCOUNT", "op": "update", "note": "디스코드 별명 갱신" }, + { "external": "discord", "event": "study_participant.joined", "note": "커밋 뒤 스터디 역할 부여 (DSC-07)" } + ] +} diff --git a/.prober/map/flows/user-leave.json b/.prober/map/flows/user-leave.json new file mode 100644 index 000000000..08192ae82 --- /dev/null +++ b/.prober/map/flows/user-leave.json @@ -0,0 +1,19 @@ +{ + "name": "회원 탈퇴", + "trigger": "DELETE /api/me", + "spec": "specs/user-leave/spec.md", + "steps": [ + { "table": "ACCOUNT", "op": "read", "note": "본인 계정. 이후 전부 한 트랜잭션" }, + { "table": "ACCOUNT_LEAVE_REASON", "op": "insert", "note": "사유만 — accountId 는 담지 않는다" }, + { "table": "ACCOUNT_IDENTITY", "op": "delete", "note": "로그인 수단 파기 (재가입 가능하게)" }, + { "table": "STUDY_PARTICIPANT", "op": "update", "note": "DELETED + LEFT_AT — 물리 삭제하면 출석 이력이 사라진다" }, + { "table": "STUDY_BOOKMARK", "op": "delete" }, + { "table": "STUDY_PROPOSAL_INTEREST", "op": "delete" }, + { "table": "NOTIFICATION", "op": "update", "note": "받는 사람 비식별화, PENDING 은 CANCELLED" }, + { "table": "STUDY_PROPOSAL", "op": "update", "note": "OPEN → CLOSED" }, + { "table": "STUDY_APPLICATION", "op": "update", "note": "FORM_ANSWER.discordNickname 마스킹" }, + { "table": "ACCOUNT_CONSENT", "op": "delete" }, + { "table": "ACCOUNT", "op": "delete", "note": "프로필 파기. 사진 주소는 먼저 읽어 둔다" }, + { "table": "STORAGE_OBJECT", "op": "update", "note": "올린 사진 DELETING → 커밋 뒤 S3 파일과 행 삭제" } + ] +} diff --git a/AGENT.md b/AGENT.md index 557ef4351..0b8998a0f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -146,6 +146,12 @@ cd backend && ./gradlew :api:bootRun # JDK 25 필요. Gradle 은 wrapp **playground 만 `beta` 브랜치에서 배포된다** (`playground-beta.yaml`) — 프로토타입이라 main 을 기다리지 않는다. `backend-PR-CI` 는 PR 마다 `./gradlew check` 를 돌린다 — 컴파일 실패·포맷 위반이 머지되는 걸 막는 게이트. `backend-migration-check` 는 PR 마다 빈 MySQL 에 마이그레이션을 적용해 본다 — 여기서 깨지면 `V*.sql` 을 고친다. +- **디스코드 봇의 `CREATE TABLE` 을 고치면 같은 커밋에서 `discord/.prober/map/schema.json` 도 다시 뽑는다** — + `cd discord && python -m tests.test_prober_schema > .prober/map/schema.json`. 봇은 백엔드 MySQL 과 별개로 SQLite 테이블을 쓰고, + Prober(DB 구조 지도)는 이 파일을 그대로 읽는다(`prober-map`). 파일이 실제 테이블과 다르면 `discord-PR-CI` 의 pytest 가 막는다. +- **`.prober/map/` 는 Prober 지도의 위층이다** — `concepts.json` 은 테이블을 개념 묶음으로 나누고 이름으로 못 찾는 관계(`CREATED_BY` → ACCOUNT, 디스코드 snowflake 등)를 적는다. + `flows/*.json` 은 흐름 하나의 단계 순서(테이블 · op · 이벤트 · 시스템 밖). 테이블을 추가·삭제·이름 변경했거나 스펙의 처리 순서를 바꿨으면 같은 PR 에서 고친다. + `prober-map` CI 가 스펙·`docs/erd`·`docs/flows` 와 함께 올리고, DB 구조에 없는 테이블을 가리키면 경고를 찍는다. --- diff --git a/discord/.prober/map/schema.json b/discord/.prober/map/schema.json new file mode 100644 index 000000000..cbb7ea604 --- /dev/null +++ b/discord/.prober/map/schema.json @@ -0,0 +1,75 @@ +{ + "engine": "sqlite 3", + "tables": [ + { + "name": "study_reservation", + "columns": [ + { + "name": "study_name", + "type": "text", + "nullable": false, + "default": null, + "pk": true + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null + }, + { + "name": "idempotency_key", + "type": "text", + "nullable": false, + "default": null + }, + { + "name": "study_id", + "type": "integer", + "nullable": true, + "default": null + }, + { + "name": "role_id", + "type": "integer", + "nullable": true, + "default": null + } + ], + "fks": [], + "indexes": [] + }, + { + "name": "voice_channel_activity", + "columns": [ + { + "name": "channel_id", + "type": "integer", + "nullable": false, + "default": null, + "pk": true + }, + { + "name": "first_seen_at", + "type": "text", + "nullable": false, + "default": null + }, + { + "name": "last_activity_at", + "type": "text", + "nullable": true, + "default": null + }, + { + "name": "last_alerted_at", + "type": "text", + "nullable": true, + "default": null + } + ], + "fks": [], + "indexes": [] + } + ] +} diff --git a/discord/tests/test_prober_schema.py b/discord/tests/test_prober_schema.py new file mode 100644 index 000000000..aed1cc5da --- /dev/null +++ b/discord/tests/test_prober_schema.py @@ -0,0 +1,57 @@ +"""discord/.prober/map/schema.json matches the tables the bot really creates. + +Prober (the DB-structure map) reads that file as-is; nothing here runs a DB in CI +for it. So the file is hand- (or AI-) maintained next to the CREATE TABLE +statements, and this test is what keeps the two from drifting: it lets each +store create its table in a scratch SQLite file and compares PRAGMA output. + +After changing a CREATE TABLE, regenerate with: + python -m tests.test_prober_schema > .prober/map/schema.json +""" + +from __future__ import annotations + +import json +import sqlite3 +import tempfile +from pathlib import Path + +from app.study_reservations import StudyReservations +from app.voice_activity_log import VoiceActivityLog + +SCHEMA = Path(__file__).resolve().parents[1] / ".prober" / "map" / "schema.json" +STORES = (StudyReservations, VoiceActivityLog) + + +def actual() -> dict: + """The ERD of a fresh SQLite file after every store has created its table.""" + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "discord.sqlite3") + for store in STORES: + store(path)._connect().close() + conn = sqlite3.connect(path) + names = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")] + tables = [] + for name in names: + columns = [ + {"name": c, "type": t.lower(), "nullable": not (notnull or pk), "default": d, **({"pk": True} if pk else {})} + for _, c, t, notnull, d, pk in conn.execute(f"PRAGMA table_info({name})") + ] + fks = [ + {"name": f"fk_{name}_{i}", "columns": [frm], "refTable": ref, "refColumns": [to]} + for i, _, ref, frm, to, *_ in conn.execute(f"PRAGMA foreign_key_list({name})") + ] + tables.append({"name": name, "columns": columns, "fks": fks, "indexes": []}) + version = conn.execute("SELECT sqlite_version()").fetchone()[0].split(".")[0] + conn.close() + return {"engine": f"sqlite {version}", "tables": tables} + + +def test_schema_file_matches_the_tables_the_bot_creates(): + assert json.loads(SCHEMA.read_text()) == actual(), ( + "discord/.prober/map/schema.json is stale -- run: python -m tests.test_prober_schema > .prober/map/schema.json" + ) + + +if __name__ == "__main__": + print(json.dumps(actual(), ensure_ascii=False, indent=2)) diff --git a/scripts/prober/collect-knowledge.cjs b/scripts/prober/collect-knowledge.cjs new file mode 100644 index 000000000..e2c0aa569 --- /dev/null +++ b/scripts/prober/collect-knowledge.cjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +// 레포의 위층(문서 · 개념 · 흐름)을 모아 Prober 에 올린다. specs/schema §위층 +// node collect.cjs → 올린다 (env PROBER_API · PROBER_TOKEN · PROBER_PROJECT=<팀>/projects/<프로젝트>) +// node collect.cjs --dry-run → 저장하지 않고 "DB 구조에 없는 테이블" 경고만 본다 (로컬 검사) +// node collect.cjs --print → 보낼 body 만 찍는다 (네트워크 없음) +// PR 브랜치면 응답의 기준 대비 비교를 knowledge-comment.md 로 쓴다 (action.yml 이 PR 코멘트로) +// 읽는 것 (레포 루트 기준, env 로 바꿀 수 있다) +// PROBER_DOCS = specs/*/spec.md,docs/erd/*.md,docs/flows/*.md +// /concepts.json = { concepts: [{ name, summary?, tables[] }], relations: [{ from: 'TABLE.COLUMN', to, why? }] } +// /flows/*.json = { name, trigger?, spec?, steps: [{ table? | external?, op?, event?, note? }] } +// = 옛 폴더 .strata 가 있으면 그것, 없으면 .prober/map (옛 폴더 지원은 한 분기 뒤 삭제) +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const root = process.env.REPO_ROOT || process.cwd(); +const args = process.argv.slice(2); +const git = (...a) => { + try { + return execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +}; + +// a/별/b.md · a/별.md 정도만 — 별 하나가 디렉토리 한 칸 또는 파일 이름 +function glob(pattern) { + let paths = ['']; + for (const part of pattern.split('/')) { + const next = []; + for (const p of paths) { + const dir = path.join(root, p); + if (!part.includes('*')) { + if (fs.existsSync(path.join(dir, part))) next.push(path.join(p, part)); + continue; + } + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) continue; + const re = new RegExp('^' + part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '$'); + for (const name of fs.readdirSync(dir).sort()) if (re.test(name)) next.push(path.join(p, name)); + } + paths = next; + } + return paths.filter((p) => fs.statSync(path.join(root, p)).isFile()); +} + +const readJson = (p) => JSON.parse(fs.readFileSync(path.join(root, p), 'utf8')); +const patterns = (process.env.PROBER_DOCS || 'specs/*/spec.md,docs/erd/*.md,docs/flows/*.md').split(',').filter(Boolean); +const docs = [...new Set(patterns.flatMap(glob))].map((p) => ({ path: p, body: fs.readFileSync(path.join(root, p), 'utf8') })); +const map = fs.existsSync(path.join(root, '.strata')) ? '.strata' : '.prober/map'; +const concepts = fs.existsSync(path.join(root, map, 'concepts.json')) ? readJson(`${map}/concepts.json`) : { concepts: [], relations: [] }; +const flows = glob(`${map}/flows/*.json`).map((p) => { + const f = readJson(p); + if (!f.name || !Array.isArray(f.steps)) throw new Error(`${p}: { name, steps: [...] } 모양이 아니다`); + return f; +}); + +// PR 이 고친 파일 — 액션은 gh pr diff 결과를 PROBER_CHANGED_FILE 로 준다. 로컬은 merge-base 로. 못 구하면 전부 비교 +function changedPaths() { + if (process.env.PROBER_CHANGED_FILE && fs.existsSync(process.env.PROBER_CHANGED_FILE)) + return fs.readFileSync(process.env.PROBER_CHANGED_FILE, 'utf8').split('\n').filter(Boolean); + const base = process.env.BASE_BRANCH || 'main'; + const mb = git('merge-base', `origin/${base}`, 'HEAD'); + return mb ? git('diff', '--name-only', `${mb}...HEAD`).split('\n').filter(Boolean) : undefined; +} + +const remote = git('remote', 'get-url', 'origin').match(/[:/]([^/:]+\/[^/]+?)(\.git)?$/)?.[1]; +const body = { + repo: process.env.GITHUB_REPOSITORY || remote, + branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || git('branch', '--show-current'), + baseBranch: process.env.BASE_BRANCH || 'main', + sha: process.env.HEAD_SHA || process.env.GITHUB_SHA || git('rev-parse', 'HEAD'), + ...(process.env.PR_NUMBER ? { pr: Number(process.env.PR_NUMBER) } : {}), + concepts: concepts.concepts ?? [], + relations: concepts.relations ?? [], + flows, + docs, + ...(changedPaths() ? { changed: changedPaths() } : {}), + ...(args.includes('--dry-run') ? { dryRun: true } : {}), +}; + +if (args.includes('--print')) { + console.log(JSON.stringify({ ...body, docs: body.docs.map((d) => ({ path: d.path, bytes: d.body.length })) }, null, 2)); + process.exit(0); +} + +(async () => { + const api = (process.env.PROBER_API || 'https://api.prober.im').replace(/\/$/, ''); + const res = await fetch(`${api}/teams/${process.env.PROBER_PROJECT}/schema/knowledge`, { + method: 'POST', + headers: { authorization: `Bearer ${process.env.PROBER_TOKEN}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) { + console.log(`::error::Prober 가 ${res.status} 로 거절 — ${text.slice(0, 300)}`); + process.exit(1); + } + const out = JSON.parse(text); + for (const w of out.warnings) console.log(`::warning::${w}`); + const c = out.counts; + console.log(`${out.saved ? '올렸다' : '검사만 했다'} — 개념 ${c.concepts} · 선언 관계 ${c.relations} · 흐름 ${c.flows} · 문서 ${c.docs}(테이블과 이어짐 ${c.linkedDocs}) · 경고 ${out.warnings.length}`); + // PR 이면 기준 브랜치와 견준 코멘트 본문이 온다 — 액션 다음 스텝이 PR 에 단다 (로컬 --dry-run 은 화면에) + if (out.comment) { + fs.writeFileSync('knowledge-comment.md', out.comment); + if (args.includes('--dry-run')) console.log(`\n${out.comment}`); + } + if (process.env.GITHUB_STEP_SUMMARY) + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Prober 위층 · ${body.repo}\n\n개념 ${c.concepts} · 흐름 ${c.flows} · 문서 ${c.docs} (테이블과 이어짐 ${c.linkedDocs})\n\n${out.warnings.map((w) => `- ⚠️ ${w}`).join('\n')}\n`); +})().catch((e) => { + console.log(`::error::${e.message}`); + process.exit(1); +}); diff --git a/scripts/prober/erd.sql b/scripts/prober/erd.sql new file mode 100644 index 000000000..da3250eec --- /dev/null +++ b/scripts/prober/erd.sql @@ -0,0 +1,39 @@ +-- 현재 DB 의 구조를 erd.json 한 덩어리로 — 언어·ORM 무관 (mysql CLI 만 있으면 된다). specs/schema §수집기 +-- mysql --default-character-set=utf8mb4 -h -u -p -N -B -r < erd.sql > erd.json +-- (문자셋을 안 주면 한글 default 가 ?? 로 깨진다) +-- 순서가 필요한 배열은 GROUP_CONCAT … ORDER BY 로 만든다 — JSON_ARRAYAGG 는 순서를 보장하지 않는다 +SET SESSION group_concat_max_len = 16777216; +-- extract.cjs 와 같은 모양. Flyway 기록 테이블은 뺀다 +SELECT JSON_OBJECT( + 'engine', CONCAT('mysql ', SUBSTRING_INDEX(VERSION(), '-', 1)), + 'tables', COALESCE(( + SELECT CAST(CONCAT('[', GROUP_CONCAT(JSON_OBJECT( + 'name', t.TABLE_NAME, + 'columns', ( + SELECT CAST(CONCAT('[', GROUP_CONCAT(JSON_OBJECT( + 'name', c.COLUMN_NAME, + 'type', c.COLUMN_TYPE, + 'nullable', CAST(IF(c.IS_NULLABLE = 'YES', 'true', 'false') AS JSON), + 'default', c.COLUMN_DEFAULT, + 'pk', CAST(IF(c.COLUMN_KEY = 'PRI', 'true', 'false') AS JSON), + 'extra', c.EXTRA) ORDER BY c.ORDINAL_POSITION SEPARATOR ','), ']') AS JSON) + FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME), + 'fks', COALESCE(( + SELECT JSON_ARRAYAGG(JSON_OBJECT('name', k.n, 'columns', k.cols, 'refTable', k.rt, 'refColumns', k.rcols)) + FROM ( + SELECT CONSTRAINT_NAME n, MIN(REFERENCED_TABLE_NAME) rt, + CAST(CONCAT('[', GROUP_CONCAT(JSON_QUOTE(COLUMN_NAME) ORDER BY ORDINAL_POSITION), ']') AS JSON) cols, + CAST(CONCAT('[', GROUP_CONCAT(JSON_QUOTE(REFERENCED_COLUMN_NAME) ORDER BY ORDINAL_POSITION), ']') AS JSON) rcols + FROM information_schema.KEY_COLUMN_USAGE kc + WHERE TABLE_SCHEMA = t.TABLE_SCHEMA AND TABLE_NAME = t.TABLE_NAME AND REFERENCED_TABLE_NAME IS NOT NULL + GROUP BY CONSTRAINT_NAME) k), JSON_ARRAY()), + 'indexes', COALESCE(( + SELECT JSON_ARRAYAGG(JSON_OBJECT('name', i.n, 'columns', i.cols, 'unique', CAST(IF(i.nu = 0, 'true', 'false') AS JSON))) + FROM ( + SELECT INDEX_NAME n, MIN(NON_UNIQUE) nu, CAST(CONCAT('[', GROUP_CONCAT(JSON_QUOTE(COLUMN_NAME) ORDER BY SEQ_IN_INDEX), ']') AS JSON) cols + FROM information_schema.STATISTICS st + WHERE TABLE_SCHEMA = t.TABLE_SCHEMA AND TABLE_NAME = t.TABLE_NAME AND INDEX_NAME <> 'PRIMARY' + GROUP BY INDEX_NAME) i), JSON_ARRAY())) ORDER BY t.TABLE_NAME SEPARATOR ','), ']') AS JSON) + FROM information_schema.TABLES t + WHERE t.TABLE_SCHEMA = DATABASE() AND t.TABLE_TYPE = 'BASE TABLE' AND t.TABLE_NAME NOT IN ('flyway_schema_history')), JSON_ARRAY()) +) AS erd;