이 문서는 backend/routes, backend/models, backend/utils, backend/config.py, backend/app.py와 backend/fastapi_app/*를 기준으로 작성한 실행 계약 문서입니다.
- Flask 개발 기본값:
http://127.0.0.1:5000 - FastAPI 개발 기본값:
http://127.0.0.1:8000 - Health Check:
GET /api/health
- 요청:
Content-Type: application/json(파일 업로드 제외) - 응답: JSON (
application/json)
- 요청 쿼리:
page(기본 1)page_size또는pageSize(동시 지원)
- 응답 키:
items,total,page,page_size,pageSize
{
"items": [],
"total": 0,
"page": 1,
"page_size": 10,
"pageSize": 10
}이 백엔드는 Bearer 헤더 중심 계약이 아닙니다. 표준 계약은 HttpOnly 쿠키 기반 JWT + CSRF 헤더입니다.
- Access Cookie:
access_token_cookie - Refresh Cookie:
refresh_token_cookie - CSRF Cookie:
csrf_access_token,csrf_refresh_token - CSRF Header:
X-CSRF-TOKEN - 관련 설정:
JWT_TOKEN_LOCATION=['cookies']JWT_COOKIE_CSRF_PROTECT=true(기본)JWT_ACCESS_COOKIE_PATH=/JWT_REFRESH_COOKIE_PATH=/api/auth
클라이언트 요구사항:
withCredentials: true로 요청- 비안전 메서드(
POST/PUT/PATCH/DELETE)에X-CSRF-TOKEN포함
FastAPI 수학여행 쓰기 추가 규약:
- 대상:
/api/community/field-trip/*의 쓰기 요청 - 추가 CSRF Cookie:
field_trip_csrf_token - 추가 CSRF Header:
X-Field-Trip-CSRF - 설명: 로그인 사용자도 기본
X-CSRF-TOKEN을 유지하고, 수학여행 잠금 해제 후에는X-Field-Trip-CSRF도 함께 보냄
adminstudent_councilteacherstudent
| 코드 | 의미 | 대표 응답 |
|---|---|---|
200 |
조회/처리 성공 | {...} |
201 |
생성 성공 | {...} |
400 |
잘못된 요청 | {"error":"..."} |
401 |
인증 실패/누락 | {"error":"...","error_code":"..."} |
403 |
권한 부족/가시성 제한 | {"error":"..."} |
404 |
리소스 없음 | {"error":"..."} |
405 |
메서드 비허용 | {"error":"..."} |
409 |
중복/충돌 | {"error":"..."} |
422 |
유효성 검증 실패 | {"error":"..."} 또는 {"errors":[...]} |
429 |
레이트 리밋 초과 | {"error":"...","error_code":"rate_limit_exceeded"} |
500 |
서버 오류 | {"error":"..."} |
JWT 공통 에러 코드:
token_expiredinvalid_tokenauthorization_requiredtoken_revoked
- 캐시:
@cache_json_response(namespace)데코레이터 기반 응답 캐시- 실동작 조건:
GET+CACHE_RUNTIME_MODE=redis+admin 아님 - 키 구성:
method + path + normalized query + actor_scope(anon | user:{id}:role:{role}) - 권한/개인화 응답(
myReaction,myVote, 승인+본인 pending 목록`)도 actor_scope로 사용자별 분리 - 쓰기 성공 시
invalidate_cache_namespaces(...)로 네임스페이스 일괄 무효화
- 레이트리밋 키:
- 로그인 사용자:
user:<id> - 익명:
ip:<remote>
- 로그인 사용자:
- 공통 쓰기 제한:
- 블루프린트 단위
POST/PUT/PATCH/DELETE
- 블루프린트 단위
- 인증 엔드포인트 별도 제한:
register,login,refresh
업로드 성공 응답:
{
"id": "stored-filename.ext",
"name": "original.ext",
"size": 12345,
"url": "/api/.../uploads/stored-filename.ext?preview_token=...",
"canonicalUrl": "/api/.../uploads/stored-filename.ext",
"mime": "image/png",
"kind": "image"
}url: 임시 미리보기 토큰이 포함될 수 있음canonicalUrl: DB/본문 저장에 사용하는 정규 URL- DB 미연결 임시 파일은
preview_token없이 접근 불가
쓰기 요청에서 생성되는 리소스는 서버가 ip_address, user_agent를 자동 수집할 수 있습니다.
- 클라이언트 입력: 불필요(요청 바디에 보내지 않음)
- 수집 시점: 신규 ORM row flush 직전(
before_flush) - 수집 조건: 요청 컨텍스트 존재 + 대상 컬럼이 비어 있음
- 정규화:
ip_address최대 64자,user_agent최대 255자 - 오류 처리: 수집 실패가 API 실패로 이어지지 않음(best-effort)
적용 범위(요약):
- 사용자/인증:
users,auth_tokens - 공지/자유게시판/인성 가치 PICK!/댓글/반응/북마크
- 청원/설문/투표/BOSPI/스터디 윗 범서/분실물/곰솔마켓/동아리모집/과목변경 관련 쓰기 엔티티
flowchart TD
API["beomseo.in API"]
API --> Health["/api/health"]
API --> Auth["/api/auth/*"]
API --> Notices["/api/notices/*"]
API --> Free["/api/community/free/*"]
API --> ValuePick["/api/community/value-pick/*"]
API --> Club["/api/club-recruit/*"]
API --> Subject["/api/subject-changes/*"]
API --> Petition["/api/community/petitions/*"]
API --> Survey["/api/surveys/*"]
API --> Vote["/api/community/votes/*"]
API --> Bospi["/api/community/bospi/*"]
API --> StudyWithBeomseo["/api/community/study-with-beomseo/*"]
API --> SportsLeague["/api/sports-league/*"]
API --> FieldTrip["/api/community/field-trip/*"]
API --> SchoolMeals["/api/school-info/meals*"]
API --> Lost["/api/community/lost-found/*"]
API --> Market["/api/community/gomsol-market/*"]
Auth --> A1["register / login"]
Auth --> A2["refresh / logout / me"]
Notices --> N1["CRUD + comments"]
Notices --> N2["reactions + uploads"]
Free --> F1["CRUD + approve"]
Free --> F2["bookmark + comments"]
ValuePick --> VP1["CRUD + approve"]
ValuePick --> VP2["reactions + comments + uploads"]
Survey --> S1["CRUD + responses"]
Survey --> S2["credits / summary"]
Bospi --> B1["상태 / 예측"]
Bospi --> B2["공식 기록 / 랭킹"]
StudyWithBeomseo --> SWB1["반별 순위판"]
StudyWithBeomseo --> SWB2["예약 점수 입력"]
FieldTrip --> FT1["class unlock / posts / uploads"]
FieldTrip --> FT2["scoreboard / admin board settings"]
SchoolMeals --> SM1["today / range / ratings"]
SchoolMeals --> SM2["notification subscriptions"]
- Health/Auth:
/api/health,/api/auth/* - Notices:
/api/notices/* - Free:
/api/community/free/* - Value Pick:
/api/community/value-pick/* - Club Recruit:
/api/club-recruit/* - Subject Changes:
/api/subject-changes/* - Petitions:
/api/community/petitions/* - Surveys:
/api/surveys/* - Votes:
/api/community/votes/* - BOSPI:
/api/community/bospi/* - Study With Beomseo:
/api/community/study-with-beomseo/* - Sports League:
/api/sports-league/* - Field Trip:
/api/community/field-trip/* - 급식:
/api/school-info/meals* - Lost & Found:
/api/community/lost-found/* - Gomsol Market:
/api/community/gomsol-market/*
참고: 목록/생성/업로드 일부는 trailing slash(.../)도 허용합니다.
- 권한: 없음
- 성공
200:
{
"status": "healthy",
"message": "범서고등학교 API 서버"
}- 권한: 없음
- 레이트리밋:
RATELIMIT_REGISTER_LIMIT(기본5 per 10 minute) - 정책:
ALLOWED_SIGNUP_IPS대역에서만 가입 허용- 닉네임 금칙어(
NICKNAME_BANNED_WORDS) 검사
- Body:
nicknamestring, 길이2~50, 필수passwordstring, 길이8~72, 필수- 비밀번호 강도: 소문자/숫자/특수문자 각 1개 이상
- 성공
201:message + user, 쿠키 발급 - 대표 실패:
{
"error": "이미 사용 중인 닉네임입니다."
}- 권한: 없음
- 레이트리밋:
RATELIMIT_LOGIN_LIMIT(기본5 per minute) - Body:
nicknamestring, 필수passwordstring, 필수
- 성공
200:message + user, 쿠키 발급 - 대표 실패:
{
"error": "닉네임 또는 비밀번호가 올바르지 않습니다."
}- 권한: Refresh JWT 필요 (
@jwt_required(refresh=True)) - 레이트리밋:
RATELIMIT_REFRESH_LIMIT(기본20 per 10 minute) - 성공
200:
{
"message": "토큰이 갱신되었습니다."
}- 대표 실패:
{
"error": "Invalid refresh token"
}- 권한: Access JWT 필요
- Body: 없음
- 성공
200:
{
"message": "로그아웃 되었습니다."
}- 권한: Access JWT 필요
- 성공
200:
{
"user": {
"id": 1,
"nickname": "user01",
"role": "student",
"is_teacher": false,
"created_at": "2026-02-24T10:00:00"
}
}category:school | council | budgetbudgetYear: 예산 공개 글에서만 사용, 회계연도 시작 연도budgetMonth: 예산 공개 글에서만 사용,01~12입력을 받되 보드 UI는03~12,01,02순서로 사용- 활성 예산 공개 범위:
BUDGET_BOARD_START_YEAR <= budgetYear <= BUDGET_BOARD_END_YEAR - 예산 공개 글은
pinned,important,examRelated,tags를 저장/노출하지 않음 reaction.type:like | dislike- 댓글 길이:
1~1000 - 제목 길이:
2~200 - 첨부 개수: 최대
MAX_ATTACH_COUNT(기본 5)
- 권한: 선택 인증
- 캐시:
notices - 설명:
- 예산 공개 게시판의 활성 회계연도 범위와 기본 진입 연/월을 반환
- 프론트
/notices/budget루트 리다이렉트와 연도 탭 가드의 기준값
성공 예시:
{
"startYear": 2026,
"endYear": 2026,
"monthOrder": ["03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "01", "02"],
"currentBudgetYear": 2026,
"currentBudgetMonth": "03",
"defaultBudgetYear": 2026,
"defaultBudgetMonth": "03"
}- 권한: 선택 인증
- 캐시:
notices - Query:
category:school|council|budgetquery: 제목/본문/요약/태그 검색pinned,important,exam: booleantags: 콤마/개행/세미콜론 구분sort:recent|views|importantview:list면 리스트 직렬화budgetYear,budgetMonth:category=budget일 때 필수page,page_size|pageSize
- 예산 공개 제약:
category=budget이고budgetYear또는budgetMonth가 없으면422budgetYear가 활성 범위를 벗어나면422budgetMonth가1~12범위를 벗어나면422
- 권한:
student_council | admin - Body:
titlestring2~200bodystring 필수categoryrequired (school|council|budget)summaryoptionalpinned,important,examRelatedoptionaltagsoptionalattachmentsarray optional
- 예산 공개 추가 규칙:
category='budget'일 때budgetYear,budgetMonth필수budgetYear는 활성 범위 안에 있어야 함budgetMonth는01~12- budget 글에서는
pinned,important,examRelated,tags를 보내도 서버가 비활성화 처리
- 실패
422예시:
{
"errors": [
"budgetYear is required for budget notices."
]
}- 권한: 선택 인증
- 성공: 상세 +
myReaction - 실패:
404(없음/삭제) - 예산 공개 추가 규칙:
- 활성 회계연도 범위 밖의 budget 글은 존재하더라도
404
- 활성 회계연도 범위 밖의 budget 글은 존재하더라도
- 권한: 작성 학생회 본인 또는
admin - Body: 생성과 동일
- 예산 공개 추가 규칙:
- 일반 공지와 같은 엔드포인트를 재사용
- 수정 가능 주체는
admin또는 원작성student_council - 활성 범위 밖 budget 글은 수정도
404
- 권한: 작성 학생회 본인 또는
admin - 동작: 소프트 삭제
- 예산 공개 추가 규칙:
- 활성 범위 밖 budget 글은 삭제도
404
- 활성 범위 밖 budget 글은 삭제도
- 권한:
student_council | admin - Content-Type:
multipart/form-data - 필드:
file - 성공: 업로드 공통 계약
- 권한: 선택 인증
- 정책:
- 공지 첨부/본문 연결 파일은 접근 허용
- 임시 파일은
preview_token필수 - 활성 범위 밖 budget 글에 연결된 첨부/본문 이미지 경로는
404
- 권한: 선택 인증
- 캐시:
notices - Query:
order,page,page_size|pageSize - 예산 공개 추가 규칙:
- 댓글 API는 기존 notices 댓글 계약을 그대로 재사용
- 활성 범위 밖 budget 글 댓글 조회는
404
- 권한: 인증 필요
- Body:
bodystring1~1000 - 예산 공개 추가 규칙:
- 활성 범위 밖 budget 글 댓글 작성은
404
- 활성 범위 밖 budget 글 댓글 작성은
- 권한:
admin - 동작: 소프트 삭제
- 권한: 인증 필요
- Body:
{"type":"like"|"dislike"} - 동작: 같은 타입 재요청 시 토글 off, 다른 타입 시 전환
- 예산 공개 추가 규칙:
- 반응 API는 기존 notices 반응 계약을 그대로 재사용
- 활성 범위 밖 budget 글 반응 처리는
404
운영 메모:
- 기존
notices테이블이 이미 운영 중이면scripts/migrate_notice_budget_board.py를 먼저 실행해budget_year,budget_month,BUDGETenum,ix_notices_budget_list인덱스를 추가합니다.
category:chat|info|qnastatus:pending|approved- reaction:
like|dislike - 댓글 길이:
1~1000
- 권한: 선택 인증
- 캐시:
free - Query:
category,querysort:recent|comments|likesminebooleanbookmarkedbooleanstatus(admin에서만 의미)viewpage,page_size|pageSize
- 가시성:
- 비로그인: 승인 글만
- 일반 로그인: 승인 글 + 본인 pending
- admin: 전체
- 권한: 인증 필요
- Body:
title2~200bodyrequiredcategoryrequired (chat|info|qna)summaryoptional
- 성공:
status=pending
- 권한: 선택 인증
- pending 가시성: admin/작성자만
- 권한: 작성자 또는
admin
- 권한:
admin - 동작: 소프트 삭제
- 권한:
admin
- 권한:
admin
- 권한: 인증 필요
- Body:
{"type":"like"|"dislike"}
- 권한: 인증 필요
- 성공 예시:
{
"bookmarked": true,
"bookmarkedCount": 3
}- 권한: 선택 인증
- 캐시:
free - Query:
order,page,page_size|pageSize
- 권한: 인증 필요
- Body:
body1~1000
- 권한:
admin
- 권한: 인증 필요
- 업로드: 파일/이미지 허용(
require_image=false)
- 권한: 선택 인증
- pending 게시글 첨부는 admin/작성자만 접근
- 임시 파일은
preview_token필요
status:pending | approved- reaction:
like | dislike competency:1~50pledge:1~180body: rich HTML 허용, 최대10000- 댓글 길이:
1~1000
- 권한: 선택 인증
- 캐시:
value_pick - Query:
query | qsort:recent | comments | likesminebooleanstatus(admin에서만 의미)viewpage,page_size|pageSize
- 가시성:
- 비로그인: 승인 글만
- 일반 로그인: 승인 글 + 본인 pending
- admin: 전체
- 권한: 인증 필요
- Body:
competencyrequired,1~50pledgerequired,1~180bodyoptional rich HTML, 최대10000
- 성공:
status=pending
- 권한: 선택 인증
- pending 가시성: admin/작성자만
- 동작: 조회 성공 시
views카운터를 best-effort로 증가
- 권한: 작성자 또는
admin - 본문 저장 시 업로드 canonical URL을 기준으로 정규화
- 권한:
admin - 동작: 소프트 삭제
- 권한:
admin
- 권한:
admin
- 권한: 인증 필요
- Body:
{"type":"like"|"dislike"} - 동작:
- 같은 타입 재요청 시 토글 off
- 다른 타입이면 기존 반응을 전환
- 권한: 선택 인증
- 캐시:
value_pick - Query:
order,page,page_size|pageSize - pending 글 댓글 가시성: admin/작성자만
- 권한: 인증 필요
- Body:
body1~1000 - pending 글 댓글 작성 권한: admin/작성자만
- 권한:
admin - 동작: 소프트 삭제 +
comments_count감소
- 권한: 인증 필요
- Content-Type:
multipart/form-data - 필드:
file - 업로드: 파일/이미지 허용(
require_image=false) - 응답: 업로드 공통 계약 (
url,canonicalUrl,preview_token)
- 권한: 선택 인증
- 정책:
- 승인 글에 연결된 파일은 공개 열람
- pending 글 연결 파일은 admin/작성자만 열람
- 아직 글 본문에 연결되지 않은 임시 파일은
preview_token필요
gradeGroup:lower|upperstatus:pending|approved
- 권한: 선택 인증
- 캐시:
club_recruit - Query:
gradeGroupq또는querysort:recent|deadlinestatus(admin의미)viewpage,page_size|pageSize
- 권한: 인증 필요
- Body:
clubName1~120field1~120gradeGroup(lower|upper)applyPeriod.start(또는applyStart) requiredapplyPeriod.endoptional, 시작일 이후applyLinkoptional, 길이<=500,http/httpsextraNote1~200bodyoptional<=20000posterUrloptional
- 권한: 선택 인증
- pending 가시성: admin/작성자만
- 권한: 작성자 또는 admin
- 권한:
admin
- 권한:
admin
- 권한:
admin
- 권한: 인증 필요
- 업로드: 이미지 전용(
require_image=true)
- 권한: 선택 인증
- 임시 파일은
preview_token필요
status:open|negotiating|matchedapprovalStatus:pending|approvedcontactLinks[].type:kakao|email|url|student_id|extra- 댓글 길이:
1~800
- 권한: 선택 인증
- 캐시:
subject_changes - Query:
grade(1|2|3)q또는querysubjectTagonlyMine,hideClosedbooleanstatus(pending|approved, admin 의미)viewpage,page_size|pageSize
- 권한: 인증 필요
- Body:
grade(1~3)classNameoptional<=20offeringSubject2~120requestingSubject2~120noteoptional<=1000contactLinksarray 최대 3statusoptional
- 성공:
approvalStatus=pending
- 권한: 인증 필요 (
@jwt_required) - pending 가시성: admin/작성자만
- 권한: 작성자 또는 admin
- 작성자 수정 시
approvalStatus가pending으로 리셋
- 권한: 작성자 또는 admin
- 권한:
admin
- 권한:
admin
- 권한: 작성자 또는 admin
- Body:
status=open|negotiating|matched
- 권한: 인증 필요
- 캐시:
subject_changes
- 권한: 인증 필요
- Body:
body1~800
- 권한: 댓글 작성자 또는 admin
status:pending|approved|rejectedstatusDerived:needs-support|waiting-answer|answeredcategory는 고정 한글 enum(14개 부서 포함)
- 권한: 선택 인증
- 캐시:
petitions - Query:
viewstatus(admin)approval(approved|unapproved|all, admin)statusDerivedcategoryqsort(recent|votes)page,page_size|pageSize
- 가시성:
- 비로그인: 승인 글만
- 로그인 일반: 승인 글 + 본인 글
- admin: 전체 상태
- 권한: 인증 필요
- Body:
title2~200summary1~200bodyrequired,<=MAX_PETITION_BODY(기본 10000)category고정 enum
- 성공:
status=pending
- 권한: 선택 인증
- 캐시:
petitions - 승인 전: 작성자/admin만 조회 가능
- 권한:
- admin
- 또는 작성자(현재 상태가
pending|rejected)
- 권한:
admin
- 권한:
admin
- 권한:
admin
- 권한: 인증 필요
- 조건: 승인된 청원만
- Body:
action=up|cancel(기본up) - 성공 예시:
{
"votes": 34,
"isVotedByMe": true,
"status": "needs-support"
}- 권한:
admin | student_council - Body:
contentrequired - 동작: 기존 답변 overwrite
approvalStatus:pending|approved- 파생
status:open|closed - 크레딧 원장:
survey_creditsavailable = base + earned - used
- 승인 보너스:
SURVEY_APPROVAL_GRANT(기본 30) - 응답 보상: 타인 설문 응답 시
earned +5
- 권한: 선택 인증
- 캐시:
surveys - Query:
viewstatus(pending|approved, admin)q|querysort:recent|quota-asc|responses-descmine=1hide=1(이미 응답한 설문 숨김)page,page_size|pageSize
- 권한: 선택 인증
- 캐시:
surveys - 승인 전: owner/admin만
- 권한: 인증 필요
- Body:
title2~200descriptionoptional<=1000formJson(또는form_json) array 최소 1개expiresAtoptional
- 권한: 인증 필요
- 현재 동작: 항상
405
- 권한:
admin - 동작: 승인 + 최초 1회 보너스 크레딧 지급
- 권한:
admin
- 권한: 인증 필요
- 조건:
- 설문 open
- 중복 응답 불가
- owner 크레딧 잔여 > 0
- Body:
answersrequired - 성공 예시:
{
"responseId": 100,
"creditsEarned": 5,
"creditsAvailable": 29,
"responseQuota": 60,
"responsesReceived": 1
}- 권한: owner/admin
- 캐시:
surveys
- 권한: owner/admin
- 캐시:
surveys
- 권한: 인증 필요
- 캐시:
surveys(TTL 20초)
- 생성 권한:
admin | student_council - 옵션 개수: 최소 2, 최대 8 (기본값 기준)
- 투표 보상:
VOTE_REWARD_CREDITS(기본 1)
- 권한: 선택 인증
- 캐시:
votes(TTL 20) - Query:
viewsort:recent|participation|deadlineqincludeClosed또는closedpage,page_size|pageSize
- 권한: 선택 인증
- 캐시:
votes(TTL 20)
- 권한:
admin | student_council - Body:
title2~120descriptionoptional<=1000closesAtoptional, 현재보다 미래optionsarray (id,text)
- 권한: 인증 필요
- Body:
optionIdrequired - 조건: open poll + 중복 투표 불가
- BOSPI는 공식 기록(
bospi_records)의 교복 착용자 수/전체 학생 수로 비율을 계산합니다. - 비율은 서버에서
(uniformedStudentCount / baselineStudentCount) * 100으로 계산하고 4자리 소수로 저장/응답합니다. - 사용자의 다음 지수 예측은 날짜가 없는
BospiPendingPrediction으로 저장됩니다. 새 공식 기록이 입력되면 대기 예측을 해당 기록 날짜의BospiPrediction으로 확정한 뒤 평가합니다. - 점수는
BospiUserScore집계 테이블에 저장되며, 예측 재평가 후 영향받은 사용자만 재계산합니다. BospiSettings,/settings,/criteria계약은 현재 코드 기준으로 사용하지 않습니다.
- 권한: 선택 인증
- 캐시:
bospi(TTL 20) - 성공
200주요 필드:
{
"settings": { "rewardPoints": 10 },
"records": [
{
"id": 1,
"date": "2026-05-10",
"ratio": 72.4138,
"baselineStudentCount": 290,
"uniformedStudentCount": 210,
"createdAt": "2026-05-10T01:00:00",
"updatedAt": "2026-05-10T01:00:00",
"enteredBy": { "id": 3, "name": "학생회", "role": "student_council" }
}
],
"comparisons": [{ "date": "2026-05-10", "outcome": "increase" }],
"predictionTargetDate": null,
"predictionOpen": true,
"myPrediction": {
"id": 4,
"targetDate": null,
"direction": "increase",
"pointsAwarded": 0,
"isCorrect": null,
"status": "pending",
"evaluatedAt": null
},
"myPredictions": [],
"myScore": 20,
"rankings": [
{
"rank": 1,
"userId": 7,
"nickname": "곰솔",
"totalScore": 20,
"correctCount": 2,
"incorrectCount": 1,
"nextPrediction": { "direction": "decrease", "status": "pending" },
"isCurrentUser": false
}
],
"canManage": false,
"today": "2026-05-10"
}- 권한: 인증 필요
- Body:
direction:increase|decrease(up|down도 서버에서 정규화)
- 조건:
- 공식 BOSPI 기록이 1개 이상 있어야 예측 가능
- 동일 사용자의 대기 예측은 새 행을 만들지 않고 방향만 갱신
- 성공:
- 신규 대기 예측
201 - 기존 대기 예측 갱신
200
- 신규 대기 예측
- 오류:
422: 방향 누락/오류, 아직 공식 기록 없음404: 사용자 없음
- 권한:
admin | student_council - Body:
date또는operationDate:YYYY-MM-DDbaselineStudentCount: 양의 정수uniformedStudentCount: 0 이상의 정수,baselineStudentCount이하
- 동작:
- 새 기록은 최신 공식 기록 날짜보다 이후 날짜만 허용합니다.
- 기존 날짜는 수정 가능하며, 해당 날짜와 다음 공식 기록의 예측 결과를 다시 평가합니다.
- 새 기록 입력 시 모든 대기 예측을 해당 날짜의 평가 대상 예측으로 이동합니다.
- 응답은
GET /api/community/bospi와 같은 상태 응답입니다.
- 오류:
422: 날짜/학생 수 유효성 오류 또는 최신 기록 이전 신규 날짜404: 사용자 없음500: 저장 실패
- 순위판은
1-1부터3-10까지 30개 반을 항상 반환합니다. - 점수 입력은 증감값이 아니라 특정 반의 최종 총점 snapshot입니다.
- 저장 테이블은
study_with_beomseo_score_updates이며 기존 행을 수정/삭제하지 않고 append-only로 이력을 누적합니다. effectiveAt이 현재 KST 시간 이하인 최신 기록만 일반 사용자 순위에 반영됩니다.- 같은 반에 공개 가능한 기록이 여러 개 있으면
effectiveAt desc,id desc기준으로 가장 최신 snapshot을 사용합니다. effectiveAt이 미래인 예약 기록은admin | student_council에게만pendingUpdates로 노출됩니다.- 응답 날짜시간(
serverNow,updatedThrough,lastPublishedAt,effectiveAt)은+09:00offset을 포함합니다. - 서버 저장값은 KST 기준 naive datetime이고, API 직렬화 시
+09:00offset을 붙여 프론트가 명시적인 시각으로 해석하게 합니다. - 이 순위판은 공개 시간이 지나면 즉시 결과가 달라져야 하므로 응답 캐시를 사용하지 않습니다.
- 쓰기 행에는 공통 요청 메타데이터 훅으로
ip_address,user_agent가 best-effort로 채워질 수 있습니다.
- 권한: 선택 인증
- 성공
200주요 필드:
{
"serverNow": "2026-05-19T22:00:00+09:00",
"updatedThrough": "2026-05-19T22:00:00+09:00",
"canManage": false,
"items": [
{
"classId": "2-7",
"grade": 2,
"classNumber": 7,
"label": "2-7",
"rank": 1,
"totalScore": 1650,
"lastPublishedAt": "2026-05-19T22:00:00+09:00"
}
],
"pendingUpdates": []
}- 순위 정렬:
totalScore desc, 학년, 반 번호 순서 - 동점: 같은 총점은 같은 표시 순위 공유
- 공개된 점수가 없는 반:
totalScore=0,lastPublishedAt=null - 관리자 응답:
canManage=true이고pendingUpdates[]에 미래 예약 점수 포함 updatedThrough: 현재 응답에 반영된 공개 snapshot 중 가장 늦은effectiveAtpendingUpdates[]:id,classId,grade,classNumber,label,totalScore,effectiveAt,createdAt,createdBy포함
- 권한:
admin | student_council - Body:
classId:1-1부터3-10totalScore: 정수0..1000000effectiveAt: ISO 날짜시간, timezone 없으면 KST로 해석
- 동작:
- 과거/현재
effectiveAt은 즉시 공개 점수로 반영됩니다. - 미래
effectiveAt은 예약 대기 점수로 저장됩니다. - 같은 반의 기존 기록은 삭제하지 않고 append-only 이력으로 보존합니다.
- 과거/현재
- 입력자 기록:
created_by_id: 현재 로그인 사용자 IDcreated_by_role: 요청 당시 사용자 역할 문자열
- 오류:
400: 요청 본문 없음 또는 JSON object가 아님401: 인증 누락/실패403:admin | student_council권한 없음422:classId,totalScore,effectiveAt유효성 오류500: DB 저장 실패
- 성공
201:
{
"id": 12,
"classId": "2-7",
"grade": 2,
"classNumber": 7,
"label": "2-7",
"totalScore": 1650,
"effectiveAt": "2026-05-19T22:00:00+09:00",
"createdAt": "2026-05-19T03:20:00",
"createdBy": {
"id": 3,
"nickname": "학생회",
"role": "student_council"
}
}status:searching|foundcategory:electronics|clothing|bag|wallet_card|stationery|etc- 생성 시 이미지 최소 1개 필수
- 권한: 선택 인증
- 캐시:
lost_found - Query:
statuscategoryq|querysort:recent|foundAt-desc|foundAt-ascviewpage,page_size|pageSize
- 권한: 없음
- 권한:
admin | student_council - Body:
title2~120description1~2000status,categoryfoundAtrequiredfoundLocation1~200storageLocation1~200images최소 1개
- 권한:
admin | student_council - Body:
status=searching|found
- 권한:
admin | student_council - 업로드: 이미지 전용
- 권한: 없음
- 임시 파일은
preview_token필요
- 권한: 없음
- 캐시:
lost_found
- 권한: 인증 필요
- Body:
body1~1000
- 권한:
admin
- 공개 읽기 경로는 익명 접근을 허용합니다.
GET /categories/{category_id}에는60 per minute제한이 연결되어 있습니다.- 선수 라인업/개인 순위 데이터는 snapshot에 포함되지 않으며, 별도
/players엔드포인트로 읽고 씁니다. liveEvents[].author는{ nickname }만 노출합니다.- active event는 최신
250개까지만 유지되고, 오래된 항목은 soft delete 됩니다. - 등록된 seed 카테고리는 기본
2026-spring-grade2-boys-soccer와 이전2026-spring-grade3-boys-soccer입니다. RATELIMIT_SPORTS_LEAGUE_STREAM_CONNECT, client/category 동시 연결 제한 helper는 현재 코드에 정의되어 있지만 stream route에는 직접 연결되지 않았습니다.
- 권한: 없음
- 반환:
defaultCategoryId,items[] items[]:id,title,seasonLabel,gradeLabel,sportLabel,scheduleWindowLabel,updatedAt,storageVersion- 용도: 프론트 스포츠리그 전환 UI와 기본 카테고리 진입점
- 동작:
- seed registry 순서로 카테고리 목록을 반환합니다.
- DB에 bootstrap된 row가 있으면 제목/학기/학년/종목/일정/버전/갱신 시각은 DB 값을 우선 사용합니다.
- 아직 bootstrap 전인 카테고리도 seed metadata로 목록에 노출될 수 있습니다.
응답 예시:
{
"defaultCategoryId": "2026-spring-grade2-boys-soccer",
"items": [
{
"id": "2026-spring-grade2-boys-soccer",
"title": "2026 1학기 2학년 남자 축구",
"seasonLabel": "2026 1학기",
"gradeLabel": "2학년",
"sportLabel": "남자 축구",
"scheduleWindowLabel": "2026.05.26 ~ 2026.06.08",
"updatedAt": "2026-05-26T02:45:00Z",
"storageVersion": "2026.05.26"
}
]
}- 권한: 없음
- 캐시:
sports_league(TTL 10초) - 반환:
category,teams,matches,rules,liveEvents,standingsOverrides,updatedAt,storageVersion
응답 예시:
{
"category": {
"id": "2026-spring-grade2-boys-soccer",
"title": "2026 1학기 2학년 남자 축구"
},
"teams": [],
"matches": [],
"rules": {
"format": [],
"points": [],
"ranking": [],
"notes": []
},
"liveEvents": [],
"standingsOverrides": {
"A": null,
"B": null
},
"updatedAt": "2026-05-26T02:45:00Z",
"storageVersion": "2026.05.26"
}- 권한: 없음
- 반환:
players,updatedAt - 정렬 규칙:
- 팀 group (
A→B) - 팀
displayOrder - 선수 이름
- 생성 시각
- 팀 group (
응답 예시:
{
"players": [
{
"id": "sports-player-3f2b6d8d7f5b4c37b8a2b0ef5d92e44f",
"categoryId": "2026-spring-grade2-boys-soccer",
"teamId": "g2-team-2-1-2-7",
"name": "김민준",
"goals": 2,
"assists": 1,
"createdAt": "2026-03-15T04:10:00Z",
"updatedAt": "2026-03-15T04:32:00Z"
}
],
"updatedAt": "2026-03-15T04:32:00Z"
}- 권한:
student_council | admin - Body:
namerequired,1~20자
- 제약:
team_id는 현재 카테고리에 속한 실제 반 팀(group=A|B)이어야 함
- 성공
201:player: 생성된 선수players: 정렬된 전체 라인업updatedAt: 전체 라인업 최신 갱신 시각
- 권한:
student_council | admin - 동작: 선수를 라인업에서 제거하고, 남은 전체 라인업을 다시 반환
- 권한:
student_council | admin - Body:
stat:goals | assistsdelta:-1 | 1
- 제약:
- 누적 값은 0 아래로 내려가지 않음
- 성공 응답:
player: 수정된 선수players: 정렬된 전체 라인업updatedAt: 전체 라인업 최신 갱신 시각
- 권한: 없음
- 형식:
text/event-stream - 헤더:
Cache-Control: no-cache, no-transformX-Accel-Buffering: no
- 이벤트:
retry: <ms>event: snapshotdata: <snapshot-json>
- 동작:
- 최초 연결 직후 현재 snapshot 1회 전송
- pub/sub 신호를 받으면 전체 snapshot을 다시 전송
- 유휴 시에도 최신
updatedAt을 비교하기 위해 최대 3초 간격으로 snapshot을 재검사 - heartbeat comment를 주기적으로 전송
- 권한:
student_council | admin - Body:
matchIdrequiredeventTyperequiredstatus,minute,message,subjectTeamId,scoreSnapshot,winnerTeamId
- 제약:
message최대 240자- 점수는 0 이상 정수
- 토너먼트 경기 동점 종료 시
winnerTeamId필수
- 권한:
student_council | admin - 동작: 기존 이벤트를 수정하고 match 상태를 최신 이벤트 기준으로 재계산
- 권한:
student_council | admin - 동작: soft delete 후 match 상태를 재계산
- 권한:
student_council | admin group_id:A | B- Body:
rows[]- 각 row:
teamId,rank,points,goalDifference,goalsFor,goalsAgainst,wins,draws,losses,note
- 동작: 자동 계산 순위 전체를 운영진 확정 순위로 대체
- 권한:
student_council | admin - 동작: 해당 조의 공식 override를 제거하고 자동 계산 순위로 복귀
- 권한:
admin - 조건:
phase가knockout또는final인 경기만 허용 - Body:
teamAIdrequiredteamBIdrequired
- 제약:
- 두 팀은 서로 달라야 함
- 둘 다 현재 카테고리의 팀이어야 함
- 동작: 토너먼트 placeholder를 실제 참가 팀으로 교체하며, 기존
winnerTeamId가 새 팀 조합과 맞지 않으면 비웁니다.
- 권한:
admin - 동작:
- 카테고리/팀/경기 seed를 upsert
- 기존 live event와 standings override는 유지
- category별
storageVersion을 seed 값으로 반영 - 최신 snapshot을
201과 함께 반환
- 운영 스크립트:
python scripts/bootstrap_sports_league.py --list: 등록 seed 목록만 출력python scripts/bootstrap_sports_league.py: 등록된 모든 스포츠리그 카테고리 bootstrappython scripts/bootstrap_sports_league.py --category-id <category_id>: 특정 카테고리만 bootstrap
- 반별 게시판 읽기는 잠금 해제된 반에 한해 허용됩니다.
- 글 작성은 반 비밀번호 확인 후 anonymous도 가능하고, 로그인 사용자는 계정 닉네임/역할을 그대로 사용합니다.
- 게시글 응답은
authorRole을 포함하며, anonymous 글은authorUserId=0으로 직렬화됩니다. - 본문은 plain text가 아니라 rich HTML이며, 저장 전에 field-trip 업로드 canonical URL로 정규화됩니다.
- 게시판 비밀번호 변경과 게시판 설명 수정은
admin전용입니다.
- 권한: 없음
- 반환:
items[]classId,label,postCount,isUnlocked,boardDescription
- 비고:
isUnlocked는 잠금 해제 쿠키와 반별 상태를 합쳐 계산됩니다.
- 권한: 없음
- Body:
passwordrequired,<=64
- 성공 시:
- HttpOnly unlock cookie 갱신
field_trip_csrf_token쿠키 발급
- 성공 응답 예시:
{
"classId": "3",
"isUnlocked": true
}- 권한: 해당 반 unlock 필요
- 반환:
items[]id,classId,authorUserId,authorRole,nickname,title,body,attachments,createdAt,updatedAt
- 권한: 해당 반 unlock 필요
- 반환: 단일 게시글 상세
- 비고:
- 본문은 rich HTML 그대로 반환됩니다.
- 첨부 URL은 FastAPI 업로드 경로를 기준으로 절대경로화됩니다.
- 권한: 해당 반 unlock 필요
- CSRF:
X-Field-Trip-CSRF필수 - 인증:
- 로그인 사용자: 선택
- 비로그인 사용자: 허용
- Body:
nicknameoptional, anonymous 작성 시 필요,<=20titlerequired,1~80bodyrequired, rich HTML 포함 가능,<=6000attachments[]optional, 최대 5개
- 작성자 규칙:
- 비로그인:
author_role='anonymous',author_user_id IS NULL, 응답은authorUserId=0 - 로그인: 계정 닉네임/역할 사용, 요청
nickname은 무시
- 비로그인:
- 권한: 해당 반 unlock 필요 + 인증 필요
- CSRF:
X-CSRF-TOKENX-Field-Trip-CSRF
- 수정 가능 주체:
admin,student_council- 또는 로그인한 원작성자
- 비고:
- anonymous 글은 원작성자 FK가 없으므로 운영진만 수정 가능
- anonymous 글 닉네임은 수정 시에도 기존 값을 유지
- 권한: 해당 반 unlock 필요 + 인증 필요
- CSRF:
X-CSRF-TOKENX-Field-Trip-CSRF
- 삭제 가능 주체:
admin,student_council- 또는 로그인한 원작성자
- 권한: 하나 이상의 반 unlock 필요
- CSRF:
X-Field-Trip-CSRF필수 - Content-Type:
multipart/form-data - 필드:
file - 성공 응답: 업로드 공통 계약
- 비고:
canonicalUrl은 본문 저장용 정규 URL- 미연결 임시 업로드는
preview_token이 붙은url로만 먼저 접근 가능
- 권한:
- 게시글/첨부에 연결된 파일: 해당 반 unlock 필요
- 아직 미연결 임시 파일:
preview_token필요
- 비고:
- 첨부 row가 없어도 게시글 본문에 canonical URL이 삽입돼 있으면 연결 파일로 간주합니다.
- 권한: 없음
- 반환:
items[]classId,label,totalScore
- 권한:
student_council | admin - Body:
delta:-5 | 5
- 제약:
- 점수는 0 미만 불가
- 점수는 10000 초과 불가
- 권한:
admin - Body:
passwordrequired,4~64
- 비고:
- 학생회는 더 이상 이 엔드포인트를 사용할 수 없습니다.
- 권한:
admin - Body:
boardDescriptionoptional,<=240
- 급식 메뉴 읽기 엔드포인트는 공개 접근을 허용합니다.
- 비공개 급식 의견 목록은
admin | student_council전용입니다. - 요청 경로는 MySQL에 저장된 급식 데이터만 읽고, NEIS 호출은 동기화 스크립트에서만 수행합니다.
- 읽기/평점 요청은
MEAL_RATING_COOKIE_NAME쿠키를 보장해 비로그인 브라우저도 날짜/카테고리별 1개의 평점을 유지합니다. - 알림 구독은 계정 단위가 아니라
installationId기준의 기기 단위 레코드입니다.
- 권한: 없음
- 응답:
itemmeta.datemeta.generatedAtmeta.timezone
- 비고:
- 오늘 급식이 없으면
isNoMeal=truesynthetic entry를 반환합니다. - 응답 전에 익명 평점 쿠키가 없으면 새로 발급합니다.
- 오늘 급식이 없으면
- 권한: 없음
- Query:
fromrequiredtorequired
- 응답:
items[]meta.frommeta.tometa.generatedAtmeta.timezonemeta.servicemeta.maxRangeDays
- 제약:
- 시작일은 종료일보다 늦을 수 없음
- 조회 가능 기간은
MEALS_MAX_RANGE_DAYS이내
- 비고:
- 저장된 급식이 없는 날짜도
isNoMeal=true항목으로 채워져 주말/휴일 gap을 프론트가 그대로 렌더링할 수 있습니다.
- 저장된 급식이 없는 날짜도
- 권한: 없음
- Body:
category:taste | anticipationscore:1 ~ 5
- 제약:
taste: 오늘(KST) 급식에만 허용anticipation: 오늘 또는 미래 급식에만 허용- 과거 날짜는
422 - 실제 급식 row가 없는 날짜는
404
- 비고:
- 동일 브라우저/사용자는
meal_date + category조합마다 1개 행만 유지하며 재평가 시 overwrite됩니다.
- 동일 브라우저/사용자는
성공 예시:
{
"date": "2026-03-22",
"ratings": {
"taste": {
"averageScore": 4.2,
"totalCount": 12,
"myScore": 5,
"distribution": []
},
"anticipation": {
"averageScore": null,
"totalCount": 0,
"myScore": null,
"distribution": []
}
}
}- 제품 의미: 공개 댓글이 아니라 비공개 급식 의견 전달함입니다.
GET권한:admin | student_councilrequire_role('student_council')의존성은admin을 항상 통과시키므로 두 역할 모두 조회할 수 있습니다.page기본값은1,pageSize기본값은50, 최대값은100입니다.order는asc | desc이며, 정렬 기준은created_at입니다.
GET성공 응답:
{
"items": [
{
"id": 1,
"mealDate": "2026-05-24",
"body": "오늘 메뉴 좋아요.",
"author": { "id": 7, "name": "학생", "role": "student" },
"createdAt": "2026-05-24T02:30:00Z",
"updatedAt": "2026-05-24T02:30:00Z"
}
],
"total": 1,
"page": 1,
"page_size": 50,
"pageSize": 50
}POST권한: 로그인 사용자POSTBody:
{
"body": "오늘 메뉴 좋아요."
}- 비고:
- 의견 본문은 plain text로 정규화하며 최대
1000자입니다. - 실제 급식 row가 없는 날짜에는 작성할 수 없습니다.
- 작성 시 서버가
ip_address,user_agent를 저장해 학생회/관리자 검토용 메타데이터를 남깁니다. - 일반 사용자는 제출 후 완료 안내만 보고 의견 목록은 조회하지 않습니다.
approval_status,approved_by_id,approved_at은 unused legacy DB 컬럼이며 응답 필드와 공개 정책에 사용하지 않습니다.- 승인 상태 변경 PATCH API는 제거되었습니다.
- 의견 본문은 plain text로 정규화하며 최대
- 권한: 없음
- Query:
installationIdrequired,1~64
- 응답:
item(null가능)
- 권한: 없음
- Body:
installationIdrequiredenabledboolean requirednotificationTimerequired,HH:MMtimezonerequired, IANA timezonefcmTokenrequired whenenabled=true
- 비고:
- installationId 기준으로 upsert합니다.
- 로그인 사용자가 있으면 소유자 FK를 연결할 수 있지만, 기본 식별자는 기기입니다.
- 권한: 없음
- Query:
installationIdrequired,1~64
- 성공:
204 No Content
category:books|electronics|fashion|hobby|ticket|etcstatus:selling|soldapprovalStatus:pending|approved
- 권한: 선택 인증
- 캐시:
gomsol_market - Query:
statuscategoryapproval(admin전용)q|querysort:recent|price-asc|price-descviewpage,page_size|pageSize
- 권한: 인증 필요 (
@jwt_required) - pending 글은 admin/작성자만
- 동작: 조회 시
views카운터 증가 (best-effort)
- 권한: 인증 필요
- Body:
title2~120description1~2000price정수>=0category,statusimages최소 1개contact(studentId|openChatUrl|extra) 중 최소 1개
- 권한:
admin
- 권한:
admin
- 권한: 작성자 또는 admin
- Body:
status=selling|sold
- 권한: 인증 필요
- 업로드: 이미지 전용
- 권한: 선택 인증
- 임시 파일은
preview_token필요
{
"error": "인증이 필요합니다.",
"error_code": "authorization_required"
}{
"error": "토큰이 만료되었습니다.",
"error_code": "token_expired"
}{
"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
"error_code": "rate_limit_exceeded",
"retry_after": 10
}{
"errors": [
"category는 school 또는 council 이어야 합니다."
]
}- 엔드포인트 커버리지:
rg -n "route\(" backend/routes+rg -n "@router" backend/fastapi_app/routes - 인증 설정 정합성:
backend/config.py와backend/fastapi_app/config.py의 JWT/field-trip CSRF 설정 - 페이지네이션 정합성:
backend/utils/pagination.py - 업로드 토큰 정합성:
backend/utils/files.py - 문서 계약은 Bearer 문구가 아닌 쿠키 JWT + CSRF 기준으로 유지
- 수학여행 익명 작성/
authorRole직렬화 정합성:backend/fastapi_app/services/field_trip.py,backend/models/field_trip.py - 요청 메타데이터 정합성:
backend/utils/request_metadata.py와backend/app.py의 hook 등록 확인