Compare commits

...

2 commits

Author SHA1 Message Date
유창욱
b654468fbb docs(index): 1.0.0 릴리스 산출물 기록
Some checks failed
verify / edge (push) Failing after 4s
verify / server (push) Failing after 3s
2026-08-18 14:06:41 +09:00
유창욱
9596d1bad6 release: 1.0.0 — 첫 정식 납품 버전
0.9.0 까지가 "감지하는 시스템"이었다면 1.0.0 은 "감지에 대응하는 시스템"이다. 그 차이를
만드는 것은 e00ad69 의 통보 경로이고, 이 커밋은 그것을 실제로 납품 가능한 형태로 만든다.

VERSION 0.9.0 -> 1.0.0

릴리스 규칙 위반 3건 수정 (rules/release-versioning.md)
- 산출물 파일명에 버전·날짜가 없었다. move-detect-cafe24.zip / 2_pi_install-<arch>.tar.gz
  는 같은 날 두 번 빌드하면 구분이 안 되고, 현장이 무엇을 돌리는지 파일명으로 답할 수
  없었다. 이제 move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip /
  move-detect-pi-<VERSION>-<YYYYMMDD>-<arch>-<abi>-<hailo>.tar.gz.
- 두 빌드 스크립트가 VERSION 파일이 없으면 "unknown" 으로 넘어갔다. 무버전 산출물은
  빌드머신을 떠나는 순간 커밋과 대조할 방법이 사라진다 — 이제 exit 1 로 거부한다.
- deploy/README.md 의 stale 파일명 표기 갱신.

납품 동봉물 (skill delivery-audit: "설명서 + 점검 리포트 동봉")
- CHANGELOG.md 신설. 업그레이드 절차와 알려진 제약(모델 미확보 3종)을 명시.
- 역할별 사용 설명서를 패키지에 동봉. 운영자/안전관리자/시스템관리자 3역할 14절 + FAQ 6항,
  실제 화면 캡처 8장을 base64 로 박은 단일 HTML(665KB). 폐쇄망에서 메일·USB 로 돌아다녀도
  도판이 깨지지 않아야 하므로 단일 파일이다.
  생성물은 커밋하지 않는다 — 소스(manual.json + shots/)만 두고 패키저가 매번 빌드한다.
  커밋된 사본은 화면이 바뀐 뒤에도 최신처럼 보이고, 구 UI 캡처가 남은 매뉴얼은 납품물
  자체의 결함이다.
- 감사 리포트를 '납품점검-리포트.md' 로 동봉. 무엇을 점검했고 무엇이 잔여인지 패키지가
  스스로 답하지 못하면, 인수인계 몇 달 뒤 그 답을 아는 사람이 남지 않는다.

README-UPLOAD 에 크론 안내 신설 (누락이었다)
  설치 안내서에 자동 실행 등록이 아예 없었다. 이 릴리스의 핵심인 통보가 크론 없이는 한 건도
  나가지 않는데, 운영자가 읽는 유일한 문서가 그 사실을 말하지 않았다.
  8번(알림 켜기: 크론 2개 + --dry-run 선행 + 경광등 우선) / 9번(설치 점검 /setup) 추가,
  마지막 체크리스트에 3항목 추가.

부수 수정
- 셸 스크립트에 CRLF 가 섞여 bash 가 여는 중괄호에서 죽었다(빌드 자체가 불가). LF 로 정규화.
  cafe24 스크립트에는 bash -n 가드가 이미 있어 이번에 실제로 잡았고, Pi 쪽에는 없어서
  그대로 지나갈 뻔했다 — tests/test_deploy_artifacts.py 에 CRLF·bash -n 가드 추가.

산출물
  dist/move-detect-cafe24-1.0.0-20260818.zip
  dist/move-detect-pi-1.0.0-20260818-aarch64-cp311-hailo8.tar.gz

검증: pytest 통과(skip 3 = 하드웨어·환경) · PHPUnit 224 (skip 0).
매뉴얼 실렌더 확인(목차 정합·도판 8장·콜아웃). 패키지 내부 검증: 버전 스탬프 1.0.0,
마이그레이션 8/8 동봉, config.php 는 CHANGEME 템플릿, tools/ 는 www 밖에만,
웹 루트 PHP 는 index.php/_preflight.php 뿐.
2026-08-18 14:04:28 +09:00
19 changed files with 677 additions and 10 deletions

5
.gitignore vendored
View file

@ -33,3 +33,8 @@ Thumbs.db
# Pi provisioning (real device secret) — never commit
deploy/device.env
# 생성되는 사용 설명서. 소스는 server/deploy/manual/{manual.json,shots/} 이고,
# 납품 패키저가 빌드할 때마다 다시 만든다. 커밋된 사본을 두면 화면이 바뀐 뒤에도 그것이
# 최신처럼 보이는데, 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다.
server/deploy/manual/사용설명서.html

93
CHANGELOG.md Normal file
View file

