Compare commits
2 commits
e625d482ba
...
b654468fbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b654468fbb | ||
|
|
9596d1bad6 |
5
.gitignore
vendored
|
|
@ -33,3 +33,8 @@ Thumbs.db
|
||||||
|
|
||||||
# Pi provisioning (real device secret) — never commit
|
# Pi provisioning (real device secret) — never commit
|
||||||
deploy/device.env
|
deploy/device.env
|
||||||
|
|
||||||
|
# 생성되는 사용 설명서. 소스는 server/deploy/manual/{manual.json,shots/} 이고,
|
||||||
|
# 납품 패키저가 빌드할 때마다 다시 만든다. 커밋된 사본을 두면 화면이 바뀐 뒤에도 그것이
|
||||||
|
# 최신처럼 보이는데, 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다.
|
||||||
|
server/deploy/manual/사용설명서.html
|
||||||
|
|
|
||||||
93
CHANGELOG.md
Normal 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 스탬프.
|
||||||
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.9.0
|
1.0.0
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step.
|
||||||
Set `MAKE_TARBALL=1` if you would rather transfer one
|
Set `MAKE_TARBALL=1` if you would rather transfer one file. The tarball is named
|
||||||
`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file.
|
`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)
|
## 2. Transfer + install (on the air-gapped Pi)
|
||||||
|
|
||||||
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
|
Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then:
|
||||||
|
|
||||||
```bash
|
```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
|
cd 2_pi_install
|
||||||
sudo ./install_offline.sh
|
sudo ./install_offline.sh
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
# 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.
|
# 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_REV="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
GIT_DIRTY=""
|
GIT_DIRTY=""
|
||||||
(cd "$REPO_ROOT" && git diff --quiet HEAD -- edge deploy 2>/dev/null) || GIT_DIRTY=" (uncommitted changes)"
|
(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
|
fi
|
||||||
# Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were
|
# 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.
|
# 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"
|
tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install"
|
||||||
echo ">> DONE: $TARBALL"
|
echo ">> DONE: $TARBALL"
|
||||||
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."
|
echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi."
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,21 @@
|
||||||
# INDEX — 작업 기록 (완료 결과만)
|
# 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·
|
- **2026-08-18** 납품 재감사 — `7daee0b`. 확장된 표면(라우트 17·표 6종·CLI 도구 5·엣지 모듈 4·
|
||||||
통보 발송·감사 해시체인)을 8개 관점으로 재감사, 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 ·
|
통보 발송·감사 해시체인)을 8개 관점으로 재감사, 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 ·
|
||||||
MEDIUM 4 · LOW 2). 최대 발견은 **신규 감지 유형이 ingest 검증기에만 빠져 카메라 무결성
|
MEDIUM 4 · LOW 2). 최대 발견은 **신규 감지 유형이 ingest 검증기에만 빠져 카메라 무결성
|
||||||
|
|
|
||||||
|
|
@ -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` 이 열린다
|
- [ ] `https://내도메인/move_detect/login` 이 열린다
|
||||||
- [ ] admin 으로 로그인되고 대시보드가 뜬다
|
- [ ] admin 으로 로그인되고 대시보드가 뜬다
|
||||||
- [ ] `schema.sql` 을 서버에서 지웠다
|
- [ ] `schema.sql` 을 서버에서 지웠다
|
||||||
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
|
- [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다
|
||||||
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)
|
- [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것)
|
||||||
|
- [ ] 자동실행(크론) 2개가 등록되어 있다 — **없으면 알림이 나가지 않습니다** (8번)
|
||||||
|
- [ ] `[시스템 설정] → 통보 경로` 에 최소 1개가 '사용' 상태다 (현장 경광등 우선)
|
||||||
|
- [ ] `/setup` 화면의 모든 단계에 체크가 켜져 있다 (9번)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
|
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
|
||||||
# server/deploy/build_cafe24_package.sh
|
# 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/
|
# dist/1_cafe24_upload/
|
||||||
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
|
# ├── 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"
|
SERVER="$REPO_ROOT/server"
|
||||||
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
||||||
STAGE="$OUT_DIR/1_cafe24_upload"
|
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 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; }
|
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.
|
# package once shipped. Keep it OUT of the web root: it names the build, not a public asset.
|
||||||
{
|
{
|
||||||
echo "package: move-detect-cafe24"
|
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 "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)"
|
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
} > "$PRIV/BUILD-INFO.txt"
|
} > "$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).
|
# .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/user.ini" "$WEB/.user.ini"
|
||||||
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
|
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
|
# `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
|
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
|
||||||
|
|
|
||||||
227
server/deploy/manual/build_manual.py
Normal 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()
|
||||||
202
server/deploy/manual/manual.json
Normal 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": "[안전 관리] → 정비 중 알람 억제에서 대상과 기간, 사유를 넣으세요. 알림만 멈추고 감지·기록은 계속되므로 나중에 그 시간대를 조사할 수 있습니다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
server/deploy/manual/shots/admin.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
server/deploy/manual/shots/compliance.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
server/deploy/manual/shots/event-detail.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
server/deploy/manual/shots/events.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
server/deploy/manual/shots/package.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
server/deploy/manual/shots/report.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
server/deploy/manual/shots/setup.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
server/deploy/manual/shots/zones.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |
|
|
@ -322,3 +322,37 @@ def test_offline_installer_verifies_integrity_before_install():
|
||||||
assert verify_at < sh.index("install --no-index") # before installing wheels
|
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.
|
# A missing SHA256SUMS must be FATAL (refuse), not a warn-and-proceed that installs blind.
|
||||||
assert "refusing to install unverified code" in sh
|
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
|
||||||
|
|
|
||||||