🏛️ 지부 보고 시스템 독립 배포
서울지역본부 산하 38개 지부 간부가 보고를 올리면 본부장이 열람하고 지시사항을 다는
모바일 반응형 업무 시스템. 보고에서 출발해 업무·대장·조직점검·지부포털·자료실·회의자료 RAG·구글 캘린더까지
붙었고, app.py 한 파일이 7,293줄 · 라우트 163개인 현재 가장 큰 앱이다.
접근
http://192.168.0.10:5065admin / 본부사무처 개인계정(staff) / 지부(branch).
비밀번호는 소스가 아니라 .env·DB에 둔다/dashboard, 지부는 /portal — 역할이 착지점을 가른다기능 지도
- 보고 — 일반·현안·교섭 분류로 작성(
/input), 지부별 4탭 열람(/view/branch/<id>), 본부장 지시사항 댓글, 첨부 업로드, 현안묶음으로 여러 보고 그룹화. 교섭보고는 회차·장소·노측/사측 교섭위원·차기교섭까지 따로 받는다 - 지부 정보 — 지부장·전임간부 이력(임기·현장복귀일), 현장간부·대의원 명단, 분회형(
extended) 지부 3곳은 분회·그룹·직가입과 교섭상대까지 별도 구조로 관리. 원본은 엑셀 8시트 한 장이고 관리 페이지에서 통째로 import 한다 - 대시보드·캘린더 — 내 업무·할일·다가오는 일정·안 읽은 메모를
GET /api/dashboard한 번에 모으고, 캘린더는 구글·업무·하위업무·교섭·지부(단협만료·지부장 임기만료)·포털 6개 소스를 겹쳐 본다 - 업무리스트·메모보고 — 업무 → 하위업무 배정(드래그앤드롭), D-day 배지, 업무별 첨부·의견 / 메모 수신함·읽음·답변
- 대장관리 — 직인(본부·선관위)·위임장·본부공문 대장. 번호 자동채번, 파일 첨부·다운로드
- 조직점검 — 점검표를 만들어 셀을 클릭해 바로 입력(
contenteditable, Enter 저장·Tab 이동), 열 순서 변경, 열별 합계, 텍스트·Word·Excel 내보내기, 구글시트 URL로 표 가져오기(미리보기에서 지부명 열을 자동 추정) - 지부포털(
/portal) — 질문게시판(질문자가 답변 항목을 직접 정의), 계획서·보고서 HWP 제출과 본부 접수, 양식 다운로드. 질문이 등록되면 텔레그램 채널로 알린다 - 자료실(
/library) — 규약·규정집 DOCX를 문서 → 장 → 조항 트리로 색인해 조문 단위로 연다 - 회의자료(
/meeting) — NAS의 HWP·PDF를 파싱·구조화·벡터색인한 뒤 근거를 달아 답하는 RAG 챗 - 베타(
beta/) — 단체공문 생성·PDF 일괄변환, 위임장 hwpx 일괄생성, 의견 게시판, 6.17 평가. 폴더째 들어내도 본체가 도는 자립형 모듈로 짰다
권한 — 신원은 세션만 믿는다
- 인증은 개별 라우트가 아니라 전역
@app.before_request가 건다. 미로그인은 페이지/login리다이렉트·API 401, 지부 계정이 업무 URL에 닿으면 페이지 403·API 403. 공개 경로만auth_login.py의 화이트리스트로 뚫는다 - 경계는 셋 — 지부는 포털만, staff는 관리 페이지(
/admin·/api/admin/*)만 막고 나머지는 admin과 같다, admin은 전체. 그래서 가드도require_admin()(진짜 관리자)과require_office()(admin+staff)로 갈라 둔다 - 로그인 판정 순서는 admin → staff → 지부다. 지부 비밀번호가 공용이라 지부 분기를 마지막에 두지 않으면 다른 계정을 삼킨다
- 신원이 필요한 API는 반드시
current_user_id()를 거친다. staff면 세션 값을 강제하므로?user_id=·body의sender_id를 위조해도 무시된다 — 메모·업무 8곳에 적용했다. admin에만 남은 「누구로 볼지」 선택기는 개인계정 전환이 끝나면 지운다 - 같은 이유로 포털 쓰기 API는
_portal_effective_branch_id()를 거친다. 읽기는 이미 세션 기준이었는데 쓰기가 요청에 실려온branch_id를 그대로 믿어 다른 지부 명의로 등록할 수 있었다. 지부 계정은 요청 값을 버리고 세션 지부로 고정하되, 본부의 대리 등록은 남겼다 /api/users가SELECT *였다 —password_hash가 딸려 나가지 않도록 컬럼을 명시로 바꿨다
CSS가 두 갈래인 이유
- 2026-08-05에 INSPINIA v5 디자인을 이관하며 주색을 청록으로 바꿨다. 근거는 취향이 아니라
inspinia-dist원본 CSS에서 실측한 값이고, 간격도 뒤섞인 10·14·16·20·22px를 20px 배수로 통일했다 - 여백이 많아 보이던 원인은 폰트가 아니라 카드 개수였다. 글자를 줄여도 위젯 높이는 11%밖에 안 줄었다 — 대시보드를 카드 6장·헤더 6개에서 카드 3장·헤더 1개로 접고 점선 분할로 나눴다
- 전 페이지 이관은 템플릿을 안 고치고 공용 클래스를 다시 칠하는 방식으로 했다.
.btn·.form-*·.ur-card가 이미 수백 곳에 깔려 있어 「이관 호환 레이어」가 그것들을 덮는다 - 문제는
style.css를 지부포털이 함께 쓰고 있었다는 것이다.:root를 건드리면 포털까지 청록이 된다. 이관 내내 발목을 잡던 이 제약을portal-legacy.css라는 동결 사본을 떠서 끊었다. 관리자는style.css, 포털은 동결 사본 — 다시 합치지 않는 것이 목적이다. 분리 직후style.css의 하드코딩 파랑 131곳 → 0, 중복:root3개 → 1개로 정리했다 - 한글에서 깨지는 자리는 토큰 레이어에서 미리 막았다 —
text-transform: uppercase는 한글에 무효,letter-spacing은 음절을 흩고, 고정height+낮은line-height는 받침을 자른다. 사이드바 항목만 원본과 다르게 줄높이를 올린 것도 14px 한글이 17.5px 줄상자에서 세로로 잘리기 때문이다 - 원본의
--theme-focus-ring-width: 0은 가져오지 않았다. 키보드 포커스가 안 보이는 접근성 회귀라:focus-visible을 되살렸다 - 로그인 화면이 바인드마운트된
inspinia-dist에서 884KB를 불러 쓰고 있었다. 그 자산은 git에 없어 마운트가 빠지면 로그인 화면이 통째로 깨진다 — 토큰 레이어로 다시 그려 의존을 끊었다
회의자료 RAG · Gemini
/mnt/nas/temp를 컨테이너에 읽기전용으로 물려 HWP·HWPX·PDF를 스캔하고,
본문과 첨부를 pair_key로 짝짓는다/data/chroma)에 넣는다gemini-embedding-001 고정 — text-embedding-004는 404가 나 검색이 0건이 됐다.
모델을 바꾸면 기존 문서는 벡터가 비므로 강제 재색인이 필요하다MEETING_BATCH_DELAY(기본 2.5초)로 간격을 벌린다google-auth가 이미 깔려 있어
의존성 추가도 이미지 리빌드도 없었다. 키 파일은 data/secrets/에 두고 업로드 UI는 만들지 않았다 —
시크릿을 HTTP로 왕복시키지 않는다. 구글이 죽어도 업무 마감·교섭 일정은 보이도록 실패는 warnings[]로 삼킨다배포·운영
union-report (5065:5000).
gunicorn은 --workers 1 --timeout 300 --reload — SQLite 경쟁조건을 피하려 워커를 1개로 묶었다rhwp가 폰트를 이름이 정확히 맞아야 렌더링해서
apt에 없는 Noto Sans KR을 빌드 때 따로 내려받는다/app(코드 전체) · /data(DB·업로드·chroma) ·
NAS temp(ro) · INSPINIA dist(ro) · 공용 config.py(ro)--reload로 자동. DB 스키마 변경은 재시작(init_db() 재실행),
requirements.txt는 반드시 리빌드 — entrypoint가 의존성이 있으면 pip install을 건너뛴다/app이 통째로 바인드마운트라 이미지에 COPY된 코드는 런타임에 안 쓰인다.
「리빌드했는데 그대로」·「재시작했는데 라이브러리가 없다」가 전부 여기서 나온다/data/union_report.db(테이블 44개, 비공개).
init_db()가 CREATE TABLE IF NOT EXISTS+ALTER TABLE로 매 기동 때 스키마를 맞춘다/opt/docker/apps/union-report(자체 git 저장소, 운영 브랜치 boxy-redesign) ·
문서 PROJECT_STATUS.md·docs/ 9건결정 로그에서
- 워커가 열 번 죽었다.
--workers 1이라 그때마다 서비스 전면 중단이었는데, 원인은 우리 코드가 아니라 gunicorn 22의 예외 포맷 버그였다 — 비정규 HTTP 요청에TypeError가 나고 그게 에러 처리를 뚫고 나간다. 외부 공개 포트라 스캐너가 방아쇠였다. 23.0.0으로 올린 뒤HTTP/0.9를 실제로 던져 워커 PID가 유지되는 것까지 확인했다 .dockerignore가 없어 이미지에 DB·업로드·시크릿이 구워지고 있었다. 런타임엔/app이 호스트로 덮이니 쓰이지도 않으면서 레이어에만 남는 상태였다. 추가 후 이미지 내/app222MB → 21MB.rhwp바이너리는 데이터가 아니라 기능 의존성이라 일부러 남겼다- 안 쓰는 테이블 15개를 지웠다. 없어진 기능의 잔재와 구버전 채팅 테이블을 코드 참조 0건 확인 후 제거 —
59개 → 44개, VACUUM 후 11MB → 2.6MB. 지우기 전 DB를 통째로 백업해 뒀고, 재기동 시
init_db()가 삭제된 테이블을 되살리지 않는 것까지 확인했다 app.secret_key를 소스에서 뺐다. env → 파일 → 생성 순으로 읽고, 없으면/data/secrets/에 만들어 재사용하므로 재시작해도 세션이 유지된다. 남은 것은ADMIN_PASSWORD폴백 제거인데,.env를 넣지 않은 채 폴백만 지우면 admin 로그인이 막히므로 같은 커밋에서 처리해야 한다- 템플릿의
getCurrentUser()를 localStorage에서/api/me로 옮기는 일은 지금 하면 손해라고 판단해 미뤘다 — admin 경로는 어차피 localStorage 폴백이 남아 페이지마다 왕복만 하나 는다. admin 대행 보기가 사라진 뒤에야 실익이 생긴다 app.py는 7,293줄·라우트 163개·함수 263개 단일 파일이고 가장 긴 함수init_db()하나가 690줄이다.beta/처럼 기능별로 쪼갤 여지가 있다는 것이 점검 결론이고, 아직 하지 않았다- 정식 보안 대책(HTTPS·역할 DB·신뢰 IP)은 설계만 확정돼 있고 현재 로그인은 베타용이다. 문서에 한때 「URL·API 무인증 개방」이라고 적혀 있었으나 사실과 달라 정정했다 — 전역 가드는 동작한다
※ sandbox 허브와 별개로 배포되는 독립 앱입니다. 이 페이지는 명세만 표시하며,
실제 보고 작성·열람은 위 주소에서 이뤄집니다. 계정·토큰·서비스 계정 키는 .env와
data/secrets/에만 두고 코드·문서·이 카드에 적지 않습니다.