@ -0,0 +1,93 @@
# CHANGELOG — move-detect (작업장 안전 AI CCTV)
버전 표기는 저장소 루트 `VERSION` 파일이 단일 출처입니다.
납품 산출물 파일명에도 같은 버전이 들어갑니다:
`move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip` / `move-detect-pi-<VERSION>-<YYYYMMDD>-<arch>.tar.gz`
---
## 1.0.0 — 2026-08-18 · 첫 정식 납품 버전
0.9.0 까지가 "감지하는 시스템"이었다면, 1.0.0 은 **"감지에 대응하는 시스템"** 입니다.
그 차이를 만드는 것이 아래 첫 항목입니다.
### 추가 — 통보 (이 버전의 이유)
이전까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐습니다. 새벽 3시에
넘어진 작업자는 DB 에 행 하나를 남기고 아침을 맞았습니다.
- **현장 경광등·부저** (엣지 GPIO). 서버도 인터넷도 거치지 않고 감지한 노드가 직접 울립니다.
정전·회선 장애로 업링크가 끊긴 순간은 오히려 위험이 높아지는 때이므로, 어떤 현장이든
이것부터 설정합니다.
- **메일 · 웹훅** 채널과 1분 주기 디스패처. 알림에는 현장 이미지를 싣지 않고 링크만 보냅니다
(스냅샷은 개인정보이고, 메일링 리스트로 나가는 순간 인증 프록시의 통제를 벗어납니다).
- **미확인 경보 에스컬레이션**. 위급/높음이 정해진 시간 동안 확인되지 않으면 그 사실 자체가
사건이 되어 상위 담당자에게 통보됩니다. "아무도 보지 않았다"가 처음으로 기록에 남습니다.
### 추가 — 조용한 실패 드러내기
- **카메라 화각 변경 · 가림 · 조도 저하 감지.** 위험구역 판정은 카메라가 어제와 같은 곳을
본다는 전제 위에 서 있는데, 청소하다 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서
시스템은 계속 초록불이었습니다.
- **저장 공간 부족 경고.** 디스크가 차면 이미지 저장만 조용히 실패하고 화면은 멀쩡해 보입니다.
- **설정 지연 표시가 실제로 동작**하게 됐습니다. 지표는 있었지만 그것을 켜는 사건이 없었습니다.
### 추가 — 관리 화면 (그전에는 DB 직접 수정이 유일한 방법)
- 감지 임계값 편집 (허용 범위·단위·설명을 화면이 강제)
- 통보 경로 설정
- 정비 중 알람 억제 — 사유 필수, 최대 8시간, 자동 만료.
**감지와 기록은 그대로 계속되고 통보만 보류**되며, 보류했다는 사실도 기록에 남습니다.
- 위험성평가 연계 · 교대 인수인계 · 설비 성능 추이 · 설치 점검(`/setup`)
### 추가 — 증거와 규정
- **감사로그 위·변조 탐지** (해시체인). 수정·삭제·우회삽입이 전부 드러납니다.
- **사고조사 패키지 1클릭** — 전후 타임라인, 통보 이력, 위험요인, 조치 이력, 서명란.
인쇄해서 그대로 제출할 수 있습니다.
- 현장별 현황 (본사 계정)
### 변경 — 화면 (UI/UX 점검 21건)
- **감지 스트림이 다시 시간순이 됐습니다.** 확인 여부가 순서를 바꾸던 탓에 사고 재구성이
불가능했고, 확인하면 그 행이 사라지며 아래가 밀려 올라와 다른 이벤트를 잘못 확인할 수
있었습니다. 확인은 이제 제자리에서 처리됩니다.
- 이벤트 상세에서 바로 확인/오탐 판정 · 구역 설정에 카메라 선택기 · 날짜로 조회 ·
경보음 음소거(새 위급 오면 자동 해제) · 일괄 확인(위급 제외) · 키보드 조작
- 표본이 부족한 수치는 비율 대신 원시 숫자만 보여줍니다. 6건→8건을 "▲33% 악화"로 칠하면
안전회의에서 반박당하고, 그 순간 그 화면 전체가 신뢰를 잃습니다.
### 도구 (`move_detect_app/tools/`, 웹에서 접근 불가)
| 도구 | 용도 |
|------|------|
| `dispatch_alerts.php` | 알림 발송 (크론 1분) · `--dry-run` 지원 |
| `maintain.php` | 보존기간 정리 (크론 일 1회) |
| `backup.php` | DB 덤프 + 사진 아카이브 + 복구 안내 |
| `verify_audit_chain.php` | 감사로그 무결성 검증 |
| `seed_demo.php` | 시연·시험용 데모 데이터 (운영 DB 실행 금지) |
### 업그레이드 (0.9.x → 1.0.0)
`migrations/2026-08-09-safety-platform.sql` 을 적용합니다. **파일 안의 9번 블록은 DB 종류에
따라 문법이 다릅니다** — MySQL 8.0.16+ / MariaDB / 제약 없음 세 경우의 안내가 파일에
적혀 있으니 `SELECT VERSION();` 으로 확인 후 맞는 것을 실행하세요.
적용 후 `config.php` 에 `public_base_url`, `timezone`, `escalate_after_seconds` 를 추가하고
(템플릿 참조), 자동실행(크론) 2개를 등록해야 알림이 동작합니다 — README-UPLOAD.md 8번.
### 알려진 제약
- **보호구 미착용 · 중장비 근접 · 화재 감지**: 감지기 배선과 이벤트 계약은 준비되어 있으나
**추론 모델이 확보되지 않아 기본 비활성**입니다. 라이선스 조건을 만족하는 가중치와 Hailo
컴파일이 선행되어야 합니다. 켜져 있지 않다는 사실을 화면에서도 숨기지 않습니다.
- 실장비 검증 잔여 항목은 `docs/delivery-audit.md` 의 "OFF-HW 잔여" 절을 참조하세요.
---
## 0.9.0 — 2026-08-08
납품 감사(43건) 대응. CSP nonce 도입, 보안 헤더 PHP 이관, /logout CSRF, 계정 잠금 응답에서
사용자명 열거 제거. 엣지 큐 flush 상한, 타이머 env 전수 검증, 프레임 스킵 시 미블러 얼굴
유출 차단, systemd 워치독. PDO 통합 테스트 복구, 라우트 테이블 대조, 산출물↔소스 패리티
검사, CI 신설. 납품물에 version/git_commit/built_at 스탬프.

View file

@ -1 +1 @@
0.9.0
1.0.0

View file

