Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/backend-migration-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ on:
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
services:
mysql:
# 로컬·운영과 같은 8.0. 값은 CI 안에서만 사는 더미다 (운영 자격증명 아님).
Expand Down Expand Up @@ -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(\"<!-- prober:schema -->\") or startswith(\"<!-- strata:schema -->\")) 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: |
Expand Down
135 changes: 135 additions & 0 deletions .github/workflows/prober-map.yml
Original file line number Diff line number Diff line change
@@ -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(\"<!-- prober:schema -->\") or startswith(\"<!-- strata:schema -->\")) 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 가 응답하지 않음"
23 changes: 23 additions & 0 deletions .prober/map/concepts.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
13 changes: 13 additions & 0 deletions .prober/map/flows/discord-attendance.json
Original file line number Diff line number Diff line change
@@ -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)" }
]
}
14 changes: 14 additions & 0 deletions .prober/map/flows/study-apply.json
Original file line number Diff line number Diff line change
@@ -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)" }
]
}
19 changes: 19 additions & 0 deletions .prober/map/flows/user-leave.json
Original file line number Diff line number Diff line change
@@ -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 파일과 행 삭제" }
]
}
6 changes: 6 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 구조에 없는 테이블을 가리키면 경고를 찍는다.

---

Expand Down
Loading
Loading