@ -81,15 +81,17 @@ deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311
Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled
wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step.
Set `MAKE_TARBALL=1` if you would rather transfer one
`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file.
Set `MAKE_TARBALL=1` if you would rather transfer one file. The tarball is named
`dist/move-detect-pi-<VERSION>-<YYYYMMDD>-aarch64-cp311-hailo8.tar.gz` — version and
build date so a site can say exactly what it is running, and the arch/ABI/Hailo triple
because a hailo8 and a hailo8l bundle are byte-different and crash-loop on the wrong board.
## 2. Transfer + install (on the air-gapped Pi)
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
```bash
# tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz
# tarball variant only (MAKE_TARBALL=1): tar xzf move-detect-pi-<VERSION>-<날짜>-aarch64-cp311-hailo8.tar.gz
cd 2_pi_install
sudo ./install_offline.sh
```

View file

@ -158,7 +158,14 @@ SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes
# Provenance. Without this, a bundle is unidentifiable once it leaves the build machine: the
# SHA256SUMS inside a STALE bundle is perfectly self-consistent, so integrity checks pass while
# the contents are months behind the source. version+commit is how a site reports what it runs.
BUNDLE_VERSION="$(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
# VERSION is mandatory (rules/release-versioning.md). An "unknown" bundle cannot be matched to
# a commit after it leaves the build machine, which is exactly when the question gets asked.
[ -f "$REPO_ROOT/VERSION" ] || {
echo "ERROR: $REPO_ROOT/VERSION is missing. Refusing to build an unversioned bundle." >&2
exit 1
}
BUNDLE_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")"
[ -n "$BUNDLE_VERSION" ] || { echo "ERROR: VERSION is empty." >&2; exit 1; }
GIT_REV="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
GIT_DIRTY=""
(cd "$REPO_ROOT" && git diff --quiet HEAD -- edge deploy 2>/dev/null) || GIT_DIRTY=" (uncommitted changes)"
@ -208,7 +215,7 @@ if [ "${MAKE_TARBALL:-0}" != "1" ]; then
fi
# Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were
# otherwise identically named, so the wrong one could be shipped to a board and crash-loop.
TARBALL="$OUT_DIR/2_pi_install-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz"
TARBALL="$OUT_DIR/move-detect-pi-${BUNDLE_VERSION}-$(date -u +%Y%m%d)-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz"
tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install"
echo ">> DONE: $TARBALL"
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."

View file

@ -1,5 +1,21 @@
# INDEX — 작업 기록 (완료 결과만)
- **2026-08-18** **VERSION 1.0.0 — 첫 정식 납품 버전** — `9596d1b`.
산출물: `dist/move-detect-cafe24-1.0.0-20260818.zip` ·
`dist/move-detect-pi-1.0.0-20260818-aarch64-cp311-hailo8.tar.gz`
(둘 다 스탬프 version 1.0.0 / git_commit 9596d1b).
릴리스 규칙 위반 3건 시정: 산출물 파일명에 버전·날짜가 없던 것, VERSION 부재 시
'unknown' 으로 빌드되던 것(이제 exit 1), 문서의 stale 파일명.
납품 동봉물 확충: CHANGELOG.md 신설 · 역할별 사용 설명서(3역할 14절 + FAQ 6항, 실제 화면
캡처 8장 base64 임베드 단일 HTML 665KB — 소스는 `server/deploy/manual/`, 생성물은
커밋하지 않고 패키저가 매번 빌드) · 감사 리포트를 '납품점검-리포트.md' 로 동봉.
**README-UPLOAD 에 크론 안내 신설** — 설치 안내서에 자동 실행 등록이 아예 없어 이번
릴리스의 핵심인 통보가 한 건도 나가지 않을 뻔했다(8·9번 절 + 체크리스트 3항목).
부수: 셸 스크립트 CRLF 로 bash 가 죽던 것 정규화 + Pi 쪽에 없던 CRLF·`bash -n` 가드 추가.
검증: pytest 통과(skip 3) · PHPUnit 224 (skip 0) · 매뉴얼 실렌더 · 패키지 내부 검증
(문서 4종 동봉, 매뉴얼 외부 참조 0·임베드 이미지 8, 마이그레이션 8/8, config.php 는
CHANGEME 템플릿, tools/ 는 www 밖, 웹 루트 PHP 는 2개, Pi 휠 11 + HEF 1 + SHA256SUMS).
- **2026-08-18** 납품 재감사 — `7daee0b`. 확장된 표면(라우트 17·표 6종·CLI 도구 5·엣지 모듈 4·
통보 발송·감사 해시체인)을 8개 관점으로 재감사, 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 ·
MEDIUM 4 · LOW 2). 최대 발견은 **신규 감지 유형이 ingest 검증기에만 빠져 카메라 무결성

View file

@ -177,10 +177,61 @@ VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기',
---
## 8. 마지막 확인
## 8. 알림 켜기 (자동 실행 등록) — **이걸 안 하면 알림이 안 갑니다**
여기까지만 하면 시스템은 **감지는 하지만 아무에게도 알리지 않습니다.** 화면을 보고 있는
사람에게만 보입니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다.
카페24 관리자 페이지 → **자동실행관리(크론)** 에서 아래 두 개를 등록하세요.
| 주기 | 실행할 명령 | 하는 일 |
|------|-------------|---------|
| 1분마다 | `/usr/bin/php /home/<계정>/move_detect_app/tools/dispatch_alerts.php` | 알림 발송 · 미확인 경보 상위 통보 · 실패 재시도 |
| 하루 1회 (새벽) | `/usr/bin/php /home/<계정>/move_detect_app/tools/maintain.php` | 사진 보존기간 정리 · 기록 용량 관리 |
`<계정>` 자리에는 카페24 FTP 계정명을 넣습니다. 경로가 헷갈리면 FTP 로 접속했을 때
`move_detect_app` 폴더가 보이는 그 위치입니다.
### 처음 켤 때는 반드시 시험 발송부터
잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는 두 번 다시 이 기능을 켜지 않게 됩니다.
크론에 넣기 전에 아래를 한 번 실행해 **누구에게 무엇이 갈지만** 확인하세요 (아무것도 보내지
않습니다).
```bash
php /home/<계정>/move_detect_app/tools/dispatch_alerts.php --dry-run
```
### 알림 경로 설정
로그인 후 상단 **[시스템 설정]** → **통보 경로** 에서 등록합니다.
- **현장 경광등**을 가장 먼저 설정하세요. 라즈베리파이가 직접 울리므로 **인터넷이 끊겨도
동작하는 유일한 경로**입니다. 정전·회선 장애 때 오히려 위험이 높아지는 시간대에 유일하게
살아 있는 알림입니다.
- 메일·웹훅은 그 위에 얹는 보조 수단이고, 위 크론이 돌아야 나갑니다.
- `config.php` 의 `public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지
않는 링크가 담긴 알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다.
---
## 9. 설치 점검 화면
로그인 후 **`https://내도메인/move_detect/setup`** 을 엽니다.
각 단계는 실제 상태를 조회해서 표시하므로, 체크가 켜졌다면 그 단계는 **정말로 동작하고
있는 것**입니다. 장치 등록 · 장치 통신 · 카메라 감지 · 위험구역 · 통보 경로 순서대로
확인하세요.
---
## 10. 마지막 확인
- [ ] `https://내도메인/move_detect/login` 이 열린다
- [ ] admin 으로 로그인되고 대시보드가 뜬다
- [ ] `schema.sql` 을 서버에서 지웠다
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)
- [ ] 자동실행(크론) 2개가 등록되어 있다 — **없으면 알림이 나가지 않습니다** (8번)
- [ ] `[시스템 설정] → 통보 경로` 에 최소 1개가 '사용' 상태다 (현장 경광등 우선)
- [ ] `/setup` 화면의 모든 단계에 체크가 켜져 있다 (9번)

View file

@ -11,7 +11,8 @@
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
# server/deploy/build_cafe24_package.sh
#
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip
# Output: dist/1_cafe24_upload/ and (MAKE_ZIP=1)
# dist/move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip
#
# dist/1_cafe24_upload/
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
@ -45,7 +46,21 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SERVER="$REPO_ROOT/server"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/1_cafe24_upload"
ZIP="$OUT_DIR/move-detect-cafe24.zip"
# Release identity. VERSION is the single source (rules/release-versioning.md) and its ABSENCE
# is fatal, not a fallback: a package named "unknown" is one nobody can match to a commit, and
# the whole point of stamping provenance is that a stale bundle is self-consistent and therefore
# undetectable from the inside.
[ -f "$REPO_ROOT/VERSION" ] || {
echo "ERROR: $REPO_ROOT/VERSION is missing. Refusing to build an unversioned package." >&2
exit 1
}
RELEASE_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")"
[ -n "$RELEASE_VERSION" ] || { echo "ERROR: VERSION is empty." >&2; exit 1; }
BUILD_DAY="$(date -u +%Y%m%d)"
# {제품}-{VERSION}-{YYYYMMDD}.zip — a date alone cannot separate two builds of the same day,
# and a version alone cannot say which day's rebuild a site is running.
ZIP="$OUT_DIR/move-detect-cafe24-${RELEASE_VERSION}-${BUILD_DAY}.zip"
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
@ -162,7 +177,7 @@ cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
# package once shipped. Keep it OUT of the web root: it names the build, not a public asset.
{
echo "package: move-detect-cafe24"
echo "version: $(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
echo "version: $RELEASE_VERSION"
echo "git_commit: $(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$PRIV/BUILD-INFO.txt"
@ -170,6 +185,21 @@ cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
# 납품 동봉 문서. 스테이지 루트(= FTP 최상위)에 두지만 www/ 밖이라 웹으로는 열리지 않는다.
# 감사 리포트를 함께 싣는 이유: "무엇을 점검했고 무엇이 잔여인가"를 패키지 자체가 답하지
# 못하면, 인수인계 몇 달 뒤에 그 답을 아는 사람이 아무도 남지 않는다.
cp "$REPO_ROOT/CHANGELOG.md" "$STAGE/CHANGELOG.md"
cp "$REPO_ROOT/docs/delivery-audit.md" "$STAGE/납품점검-리포트.md"
# 역할별 사용 설명서. 화면이 바뀌면 캡처와 함께 다시 생성해야 하므로, 여기서 빌드까지 돌려
# 소스(manual.json + shots/)와 산출물이 어긋난 채 납품되는 경우를 없앤다. 구 UI 캡처가 남은
# 매뉴얼은 납품물 자체의 결함이다.
if command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then
PY_BIN="$(command -v python3 || command -v python)"
"$PY_BIN" "$SERVER/deploy/manual/build_manual.py" --out "$STAGE/사용설명서.html" || { echo "ERROR: 사용설명서 생성 실패 — 캡처(shots/)가 빠졌는지 확인하세요." >&2; exit 1; }
else
echo "ERROR: python not found; 사용설명서를 생성할 수 없습니다." >&2
exit 1
fi
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served

View file

@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""역할별 사용자 설명서 생성기 — 캡처를 base64 로 박아 넣은 단일 HTML.
WHY A SINGLE FILE
납품처는 폐쇄망이고, 매뉴얼은 메일로 돌거나 USB 로 옮겨 다닌다. 이미지가 옆 폴더에
있으면 그 순간 도판 없는 문서가 된다. 전부 한 파일에 넣으면 어디로 옮겨도 온전하다.
WHY REGENERATE INSTEAD OF EDITING HTML
화면이 바뀌면 도판도 바뀌어야 한다. 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다
(rules: delivery-audit '매뉴얼 캡처 실물 정합'). 내용은 manual.json, 도판은 shots/ 에
두고 이 스크립트로 다시 뽑는 구조라, 화면이 바뀌면 캡처만 갈아끼우고 재실행하면 된다.
사용법:
python server/deploy/manual/build_manual.py # -> 사용설명서.html
python server/deploy/manual/build_manual.py --out X # 출력 경로 지정
"""
from __future__ import annotations
import argparse
import base64
import datetime
import html
import json
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
REPO = HERE.parents[2]
CALLOUT_LABEL = {"info": "참고", "warn": "주의", "danger": "경고"}
def data_uri(path: pathlib.Path) -> str:
return "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii")
CSS = """
:root{
--bg:#eef1f5; --card:#fff; --line:#dde3ea; --line-2:#eef2f6;
--ink:#131824; --ink-2:#464e5c; --ink-3:#646c7a;
--accent:#0b6b7a; --accent-wash:#e2f0f3; --accent-line:#b9d9df;
--warn-bg:#fbf6e6; --warn-line:#efe0b4; --warn-ink:#7a5606;
--crit-bg:#fdf2f2; --crit-line:#f2cccc; --crit-ink:#a51d1d;
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
--mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);
line-height:1.75;-webkit-font-smoothing:antialiased}
.wrap{max-width:880px;margin:0 auto;padding:0 1.2rem 5rem}
/* 표지 */
.cover{padding:3.5rem 0 2rem;border-bottom:3px solid var(--ink);margin-bottom:2rem}
.cover .kind{margin:0;font-size:.72rem;font-weight:700;letter-spacing:.14em;
text-transform:uppercase;color:var(--ink-3)}
.cover h1{margin:.5rem 0 .3rem;font-size:2.1rem;letter-spacing:-.02em}
.cover .sub{margin:0;color:var(--ink-2);font-size:1rem}
.cover .meta{margin:1.2rem 0 0;font-family:var(--mono);font-size:.76rem;color:var(--ink-3)}
/* 목차 */
.toc{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:1.1rem 1.3rem;margin-bottom:2.5rem}
.toc h2{margin:0 0 .6rem;font-size:.72rem;font-weight:700;letter-spacing:.08em;
text-transform:uppercase;color:var(--ink-3)}
.toc ul{margin:.2rem 0 0;padding-left:1.1rem;list-style:none}
.toc li{margin:.15rem 0}
.toc li::before{content:"·";color:var(--ink-3);margin-right:.5rem}
.toc a{color:var(--accent);text-decoration:none}
.toc a:hover{text-decoration:underline;text-underline-offset:3px}
.toc-role{margin:.9rem 0 0;font-weight:700;color:var(--ink);font-size:.9rem}
.toc-role:first-of-type{margin-top:0}
/* 역할 */
.role{margin:0 0 3rem}
.role-head{padding:1rem 1.2rem;background:var(--accent-wash);
border:1px solid var(--accent-line);border-radius:10px;margin-bottom:1.5rem}
.role-head h2{margin:0;font-size:1.25rem;color:var(--accent)}
.role-head p{margin:.35rem 0 0;font-size:.9rem;color:var(--ink-2)}
section.step{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:1.3rem 1.4rem 1.5rem;margin-bottom:1rem}
section.step h3{margin:0 0 .8rem;font-size:1.05rem;letter-spacing:-.01em}
section.step p{margin:0 0 .7rem;font-size:.94rem;color:var(--ink-2)}
section.step p:last-child{margin-bottom:0}
section.step strong{color:var(--ink)}
figure{margin:0 0 1.1rem}
figure img{width:100%;height:auto;display:block;border:1px solid var(--line);border-radius:8px}
figcaption{margin-top:.35rem;font-size:.74rem;color:var(--ink-3)}
.callout{margin:1rem 0 0;padding:.75rem .9rem;border-radius:8px;font-size:.9rem;
border:1px solid var(--line);background:var(--line-2);color:var(--ink-2)}
.callout .tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em;
margin-right:.45rem;text-transform:uppercase}
.callout.warn{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn-ink)}
.callout.danger{background:var(--crit-bg);border-color:var(--crit-line);color:var(--crit-ink)}
.callout.info{background:var(--accent-wash);border-color:var(--accent-line);color:var(--accent)}
code{font-family:var(--mono);font-size:.86em;background:var(--line-2);
padding:.1em .35em;border-radius:4px}
kbd{font-family:var(--mono);font-size:.8em;background:var(--card);border:1px solid var(--line);
border-bottom-width:2px;border-radius:4px;padding:.05em .4em}
.chip{display:inline-block;font-size:.72em;font-weight:700;color:#fff;background:var(--crit-ink);
padding:.1em .5em;border-radius:999px}
/* FAQ */
.faq{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.3rem 1.4rem}
.faq h2{margin:0 0 1rem;font-size:1.15rem}
.faq dt{font-weight:700;margin-top:1rem;color:var(--ink)}
.faq dt:first-of-type{margin-top:0}
.faq dd{margin:.3rem 0 0;font-size:.93rem;color:var(--ink-2)}
footer.doc{margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--line);
font-size:.76rem;color:var(--ink-3);line-height:1.7}
@media print{
body{background:#fff}
.wrap{max-width:none;padding:0}
section.step,.toc,.faq,.role-head{break-inside:avoid;box-shadow:none}
.toc{display:none}
}
"""
def render(doc: dict, shots: pathlib.Path, version: str) -> str:
out: list[str] = []
a = out.append
today = datetime.date.today().isoformat()
a("<!doctype html>")
a('<html lang="ko"><head><meta charset="utf-8">')
a('<meta name="viewport" content="width=device-width, initial-scale=1">')
a(f"<title>{html.escape(doc['title'])}</title>")
a(f"<style>{CSS}</style>")
a("</head><body><div class=\"wrap\">")
# 표지
a('<header class="cover">')
a('<p class="kind">사용 설명서</p>')
a(f"<h1>{html.escape(doc['title'])}</h1>")
a(f"<p class=\"sub\">{html.escape(doc['subtitle'])}</p>")
a(f'<p class="meta">버전 {html.escape(version)} · {today} 기준 화면</p>')
a("</header>")
# 목차. 항목 번호는 제목이 이미 달고 있으므로(1., 2., …) 목록 자체는 번호를 매기지
# 않는다. 역할 이름을 <li> 로 끼워 넣고 list-style 로만 숨기면 그 줄이 카운터를 먹어
# 본문 번호와 목차 번호가 어긋난다(7 다음이 9로 뛴다).
a('<nav class="toc"><h2>목차</h2>')
n = 1
for role in doc["roles"]:
a(f'<p class="toc-role">{html.escape(role["name"])}</p><ul>')
for sec in role["sections"]:
anchor = f"sec-{n}"
n += 1
sec["_anchor"] = anchor
a(f'<li><a href="#{anchor}">{html.escape(sec["heading"])}</a></li>')
a("</ul>")
a('<p class="toc-role">자주 묻는 질문</p>')
a("</nav>")
# 본문
for role in doc["roles"]:
a('<div class="role">')
a('<div class="role-head">')
a(f"<h2>{html.escape(role['name'])}</h2>")
a(f"<p>{html.escape(role['summary'])}</p>")
a("</div>")
for sec in role["sections"]:
a(f'<section class="step" id="{sec["_anchor"]}">')
a(f"<h3>{html.escape(sec['heading'])}</h3>")
shot = sec.get("shot")
if shot:
p = shots / shot
if not p.is_file():
# 도판 누락을 조용히 넘기면 구멍 난 매뉴얼이 납품된다.
raise SystemExit(f"ERROR: 캡처가 없습니다: {p}")
a("<figure>")
a(f'<img alt="{html.escape(sec["heading"])} 화면" src="{data_uri(p)}">')
a(f"<figcaption>{html.escape(sec['heading'])} — 실제 화면</figcaption>")
a("</figure>")
for para in sec["body"]:
a(f"<p>{para}</p>") # 본문은 의도된 인라인 마크업을 허용한다
co = sec.get("callout")
if co:
label = CALLOUT_LABEL.get(co["kind"], "참고")
a(f'<div class="callout {co["kind"]}">'
f'<span class="tag">{label}</span>{co["text"]}</div>')
a("</section>")
a("</div>")
# FAQ
a('<div class="faq"><h2>자주 묻는 질문</h2><dl>')
for item in doc["faq"]:
a(f"<dt>{html.escape(item['q'])}</dt><dd>{item['a']}</dd>")
a("</dl></div>")
a('<footer class="doc">')
a(f"이 문서는 버전 {html.escape(version)} 화면을 기준으로 작성되었습니다. "
"화면이 바뀌면 캡처와 함께 다시 생성됩니다.<br>"
"설치·업그레이드 절차는 <code>README-UPLOAD.md</code>, "
"변경 내역은 <code>CHANGELOG.md</code>, "
"점검 결과는 <code>납품점검-리포트.md</code> 를 참조하세요.")
a("</footer>")
a("</div></body></html>")
return "\n".join(out)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=str(HERE / "사용설명서.html"))
ap.add_argument("--shots", default=str(HERE / "shots"))
args = ap.parse_args()
version_file = REPO / "VERSION"
if not version_file.is_file():
raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 매뉴얼은 만들지 않습니다.")
version = version_file.read_text(encoding="utf-8").strip()
doc = json.loads((HERE / "manual.json").read_text(encoding="utf-8"))
out = pathlib.Path(args.out)
out.write_text(render(doc, pathlib.Path(args.shots), version), encoding="utf-8")
size_kb = out.stat().st_size / 1024
print(f"{out} ({size_kb:.0f} KB, 단일 파일 · 이미지 포함)", file=sys.stderr)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,202 @@
{
"title": "AI SAFETY CCTV 사용 설명서",
"subtitle": "작업장 안전 관제 · 역할별 안내",
"roles": [
{
"id": "operator",
"name": "운영자 (관제 담당)",
"summary": "화면을 보며 감지된 사건을 확인하고 판정합니다. 하루 대부분의 시간을 [이벤트] 화면에서 보냅니다.",
"sections": [
{
"heading": "1. 감지 스트림 읽는 법",
"shot": "events.jpg",
"body": [
"목록은 <strong>항상 최근 순서</strong>입니다. 확인 여부와 무관하게 시간순으로만 정렬되므로, 사고가 났을 때 '그 시각 전후에 무슨 일이 있었나'를 그대로 따라 읽을 수 있습니다.",
"각 줄의 왼쪽은 <strong>증거 사진</strong>입니다. 사진을 눌러도, 사건 이름을 눌러도 상세 화면으로 갑니다.",
"<span class=\"chip\">확인 필요</span> 표시가 붙은 줄이 아직 아무도 처리하지 않은 사건입니다. 심각도가 높을수록 표시가 진합니다.",
"왼쪽 <strong>장치·카메라</strong> 목록은 '지금 무엇이 보고 있는가'를 말합니다. 빨갛게 바뀌면 그 장치가 신호를 보내지 않는 것이고, 그 카메라는 감시 공백입니다."
],
"callout": {
"kind": "warn",
"text": "장치가 <strong>오프라인</strong>이면 그 카메라는 아무것도 감지하지 못합니다. 화면이 조용한 것과 카메라가 죽은 것은 다릅니다 — 목록의 색을 먼저 보세요."
}
},
{
"heading": "2. 확인 · 오탐 판정",
"body": [
"<strong>확인</strong>은 '내가 봤고 처리했다'는 뜻입니다. 누르면 그 줄이 제자리에서 <em>확인됨(이름)</em>으로 바뀝니다. 목록에서 사라지지 않으므로, 방금 무엇을 처리했는지 눈으로 확인할 수 있습니다.",
"<strong>오탐</strong>은 '감지가 틀렸다'는 뜻입니다. 한 번 누르면 <em>오탐 확정?</em>으로 바뀌고, 4초 안에 한 번 더 눌러야 처리됩니다. 되돌릴 수 없고 카메라별 오탐율 통계에 반영되기 때문입니다.",
"확인과 오탐은 모두 <strong>기록에 남습니다</strong>(누가, 언제). 취소 기능은 일부러 넣지 않았습니다 — 안전 기록을 조용히 되돌릴 수 있으면 그 기록의 가치가 사라집니다."
]
},
{
"heading": "3. 밀린 사건 처리 · 키보드 조작",
"body": [
"<strong>미확인만</strong> 탭으로 바꾸면 처리할 것만 남습니다. 순서는 그대로이므로 사건 사이의 관계가 흐트러지지 않습니다.",
"<strong>일괄 확인</strong> 버튼은 화면에 보이는 미확인 사건을 한 번에 처리합니다. 단 <strong>위급은 제외</strong>됩니다 — 넘어짐은 반드시 한 건씩 눈으로 봐야 하기 때문입니다.",
"키보드: <kbd>J</kbd>/<kbd>K</kbd> 위아래 이동 · <kbd>A</kbd> 확인 · <kbd>F</kbd> 오탐 · <kbd>Enter</kbd> 상세 열기."
]
},
{
"heading": "4. 사건 상세 — 무슨 일이 있었는지 보기",
"shot": "event-detail.jpg",
"body": [
"증거 사진 3장이 <strong>이전 → 직전 → 발생 시점</strong> 순으로 놓입니다. 결과만이 아니라 사건이 어떻게 진행됐는지를 봅니다.",
"얼굴은 촬영 시점에 자동으로 흐리게 처리되어 저장됩니다. 원본 얼굴은 어디에도 남지 않습니다.",
"이 화면에서 바로 <strong>확인 처리</strong>·<strong>오탐</strong>을 누를 수 있습니다. 목록으로 돌아가 그 줄을 다시 찾을 필요가 없습니다.",
"오른쪽 <strong>개선 조치 기록</strong>에 조치를 남기면, 리포트가 조치 전후의 감지 밀도를 자동으로 비교해 줍니다."
]
},
{
"heading": "5. 특정 날짜 찾아보기",
"body": [
"왼쪽 <strong>날짜 조회</strong>에 날짜를 넣고 [조회]를 누르면 그날 하루만 봅니다. 사고 조사에서 '지난주 화요일 오후'를 찾을 때 씁니다.",
"조회 중에도 실시간 감시는 계속 돌아갑니다. 화면 위 배너의 [실시간 스트림으로]를 누르면 돌아옵니다."
]
},
{
"heading": "6. 경보음",
"body": [
"미확인 <strong>위급</strong> 사건이 있으면 경보음이 반복됩니다.",
"[경보음 끄기]로 잠시 끌 수 있지만, <strong>새 위급 사건이 오면 자동으로 다시 켜집니다.</strong> 밀린 사건 때문에 끈 것이 다음 사고를 놓치는 이유가 되지 않게 하기 위해서입니다.",
"음소거는 이 화면에서만, 이번 접속에만 적용됩니다. 새로고침하면 다시 켜집니다."
]
}
]
},
{
"id": "manager",
"name": "안전관리자",
"summary": "주간 안전회의 자료를 만들고, 사고가 나면 조사 자료를 제출하고, 위험구역과 위험성평가를 관리합니다.",
"sections": [
{
"heading": "7. 안전 리포트 — 주간 회의 자료",
"shot": "report.jpg",
"body": [
"상단 네 숫자가 그 기간의 요약입니다. 각 숫자 아래 <strong>이전 기간 대비</strong> 화살표가 붙어 추세를 함께 보여줍니다.",
"<strong>니어미스 : 사고</strong> 비율은 '사고 전 신호가 먼저 잡히고 있는가'를 봅니다. 니어미스가 많다는 것은 예방이 작동하고 있다는 뜻입니다.",
"[인쇄] 버튼을 누르면 화면 장식이 빠지고 A4 한 장으로 정리됩니다. 그대로 회의 자료로 씁니다.",
"<strong>표본이 적은 수치는 비율 대신 원시 숫자만</strong> 보여줍니다. 6건이 8건이 된 것을 '33% 악화'라고 적으면 회의에서 반박당하고, 그 순간 이 자료 전체가 신뢰를 잃기 때문입니다."
]
},
{
"heading": "8. 사고조사 패키지",
"shot": "package.jpg",
"body": [
"사건 상세 화면의 [사고조사 패키지 →]를 누르면 만들어집니다.",
"한 장에 들어가는 것: 사건 개요와 <strong>대응 소요 시간</strong>, 증거 사진, <strong>같은 카메라의 전후 30분 타임라인</strong>, 누구에게 언제 알림이 갔는지, 해당 위험요인과 통제 대책, 그 카메라의 개선 조치 이력, 그리고 서명란.",
"[인쇄 · PDF 저장]으로 그대로 제출할 수 있습니다.",
"사고 다음 날 이 자료를 손으로 모을 시간이 있는 사람은 없습니다. 그래서 한 번에 나오게 만들었습니다."
]
},
{
"heading": "9. 위험구역 그리기",
"shot": "zones.jpg",
"body": [
"① 장치와 카메라를 고릅니다. 고르면 그 카메라의 최근 화면과 저장된 구역이 자동으로 뜹니다.",
"② 등급을 정하고 화면을 클릭해 점을 3개 이상 찍습니다. <strong>경고</strong>는 진입 시 알림, <strong>제한</strong>은 진입 자체가 위반, <strong>협착</strong>은 끼임 위험 구역입니다.",
"③ 필요하면 점유 규칙을 넣습니다. 최소 인원(2인 1조 강제) · 최대 인원(정원 초과) · 체류 상한.",
"[이 구역 저장]을 누르면 다음 설정 동기화 때 장비로 내려갑니다."
],
"callout": {
"kind": "warn",
"text": "<strong>카메라를 건드리면 구역을 다시 그려야 합니다.</strong> 각도가 바뀌면 구역이 엉뚱한 바닥을 가리키게 됩니다. 시스템이 화각 변경을 감지하면 <em>카메라 화각 변경</em> 사건으로 알려주니, 그 사건이 뜨면 반드시 이 화면에서 확인하세요."
}
},
{
"heading": "10. 안전 관리 — 인수인계 · 정비 억제 · 위험성평가",
"shot": "compliance.jpg",
"body": [
"<strong>교대 인수인계</strong>: 교대 시작 때 [인수 확인]을 누르면 '누가 어떤 미처리 건을 넘겨받았는지'가 기록됩니다. 숫자는 시스템이 직접 세므로 실제보다 적게 적힐 수 없습니다.",
"<strong>정비 중 알람 억제</strong>: 정비·점검처럼 계획된 작업 때 알림만 잠시 멈춥니다. 사유는 필수이고 최대 8시간이며 시간이 지나면 자동 해제됩니다.",
"<strong>위험성평가 연계</strong>: 각 감지 유형이 어떤 위험요인을 대신 보고 있는지 기록합니다. 사고조사 패키지에 이 내용이 함께 실립니다.",
"<strong>감시 설비 성능 추이</strong>: 장치가 '지금 살아 있는가'가 아니라 '점점 나빠지고 있는가'를 봅니다. 열화로 죽는 장비는 멈추기 며칠 전부터 프레임률이 흘러내립니다."
],
"callout": {
"kind": "info",
"text": "억제 중에도 <strong>감지와 기록은 그대로 계속됩니다.</strong> 알림만 보류되고, 보류했다는 사실도 기록에 남습니다 — 나중에 그 시간대에 무슨 일이 있었는지 반드시 볼 수 있어야 하기 때문입니다."
}
}
]
},
{
"id": "admin",
"name": "시스템 관리자",
"summary": "설치·설정·알림 경로를 담당합니다. 평소에는 볼 일이 없고, 설치할 때와 무언가 바꿀 때만 봅니다.",
"sections": [
{
"heading": "11. 설치 점검",
"shot": "setup.jpg",
"body": [
"[시스템 설정] 화면 상단의 <strong>설치 점검</strong> 링크, 또는 주소창에 <code>/setup</code>.",
"각 단계는 <strong>실제 상태를 조회해서</strong> 표시됩니다. 체크가 켜졌다면 그 단계는 정말로 동작하고 있는 것입니다.",
"설치 직후 반드시 한 번 열어보세요. 화면이 멀쩡히 뜨는데 아무것도 감시하지 않는 상태가 이 제품에서 가장 위험한 구간입니다."
],
"callout": {
"kind": "danger",
"text": "<strong>⑤ 통보 경로</strong>가 비어 있으면 이 시스템은 감지 기록 장치일 뿐입니다. 감지되어도 화면을 보고 있는 사람에게만 전달됩니다."
}
},
{
"heading": "12. 통보 경로 설정",
"shot": "admin.jpg",
"body": [
"<strong>현장 경광등을 가장 먼저 설정하세요.</strong> 라즈베리파이가 직접 울리므로 인터넷이나 서버가 끊겨도 동작하는 유일한 경로입니다. 정전·회선 장애는 오히려 위험이 높아지는 때입니다.",
"메일·웹훅은 그 위에 얹는 보조 수단이며, 서버의 자동 실행(크론)이 등록되어 있어야 나갑니다.",
"<strong>단계</strong>를 1 이상으로 두면 에스컬레이션 채널이 됩니다. 사건이 정해진 시간 동안 확인되지 않을 때만 울리므로, 상급자에게 가는 경로로 씁니다.",
"알림에는 현장 사진이 들어가지 않습니다. 링크만 보내고, 사진은 로그인한 사람만 볼 수 있습니다."
],
"callout": {
"kind": "warn",
"text": "채널을 처음 켤 때는 <strong>반드시 시험 발송(<code>--dry-run</code>)부터</strong> 하세요. 잘못된 수신자로 첫 발송을 하면 그 현장에서는 두 번 다시 이 기능을 켜지 않게 됩니다. 방법은 설치 안내서(README-UPLOAD.md) 8번에 있습니다."
}
},
{
"heading": "13. 감지 임계값 조정",
"body": [
"같은 [시스템 설정] 화면 아래쪽입니다. 장치를 고르면 조정 가능한 값들이 설명과 함께 나옵니다.",
"값을 바꾸면 그 장치의 설정 버전이 올라가고, 다음 동기화(최대 수 분) 때 적용됩니다. 적용됐는지는 [이벤트] 화면 왼쪽 장치 목록의 <strong>설정 지연</strong> 표시가 사라지는 것으로 확인합니다.",
"허용 범위를 벗어난 값은 저장되지 않습니다. 장비가 기동할 때 거부하는 값이라 미리 막습니다.",
"언제 조정하나: 특정 카메라의 오탐이 계속되면 [이벤트] 화면 왼쪽 아래 <strong>오탐율</strong> 패널을 먼저 보세요. 30% 이상인 카메라·유형이 재조정 후보입니다.",
"모든 변경은 누가 언제 바꿨는지 기록에 남습니다."
]
},
{
"heading": "14. 정기 점검 항목",
"body": [
"<strong>매일</strong>: [이벤트] 화면 왼쪽 장치 목록이 전부 초록인지. 빨간 장치는 감시 공백입니다.",
"<strong>매주</strong>: [안전 리포트]를 인쇄해 회의 자료로. 오탐율이 오르고 있으면 임계값 조정을 검토합니다.",
"<strong>매월</strong>: 저장 공간 경고가 뜨지 않는지. 뜨면 사진 보존기간을 줄이거나 용량을 늘려야 합니다 — 그대로 두면 새 사건의 증거 사진이 저장되지 않습니다.",
"<strong>분기</strong>: 백업(<code>tools/backup.php</code>)을 받아 안전한 곳에 보관. 감사 기록 무결성 검증(<code>tools/verify_audit_chain.php</code>)도 함께 실행합니다."
]
}
]
}
],
"faq": [
{
"q": "화면이 조용한데 정상인가요?",
"a": "왼쪽 장치 목록이 전부 초록이면 정상입니다. 빨간 장치가 있으면 그 카메라는 아무것도 보지 못하고 있는 것이므로, 조용한 것이 아니라 <strong>안 보이는 것</strong>입니다."
},
{
"q": "알림이 안 옵니다.",
"a": "① <code>/setup</code> 화면의 ⑤ 통보 경로에 체크가 켜져 있는지 ② 자동 실행(크론)이 등록되어 있는지 ③ 채널의 '최소 심각도'가 너무 높지 않은지 순서로 확인하세요. ②가 가장 흔한 원인입니다."
},
{
"q": "잘못 확인 처리했습니다. 되돌릴 수 있나요?",
"a": "화면에서는 되돌릴 수 없습니다. 안전 기록을 조용히 되돌릴 수 있으면 그 기록을 근거로 쓸 수 없기 때문입니다. 잘못 처리한 사건은 그대로 두고, 필요하면 개선 조치 기록에 경위를 남기세요."
},
{
"q": "오탐이 너무 많습니다.",
"a": "[이벤트] 화면 왼쪽 아래 <strong>오탐율</strong> 패널에서 어떤 카메라·유형이 문제인지 먼저 확인하세요. 표본이 적으면 비율 대신 원시 숫자만 표시됩니다. 30% 이상인 항목이 조정 대상이고, 조정은 [시스템 설정]의 감지 임계값에서 합니다."
},
{
"q": "작업자 얼굴이 찍히는 게 걱정됩니다.",
"a": "얼굴은 촬영하는 순간 흐리게 처리되어 저장됩니다. 원본은 어디에도 남지 않습니다. 사진은 로그인한 사람만 볼 수 있고, 정해진 보존기간이 지나면 자동으로 삭제됩니다. 알림 메일에는 사진이 들어가지 않습니다."
},
{
"q": "정비 중인데 알람이 계속 울립니다.",
"a": "[안전 관리] → 정비 중 알람 억제에서 대상과 기간, 사유를 넣으세요. 알림만 멈추고 감지·기록은 계속되므로 나중에 그 시간대를 조사할 수 있습니다."
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -322,3 +322,37 @@ def test_offline_installer_verifies_integrity_before_install():
assert verify_at < sh.index("install --no-index") # before installing wheels
# A missing SHA256SUMS must be FATAL (refuse), not a warn-and-proceed that installs blind.
assert "refusing to install unverified code" in sh
# ---------------------------------------------------------------------------
# 2026-08-18: 셸 스크립트 줄바꿈/문법 가드.
# Windows 빌드머신에서 편집하다 CRLF 가 섞이면 bash 가 `{\r` 에서 죽는다 — 그러면 납품
# 산출물을 아예 만들 수 없다. cafe24 빌드 스크립트에는 이미 같은 가드가 있었고, Pi 쪽에는
# 없어서 실제로 이 사고가 났다.
# ---------------------------------------------------------------------------
import subprocess
SHELL_SCRIPTS = [SETUP_ONLINE, INSTALL_OFFLINE, BUILD_OFFLINE]
REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
# Git Bash 는 Windows 백슬래시 경로를 받으면 구분자를 먹어버린다. 저장소 상대 POSIX 경로를
# cwd=REPO_ROOT 로 넘긴다 (test_cafe24_package.py 가 이미 쓰는 방식).
SHELL_SCRIPTS_REL = [
"deploy/setup_online.sh",
"deploy/bundle/install_offline.sh",
"deploy/bundle/build_offline_bundle.sh",
]
def test_shell_scripts_are_lf_only():
for path in SHELL_SCRIPTS:
raw = open(path, "rb").read()
assert bytes([13, 10]) not in raw, (
f"{os.path.basename(path)} 에 CRLF 가 있습니다. bash 가 여는 중괄호에서 죽습니다 — "
"LF 로 저장하세요."
)
def test_shell_scripts_are_syntactically_valid_bash():
for rel in SHELL_SCRIPTS_REL:
r = subprocess.run(["bash", "-n", rel], cwd=REPO_ROOT, capture_output=True, text=True)
assert r.returncode == 0, "bash -n " + rel + " 실패:" + chr(10) + r.stderr