refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud' was misleading. Renames the directory, the edge client module, and every doc/script/identifier that used the term. - cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths) - edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient - cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md - CLOUD_* env/config keys -> SERVER_* - tests renamed to match Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
This commit is contained in:
parent
5d511b04f2
commit
3f2823a764
110 changed files with 271 additions and 271 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-installable bundle + systemd unit for the `move-detect` edge daemon.
|
Offline-installable bundle + systemd unit for the `move-detect` edge daemon.
|
||||||
The bundle is fully self-contained (all wheels + pose model + source): the Pi
|
The bundle is fully self-contained (all wheels + pose model + source): the Pi
|
||||||
performs **zero network fetches** at install or run time. The only runtime
|
performs **zero network fetches** at install or run time. The only runtime
|
||||||
network use is the cloud POST / config-poll / heartbeat, and even that tolerates
|
network use is the server POST / config-poll / heartbeat, and even that tolerates
|
||||||
being offline (events buffer in the local sqlite queue and flush on reconnect).
|
being offline (events buffer in the local sqlite queue and flush on reconnect).
|
||||||
|
|
||||||
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
|
> Scope: this covers the **CPU MoveNet + USB (UVC) webcam** path — the one the
|
||||||
|
|
@ -81,8 +81,8 @@ sudo systemctl enable --now move-detect-edge
|
||||||
journalctl -u move-detect-edge -f
|
journalctl -u move-detect-edge -f
|
||||||
```
|
```
|
||||||
|
|
||||||
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the cloud (see
|
`DEVICE_ID`/`DEVICE_SECRET` must match a `devices` row in the server (see
|
||||||
`cloud/docs/cloud-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
|
`server/docs/server-deploy.md` §6). `MOVENET_ONNX` should point at the bundled
|
||||||
model (`/opt/move-detect/models/movenet_lightning.onnx`).
|
model (`/opt/move-detect/models/movenet_lightning.onnx`).
|
||||||
|
|
||||||
## 4. Verify the air gap (do this before trusting a build)
|
## 4. Verify the air gap (do this before trusting a build)
|
||||||
|
|
@ -93,7 +93,7 @@ model (`/opt/move-detect/models/movenet_lightning.onnx`).
|
||||||
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
|
- [ ] Step [4/6] of the installer prints `edge import OK` (onnxruntime + cv2 +
|
||||||
numpy + requests all import from the venv).
|
numpy + requests all import from the venv).
|
||||||
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
|
- [ ] `systemctl status move-detect-edge` is `active (running)`; the log shows
|
||||||
frames processed and (if the cloud is reachable) heartbeats.
|
frames processed and (if the server is reachable) heartbeats.
|
||||||
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
|
- [ ] Pull the network cable → events keep queueing (`QUEUE_DB` grows) → restore
|
||||||
→ the queue flushes. (End-to-end behavior verified in the edge e2e harness.)
|
→ the queue flushes. (End-to-end behavior verified in the edge e2e harness.)
|
||||||
|
|
||||||
|
|
@ -125,14 +125,14 @@ validated). USB webcam stays available anytime via `CAMERA_BACKEND=uvc`.
|
||||||
|
|
||||||
**Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several
|
**Multiple cameras at once.** Set `CAMERAS=backend:device:camera_id,...` to run several
|
||||||
cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share
|
cameras in one daemon — each gets its own detection pipeline and `camera_id`, they share
|
||||||
the Hailo device (round-robin) and the offline queue, and cloud zones are routed to the
|
the Hailo device (round-robin) and the offline queue, and server zones are routed to the
|
||||||
matching camera. Example — CSI + USB together:
|
matching camera. Example — CSI + USB together:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
CAMERAS=csi:0:cam-01,uvc:0:cam-02
|
CAMERAS=csi:0:cam-01,uvc:0:cam-02
|
||||||
```
|
```
|
||||||
|
|
||||||
Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
|
Each `camera_id` must match the `camera_id` on that camera's zones in the server.
|
||||||
|
|
||||||
> **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't
|
> **Air gap:** `hailo-all` / `python3-picamera2` are the ONE piece this bundle can't
|
||||||
> carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer
|
> carry (Hailo's own driver + apt tree). Pre-install them on the Pi image, or transfer
|
||||||
|
|
@ -141,7 +141,7 @@ Each `camera_id` must match the `camera_id` on that camera's zones in the cloud.
|
||||||
|
|
||||||
## Not bundled (by design)
|
## Not bundled (by design)
|
||||||
|
|
||||||
- **MySQL / the cloud app** — a separate host; see `cloud/docs/cloud-deploy.md`.
|
- **MySQL / the server app** — a separate host; see `server/docs/server-deploy.md`.
|
||||||
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
|
- **HailoRT + `yolov8s_pose.hef` + picamera2** — Hailo's runtime/driver, the compiled
|
||||||
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
|
HEF, and the CSI camera stack are Pi system packages installed out-of-band (above);
|
||||||
the default CPU MoveNet + USB path needs none of them.
|
the default CPU MoveNet + USB path needs none of them.
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
|
||||||
|
|
||||||
echo ">> [4/6] verify the edge package imports offline"
|
echo ">> [4/6] verify the edge package imports offline"
|
||||||
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
|
# cv2/numpy/requests are mandatory for every backend (snapshot.py imports cv2 at module level
|
||||||
# on the unconditional startup chain; the daemon's cloud clients import requests at startup).
|
# on the unconditional startup chain; the daemon's server clients import requests at startup).
|
||||||
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
|
PYTHONPATH="$PREFIX/src" "$PREFIX/venv/bin/python" -c "import edge.daemon, cv2, numpy, requests; print('edge import OK')"
|
||||||
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
|
# onnxruntime is the CPU-MoveNet-only dep — the Hailo path never imports it, and onnxruntime
|
||||||
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
|
# ships no sdist and no 32-bit-ARM wheel, so a bundle built for a Hailo Pi legitimately may
|
||||||
|
|
@ -226,6 +226,6 @@ systemctl daemon-reload
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Installed. Next:"
|
echo "Installed. Next:"
|
||||||
echo " 1. edit $ETC/edge.env (device id/secret, cloud URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
|
echo " 1. edit $ETC/edge.env (device id/secret, server URL; MOVENET_ONNX=$PREFIX/models/movenet_lightning.onnx)"
|
||||||
echo " 2. sudo systemctl enable --now move-detect-edge"
|
echo " 2. sudo systemctl enable --now move-detect-edge"
|
||||||
echo " 3. journalctl -u move-detect-edge -f"
|
echo " 3. journalctl -u move-detect-edge -f"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
# sudo deploy/setup_online.sh
|
# sudo deploy/setup_online.sh
|
||||||
#
|
#
|
||||||
# Non-interactive: pass values as env vars, e.g.
|
# Non-interactive: pass values as env vars, e.g.
|
||||||
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://cloud CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
|
# sudo DEVICE_ID=pi-01 DEVICE_SECRET=... CAFE24_URL=https://server CAMERAS=csi:0:cam-01,uvc:0:cam-02 \
|
||||||
# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh
|
# HAILO_HEF=/home/pi/yolov8s_pose.hef deploy/setup_online.sh
|
||||||
# The Hailo HEF is normally auto-fetched for the detected board; you do not need HAILO_HEF.
|
# The Hailo HEF is normally auto-fetched for the detected board; you do not need HAILO_HEF.
|
||||||
# If the board can't be auto-detected (a Hailo-8 that reports no serial/identity), name it:
|
# If the board can't be auto-detected (a Hailo-8 that reports no serial/identity), name it:
|
||||||
|
|
@ -89,7 +89,7 @@ python3 -m venv --system-site-packages "$PREFIX/venv"
|
||||||
# cv2 edge/snapshot.py imports it at MODULE level, and that module is on the
|
# cv2 edge/snapshot.py imports it at MODULE level, and that module is on the
|
||||||
# unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND
|
# unconditional startup chain (daemon -> runtime -> snapshot). No POSE_BACKEND
|
||||||
# can reach build_pose() without it.
|
# can reach build_pose() without it.
|
||||||
# requests the daemon builds its cloud/alert clients with no injected HTTP fn, so
|
# requests the daemon builds its server/alert clients with no injected HTTP fn, so
|
||||||
# `import requests` fires at startup regardless of backend.
|
# `import requests` fires at startup regardless of backend.
|
||||||
# numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives
|
# numpy is still deliberately NOT named here, but the reason has moved: it no longer arrives
|
||||||
# transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless
|
# transitively via onnxruntime (best-effort below, may be absent). opencv-python-headless
|
||||||
|
|
@ -233,7 +233,7 @@ else
|
||||||
: "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}"
|
: "${DEVICE_ID:=}"; : "${DEVICE_SECRET:=}"; : "${CAFE24_URL:=}"; : "${CAMERAS:=}"
|
||||||
if [ -t 0 ]; then
|
if [ -t 0 ]; then
|
||||||
[ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID
|
[ -n "$DEVICE_ID" ] || read -rp "DEVICE_ID [pi-01]: " DEVICE_ID
|
||||||
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from cloud): " DEVICE_SECRET; echo; }
|
[ -n "$DEVICE_SECRET" ] || { read -rsp "DEVICE_SECRET (from server): " DEVICE_SECRET; echo; }
|
||||||
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https://<domain>/move_detect — include the subdir!): " CAFE24_URL
|
[ -n "$CAFE24_URL" ] || read -rp "CAFE24_URL (e.g. https://<domain>/move_detect — include the subdir!): " CAFE24_URL
|
||||||
[ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
|
[ -n "$CAMERAS" ] || read -rp "CAMERAS [csi:0:cam-01,uvc:0:cam-02]: " CAMERAS
|
||||||
fi
|
fi
|
||||||
|
|
@ -254,7 +254,7 @@ TARGET_FPS=12
|
||||||
EOF
|
EOF
|
||||||
chown root:"$SVC_USER" "$ETC/edge.env"
|
chown root:"$SVC_USER" "$ETC/edge.env"
|
||||||
chmod 0640 "$ETC/edge.env"
|
chmod 0640 "$ETC/edge.env"
|
||||||
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the cloud will accept events."
|
[ -n "$DEVICE_SECRET" ] || echo " NOTE: DEVICE_SECRET is empty — set it in $ETC/edge.env before the server will accept events."
|
||||||
echo " -> wrote $ETC/edge.env"
|
echo " -> wrote $ETC/edge.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망).
|
# move-detect edge daemon — systemd unit (Raspberry Pi 5, air-gapped/폐쇄망).
|
||||||
# Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then
|
# Install: copy to /etc/systemd/system/, fill /etc/move-detect/edge.env, then
|
||||||
# sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge
|
# sudo systemctl daemon-reload && sudo systemctl enable --now move-detect-edge
|
||||||
# The daemon tolerates the cloud being offline (events buffer in the local queue
|
# The daemon tolerates the server being offline (events buffer in the local queue
|
||||||
# and flush on reconnect), so this unit only *wants* the network, not requires it.
|
# and flush on reconnect), so this unit only *wants* the network, not requires it.
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,13 @@ Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low
|
||||||
A safety system that *looks* healthy while doing nothing is the worst failure. Make every
|
A safety system that *looks* healthy while doing nothing is the worst failure. Make every
|
||||||
misconfig/degradation fail LOUD.
|
misconfig/degradation fail LOUD.
|
||||||
|
|
||||||
- [x] **P1-1 [H]** `edge/cloudclient.py`, `edge/alert.py` — `requests` sends the default
|
- [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default
|
||||||
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
|
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
|
||||||
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
|
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
|
||||||
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
|
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
|
||||||
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
|
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
|
||||||
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
|
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
|
||||||
tests in test_alert.py / test_cloudclient.py.
|
tests in test_alert.py / test_serverclient.py.
|
||||||
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
|
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
|
||||||
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
|
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
|
||||||
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
|
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
|
||||||
|
|
@ -35,7 +35,7 @@ misconfig/degradation fail LOUD.
|
||||||
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
|
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
|
||||||
test_daemon.py locks the rate-limit.
|
test_daemon.py locks the rate-limit.
|
||||||
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
|
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
|
||||||
`source.read()` returned nothing, so the cloud cannot tell a dead device from a live one.
|
`source.read()` returned nothing, so the server cannot tell a dead device from a live one.
|
||||||
**Fix:** real fps + per-camera frame-age/health in the heartbeat body.
|
**Fix:** real fps + per-camera frame-age/health in the heartbeat body.
|
||||||
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
|
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
|
||||||
test_daemon.py locks fps math + failing-camera flag.
|
test_daemon.py locks fps math + failing-camera flag.
|
||||||
|
|
@ -54,12 +54,12 @@ misconfig/degradation fail LOUD.
|
||||||
poison head unwedges the queue; test locks the drain-past-poison behavior.
|
poison head unwedges the queue; test locks the drain-past-poison behavior.
|
||||||
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
|
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
|
||||||
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
|
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
|
||||||
client-generated `event_id` in the payload; cloud dedups on it.
|
client-generated `event_id` in the payload; server dedups on it.
|
||||||
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
|
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
|
||||||
creation, preserved through the offline queue + every retry). Cloud: `events.event_uuid`
|
creation, preserved through the offline queue + every retry). Server: `events.event_uuid`
|
||||||
CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the
|
CHAR(32) NULL UNIQUE; `EventValidator` accepts/validates it; `IngestController` returns the
|
||||||
already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key.
|
already-stored event (200 `dedup:true`) on a repeat uuid, race-safe against the UNIQUE key.
|
||||||
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (cloud).
|
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server).
|
||||||
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
|
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
|
||||||
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
|
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
|
||||||
DONE — `frame_interval_from_env` validates; test_config.py locks it.
|
DONE — `frame_interval_from_env` validates; test_config.py locks it.
|
||||||
|
|
@ -70,7 +70,7 @@ misconfig/degradation fail LOUD.
|
||||||
|
|
||||||
## Phase 2 — Core safety correctness (fall / zone / tracking)
|
## Phase 2 — Core safety correctness (fall / zone / tracking)
|
||||||
|
|
||||||
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `cloud/public/assets/zone-draw.js:196` — zones are
|
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are
|
||||||
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
|
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
|
||||||
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
|
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
|
||||||
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
|
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
|
||||||
|
|
@ -125,13 +125,13 @@ misconfig/degradation fail LOUD.
|
||||||
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
|
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
|
||||||
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
|
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
|
||||||
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
|
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
|
||||||
- [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored
|
- [x] **P3-2 [H]** `server/src/Ingest/IngestController.php:68` — worker snapshots are stored
|
||||||
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
|
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
|
||||||
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
|
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
|
||||||
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
|
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
|
||||||
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
|
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
|
||||||
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
|
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
|
||||||
- [x] **P3-2b [M]** `cloud/src/Dashboard/EventListController.php:17` — `site_scope` is stored
|
- [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored
|
||||||
in the session but never enforced; any authenticated user sees every device's worker
|
in the session but never enforced; any authenticated user sees every device's worker
|
||||||
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
|
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
|
||||||
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
|
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
|
||||||
|
|
@ -149,42 +149,42 @@ misconfig/degradation fail LOUD.
|
||||||
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
|
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
|
||||||
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
|
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
|
||||||
|
|
||||||
## Phase 4 — Cloud hardening (shared hosting)
|
## Phase 4 — Server hardening (shared hosting)
|
||||||
|
|
||||||
- [x] **P4-1 [H]** `cloud/public/index.php:106` — no top-level try/catch; a PDOException
|
- [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException
|
||||||
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
|
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
|
||||||
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
|
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
|
||||||
ship `.user.ini`.
|
ship `.user.ini`.
|
||||||
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
|
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
|
||||||
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
|
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
|
||||||
backstop. Package build verified.
|
backstop. Package build verified.
|
||||||
- [x] **P4-2 [H]** `cloud/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
|
- [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
|
||||||
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
|
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
|
||||||
error.
|
error.
|
||||||
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
|
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
|
||||||
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
|
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
|
||||||
- [x] **P4-3 [M]** `cloud/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
|
- [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
|
||||||
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
|
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
|
||||||
rows.
|
rows.
|
||||||
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
|
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
|
||||||
gated 1/100). Full PHP suite green.
|
gated 1/100). Full PHP suite green.
|
||||||
- [x] **P4-4 [M]** `cloud/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
|
- [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
|
||||||
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
|
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
|
||||||
captured params.
|
captured params.
|
||||||
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
|
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
|
||||||
- [x] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at
|
- [x] **P4-5 [M]** `server/src/Dashboard/EventListController.php:17` — event list hard-capped at
|
||||||
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
|
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
|
||||||
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
|
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
|
||||||
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
|
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
|
||||||
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
|
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
|
||||||
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
|
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
|
||||||
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
|
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
|
||||||
- [x] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
|
- [x] **P4-6 [M]** `server/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
|
||||||
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
|
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
|
||||||
clear error.
|
clear error.
|
||||||
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
|
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
|
||||||
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
|
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
|
||||||
- [x] **P4-7 [L]** `cloud/src/Ingest/IngestController.php:64` — snapshot write failure is
|
- [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is
|
||||||
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
|
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
|
||||||
order write→insert, log failures, reconcile orphans.
|
order write→insert, log failures, reconcile orphans.
|
||||||
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
|
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
|
||||||
|
|
@ -192,20 +192,20 @@ misconfig/degradation fail LOUD.
|
||||||
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
|
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
|
||||||
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
|
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
|
||||||
(throwing repo double → asserts 201, event stored, no leftover *.jpg).
|
(throwing repo double → asserts 201, event stored, no leftover *.jpg).
|
||||||
- [x] **P4-8 [L]** `cloud/src/Auth/AuthController.php:31` — login form emits a CSRF token that
|
- [x] **P4-8 [L]** `server/src/Auth/AuthController.php:31` — login form emits a CSRF token that
|
||||||
`login` never checks. **Fix:** verify it (or drop it).
|
`login` never checks. **Fix:** verify it (or drop it).
|
||||||
DONE — verified (completed the half-built defense, not dropped). login() checks
|
DONE — verified (completed the half-built defense, not dropped). login() checks
|
||||||
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
|
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
|
||||||
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
|
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
|
||||||
`testLoginRejectsMissingOrWrongCsrf`.
|
`testLoginRejectsMissingOrWrongCsrf`.
|
||||||
- [x] **P4-9 [L]** `cloud/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
|
- [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
|
||||||
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
|
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
|
||||||
DONE — logout() rotates the id then wipes (was regenerate-after-destroy).
|
DONE — logout() rotates the id then wipes (was regenerate-after-destroy).
|
||||||
- [x] **P4-10 [L]** `cloud/src/Ingest/EventValidator.php:26` — no string length caps → an
|
- [x] **P4-10 [L]** `server/src/Ingest/EventValidator.php:26` — no string length caps → an
|
||||||
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
|
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
|
||||||
lengths in the validator.
|
lengths in the validator.
|
||||||
DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest.
|
DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest.
|
||||||
- [x] **P4-11 [L]** `cloud/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
|
- [x] **P4-11 [L]** `server/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
|
||||||
(parse_url scheme-relative). **Fix:** collapse/normalize.
|
(parse_url scheme-relative). **Fix:** collapse/normalize.
|
||||||
|
|
||||||
## Phase 5 — Install / bundle / supply-chain
|
## Phase 5 — Install / bundle / supply-chain
|
||||||
|
|
@ -243,7 +243,7 @@ misconfig/degradation fail LOUD.
|
||||||
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera
|
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera
|
||||||
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
|
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
|
||||||
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
|
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
|
||||||
- [x] **P5-6 [L]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs
|
- [x] **P5-6 [L]** `server/deploy/build_cafe24_package.sh` — dev cache dirs
|
||||||
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
|
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -253,7 +253,7 @@ misconfig/degradation fail LOUD.
|
||||||
(updated as each item lands — newest first)
|
(updated as each item lands — newest first)
|
||||||
|
|
||||||
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
|
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
|
||||||
P1-9: edge `event_uuid` idempotency key (stable through the queue) + cloud dedup on a UNIQUE
|
P1-9: edge `event_uuid` idempotency key (stable through the queue) + server dedup on a UNIQUE
|
||||||
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
|
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
|
||||||
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
|
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
|
||||||
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
|
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
|
||||||
|
|
@ -272,16 +272,16 @@ misconfig/degradation fail LOUD.
|
||||||
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
|
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
|
||||||
knobs — tune with real camera footage on the Pi.)
|
knobs — tune with real camera footage on the Pi.)
|
||||||
|
|
||||||
- **2026-07-17 — P4-6 + P4-8 landed (cloud error/auth hardening).** ZoneConfig ownership-mismatch
|
- **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch
|
||||||
now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation
|
now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation
|
||||||
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
|
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
|
||||||
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
|
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
|
||||||
|
|
||||||
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
|
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
|
||||||
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
|
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
|
||||||
correctness (P2-3,4,5). Remaining: P1-9 (deferred to cloud batch), P2-6..11 (mostly threshold
|
correctness (P2-3,4,5). Remaining: P1-9 (deferred to server batch), P2-6..11 (mostly threshold
|
||||||
tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
|
tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
|
||||||
decision: how many days), Phase 4 (cloud hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
|
decision: how many days), Phase 4 (server hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
|
||||||
Nothing committed yet.
|
Nothing committed yet.
|
||||||
|
|
||||||
DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package.
|
DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package.
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
|
# P1 파일럿 런북 (노트북 웹캠 엔드투엔드)
|
||||||
|
|
||||||
이 문서는 노트북 웹캠 하나로 **엣지 → 클라우드 → 관리자 응답**의 최소 경로를 돌리는 절차다.
|
이 문서는 노트북 웹캠 하나로 **엣지 → 서버 → 관리자 응답**의 최소 경로를 돌리는 절차다.
|
||||||
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
|
실장비(Hailo·Pi5)·폐쇄망 전체 번들·드릴 게이트·SMS 연동은 후속(범위 밖, §6).
|
||||||
|
|
||||||
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
|
- 엣지: `python -m edge` (또는 `move-detect-edge`) — CPU MoveNet pose → 감지 → HMAC 서명 POST.
|
||||||
- 클라우드: 기존 `cloud/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
|
- 서버: 기존 `server/` PHP 앱 — ingest + 대시보드 + 사고 상세 + 데스크 Web Audio 알림.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -25,23 +25,23 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 클라우드 로컬 기동
|
## 2. 서버 로컬 기동
|
||||||
|
|
||||||
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
|
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
|
||||||
[cloud/docs/cloud-deploy.md](../cloud/docs/cloud-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
||||||
|
|
||||||
1. DB 생성 후 스키마 임포트: `mysql <db> < cloud/schema.sql` (10개 테이블).
|
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
|
||||||
2. `cloud/config.php`를 `cloud/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
||||||
(`snapshot_dir`는 쓰기 가능 경로로).
|
(`snapshot_dir`는 쓰기 가능 경로로).
|
||||||
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
||||||
CLI 스니펫과 SQL은 cloud-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
|
CLI 스니펫과 SQL은 server-deploy.md **§6(디바이스 시크릿)** · **§7(첫 관리자)** 그대로 사용:
|
||||||
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
|
- 시크릿 생성: `php -r "echo bin2hex(random_bytes(32)), \"\n\";"`
|
||||||
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
|
- `INSERT INTO devices (device_id, name, secret, config_version) VALUES ('pi-01','노트북','<시크릿>',1);`
|
||||||
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
|
- 관리자 해시: `php -r "echo password_hash('바꿀-암구호', PASSWORD_ARGON2ID), \"\n\";"`
|
||||||
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
|
- `INSERT INTO users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
|
||||||
4. 기동: `php -S localhost:8000 -t cloud/public`
|
4. 기동: `php -S localhost:8000 -t server/public`
|
||||||
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
||||||
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 cloud-deploy.md §8(HTTPS) 참조.
|
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|
||||||
|-----|------|------|
|
|-----|------|------|
|
||||||
| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` |
|
| `DEVICE_ID` | `devices.device_id`와 일치 | `pi-01` |
|
||||||
| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
|
| `DEVICE_SECRET` | `devices.secret`와 일치(HMAC) | `<§2에서 생성한 값>` |
|
||||||
| `CAFE24_URL` | 클라우드 베이스 URL | `http://localhost:8000` |
|
| `CAFE24_URL` | 서버 베이스 URL | `http://localhost:8000` |
|
||||||
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
|
| `CAMERA_ID` | 이벤트/구역의 camera_id | `cam-01` |
|
||||||
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
|
| `MOVENET_ONNX` | 모델 경로 **(필수, 없으면 즉시 KeyError)** | `models/movenet_lightning.onnx` |
|
||||||
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
|
| `UVC_DEVICE` | OpenCV 캡처 인덱스 | `0` |
|
||||||
|
|
@ -94,7 +94,7 @@ python -m edge # 또는 move-detect-edge
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
|
pytest -q # 엣지 유닛(하드웨어 스모크는 모델 없으면 skip)
|
||||||
cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations # 클라우드(0 warnings)
|
cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations # 서버(0 warnings)
|
||||||
```
|
```
|
||||||
|
|
||||||
모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —
|
모델을 번들한 뒤 실추론 경로까지 확인하려면 `models/movenet_lightning.onnx` 존재 상태로 `pytest -q` —
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# P1-B 클라우드 수신·대시보드 (Cloud Ingest + Dashboard) Implementation Plan
|
# P1-B 서버 수신·대시보드 (Server Ingest + Dashboard) Implementation Plan
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
|
@ -10,22 +10,22 @@
|
||||||
|
|
||||||
## Global Constraints
|
## Global Constraints
|
||||||
|
|
||||||
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `cloud/src/`).
|
- **PHP 8.5**, `declare(strict_types=1);` 모든 파일. PSR-4 오토로드(`App\` → `server/src/`).
|
||||||
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `cloud/public/`만 웹 노출.
|
- **폐쇄망/공유호스팅:** 런타임 composer 의존성 금지(대시보드는 vendor 없이 동작). phpunit은 개발 전용. Cafe24 `public_html`에 `server/public/`만 웹 노출.
|
||||||
- **HMAC 계약(P1-A와 정확히 일치):** 서명 = `hmac-sha256(nonce + timestamp + body, per_device_secret)` hex. P1-A `edge/alert.py::sign`과 바이트 동일하게 검증. 헤더 `X-Signature`, `X-Nonce`, `X-Timestamp`.
|
- **HMAC 계약(P1-A와 정확히 일치):** 서명 = `hmac-sha256(nonce + timestamp + body, per_device_secret)` hex. P1-A `edge/alert.py::sign`과 바이트 동일하게 검증. 헤더 `X-Signature`, `X-Nonce`, `X-Timestamp`.
|
||||||
- **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초).
|
- **replay 방지:** nonce 1회성(저장·재사용 거부) + timestamp 윈도우(기본 300초).
|
||||||
- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`cloud/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
|
- **스냅샷:** base64 JPEG를 **gd로 재인코딩**(페이로드 제거·강제 .jpg·랜덤 UUID 파일명), **웹루트 밖**(`server/storage/snapshots/`) 저장, 인증 프록시로만 서빙. 원본 바이트 절대 그대로 저장 금지.
|
||||||
- **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증.
|
- **SQL:** 전부 prepared statement(placeholder). event_type/severity/level 등은 **화이트리스트 enum** 검증.
|
||||||
- **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer.
|
- **인증:** 로그인 필수(argon2id), 세션 쿠키 HttpOnly+Secure+SameSite=Lax, CSRF 토큰, brute-force 잠금. RBAC: admin/operator/viewer.
|
||||||
- **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트.
|
- **심각도 색상 단독 금지(NF9):** 텍스트(위급/높음/경고)+아이콘+색, 색맹안전 팔레트.
|
||||||
- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`cloud/config.php`는 `.gitignore`), 하드코딩 금지.
|
- **시크릿:** per-device secret·DB 자격증명은 env/시크릿 파일(`server/config.php`는 `.gitignore`), 하드코딩 금지.
|
||||||
- **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24).
|
- **테스트:** 로직은 mock repository로 DB 없이 단위테스트. DB 통합은 마지막 게이트 태스크(로컬 pdo_sqlite or Cafe24).
|
||||||
- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.
|
- 파일 작고 단일책임. 컨트롤러는 얇게(검증·오케스트레이션), 로직은 서비스/헬퍼로.
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cloud/
|
server/
|
||||||
composer.json # PSR-4 autoload + phpunit(dev)
|
composer.json # PSR-4 autoload + phpunit(dev)
|
||||||
phpunit.xml
|
phpunit.xml
|
||||||
config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore)
|
config.example.php # DB dsn, snapshot dir, nonce ttl 등 (실배포 config.php는 gitignore)
|
||||||
|
|
@ -64,7 +64,7 @@ cloud/
|
||||||
## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)
|
## Task 1: 스캐폴드 (composer, autoload, Config, PHPUnit)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/composer.json`, `cloud/phpunit.xml`, `cloud/src/Config.php`, `cloud/config.example.php`, `cloud/tests/ConfigTest.php`, `cloud/.gitignore`
|
- Create: `server/composer.json`, `server/phpunit.xml`, `server/src/Config.php`, `server/config.example.php`, `server/tests/ConfigTest.php`, `server/.gitignore`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
|
|
@ -74,7 +74,7 @@ cloud/
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "move-detect/cloud",
|
"name": "move-detect/server",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"require": { "php": ">=8.2" },
|
"require": { "php": ">=8.2" },
|
||||||
"require-dev": { "phpunit/phpunit": "^11" },
|
"require-dev": { "phpunit/phpunit": "^11" },
|
||||||
|
|
@ -95,7 +95,7 @@ cloud/
|
||||||
</phpunit>
|
</phpunit>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: `.gitignore`** (cloud/)
|
- [ ] **Step 3: `.gitignore`** (server/)
|
||||||
|
|
||||||
```
|
```
|
||||||
/vendor/
|
/vendor/
|
||||||
|
|
@ -104,7 +104,7 @@ cloud/
|
||||||
!/storage/snapshots/.gitkeep
|
!/storage/snapshots/.gitkeep
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 실패 테스트** — `cloud/tests/ConfigTest.php`
|
- [ ] **Step 4: 실패 테스트** — `server/tests/ConfigTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -135,10 +135,10 @@ final class ConfigTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 5: 실패 확인**
|
- [ ] **Step 5: 실패 확인**
|
||||||
|
|
||||||
Run: `cd cloud && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
|
Run: `cd server && composer install -q && ./vendor/bin/phpunit tests/ConfigTest.php`
|
||||||
Expected: FAIL — `Class "App\Config" not found`
|
Expected: FAIL — `Class "App\Config" not found`
|
||||||
|
|
||||||
- [ ] **Step 6: 구현** — `cloud/src/Config.php`
|
- [ ] **Step 6: 구현** — `server/src/Config.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -180,8 +180,8 @@ return [
|
||||||
Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS
|
Run: `./vendor/bin/phpunit tests/ConfigTest.php` → PASS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/composer.json cloud/phpunit.xml cloud/.gitignore cloud/config.example.php cloud/src/Config.php cloud/tests/ConfigTest.php
|
git add server/composer.json server/phpunit.xml server/.gitignore server/config.example.php server/src/Config.php server/tests/ConfigTest.php
|
||||||
git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config"
|
git commit -m "feat(server): P1-B scaffold — composer autoload + Config"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -189,13 +189,13 @@ git commit -m "feat(cloud): P1-B scaffold — composer autoload + Config"
|
||||||
## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치
|
## Task 2: HMAC 검증 (Hmac::sign/verify) — P1-A 계약 일치
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Security/Hmac.php`, `cloud/tests/HmacTest.php`
|
- Create: `server/src/Security/Hmac.php`, `server/tests/HmacTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
- Produces: `App\Security\Hmac::sign(string $secret, string $nonce, string $ts, string $body): string` (hex), `Hmac::verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool` (hash_equals, 상수시간).
|
- Produces: `App\Security\Hmac::sign(string $secret, string $nonce, string $ts, string $body): string` (hex), `Hmac::verify(string $secret, string $nonce, string $ts, string $body, string $sig): bool` (hash_equals, 상수시간).
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/HmacTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/HmacTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -229,7 +229,7 @@ final class HmacTest extends TestCase
|
||||||
|
|
||||||
Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found)
|
Run: `./vendor/bin/phpunit tests/HmacTest.php` → FAIL (class not found)
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Security/Hmac.php`
|
- [ ] **Step 3: 구현** — `server/src/Security/Hmac.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -256,8 +256,8 @@ final class Hmac
|
||||||
Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS
|
Run: `./vendor/bin/phpunit tests/HmacTest.php` → PASS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Security/Hmac.php cloud/tests/HmacTest.php
|
git add server/src/Security/Hmac.php server/tests/HmacTest.php
|
||||||
git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
|
git commit -m "feat(server): HMAC sign/verify matching P1-A edge contract"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -265,7 +265,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
|
||||||
## Task 3: Repository 인터페이스 + InMemory 테스트더블
|
## Task 3: Repository 인터페이스 + InMemory 테스트더블
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `cloud/tests/Support/InMemoryRepositories.php`, `cloud/tests/RepositoryContractTest.php`
|
- Create: `server/src/Repository/{EventRepository,UserRepository,DeviceRepository,ZoneRepository,ThresholdRepository,NonceRepository,RateLimiterRepository,AuditRepository,SnapshotRepository}.php`, `server/tests/Support/InMemoryRepositories.php`, `server/tests/RepositoryContractTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
|
|
@ -282,7 +282,7 @@ git commit -m "feat(cloud): HMAC sign/verify matching P1-A edge contract"
|
||||||
|
|
||||||
- [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의.
|
- [ ] **Step 1: 인터페이스들 작성** — 각 파일에 `interface` 정의.
|
||||||
|
|
||||||
`cloud/src/Repository/DeviceRepository.php`:
|
`server/src/Repository/DeviceRepository.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -413,7 +413,7 @@ interface AuditRepository
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `cloud/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
|
- [ ] **Step 2: InMemory 더블 + 실패 테스트** — `server/tests/Support/InMemoryRepositories.php` (테스트 전용 구현)
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -479,7 +479,7 @@ final class InMemoryAuditRepository implements AuditRepository {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
그리고 `cloud/tests/RepositoryContractTest.php`:
|
그리고 `server/tests/RepositoryContractTest.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -523,8 +523,8 @@ Expected: FAIL (interfaces/doubles absent) → 작성 후 PASS.
|
||||||
- [ ] **Step 4: 커밋**
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Repository/*.php cloud/tests/Support/InMemoryRepositories.php cloud/tests/RepositoryContractTest.php
|
git add server/src/Repository/*.php server/tests/Support/InMemoryRepositories.php server/tests/RepositoryContractTest.php
|
||||||
git commit -m "feat(cloud): repository interfaces + in-memory test doubles"
|
git commit -m "feat(server): repository interfaces + in-memory test doubles"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -532,13 +532,13 @@ git commit -m "feat(cloud): repository interfaces + in-memory test doubles"
|
||||||
## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증)
|
## Task 4: EventValidator (enum 화이트리스트 + 스키마 검증)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Ingest/EventValidator.php`, `cloud/tests/EventValidatorTest.php`
|
- Create: `server/src/Ingest/EventValidator.php`, `server/tests/EventValidatorTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
- Produces: `App\Ingest\EventValidator::validate(array $payload): array` — `['ok' => bool, 'errors' => string[], 'clean' => array]`. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목.
|
- Produces: `App\Ingest\EventValidator::validate(array $payload): array` — `['ok' => bool, 'errors' => string[], 'clean' => array]`. 화이트리스트: event_type ∈ {fall,no_motion,immobility,ghost,zone_warning,zone_restricted,pinch,monitoring_degraded,device_fault}; severity ∈ {critical,high,warning,info}. 필수: device_id, camera_id, event_type, severity, occurred_at. snapshots는 배열(≤3), 문자열 항목.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/EventValidatorTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/EventValidatorTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -579,7 +579,7 @@ final class EventValidatorTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/EventValidator.php`
|
- [ ] **Step 3: 구현** — `server/src/Ingest/EventValidator.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -627,8 +627,8 @@ final class EventValidator
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Ingest/EventValidator.php cloud/tests/EventValidatorTest.php
|
git add server/src/Ingest/EventValidator.php server/tests/EventValidatorTest.php
|
||||||
git commit -m "feat(cloud): event payload validator with enum whitelist"
|
git commit -m "feat(server): event payload validator with enum whitelist"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -636,13 +636,13 @@ git commit -m "feat(cloud): event payload validator with enum whitelist"
|
||||||
## Task 5: ImageProcessor (gd 재인코딩 + UUID)
|
## Task 5: ImageProcessor (gd 재인코딩 + UUID)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Ingest/ImageProcessor.php`, `cloud/tests/ImageProcessorTest.php`
|
- Create: `server/src/Ingest/ImageProcessor.php`, `server/tests/ImageProcessorTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
- Produces: `App\Ingest\ImageProcessor::reencode(string $base64, int $quality = 80): array` — `['ok'=>bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]`. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 `['ok'=>false]`. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트).
|
- Produces: `App\Ingest\ImageProcessor::reencode(string $base64, int $quality = 80): array` — `['ok'=>bool, 'bytes'=>string, 'uuid'=>string, 'hash'=>string]`. base64 디코드 → gd로 로드 → 순수 JPEG로 재출력(원본 메타/페이로드 제거). 실패 시 `['ok'=>false]`. uuid=랜덤 32hex, hash=sha256(재인코딩 바이트).
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ImageProcessorTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/ImageProcessorTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -677,7 +677,7 @@ final class ImageProcessorTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/ImageProcessor.php`
|
- [ ] **Step 3: 구현** — `server/src/Ingest/ImageProcessor.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -710,8 +710,8 @@ final class ImageProcessor
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Ingest/ImageProcessor.php cloud/tests/ImageProcessorTest.php
|
git add server/src/Ingest/ImageProcessor.php server/tests/ImageProcessorTest.php
|
||||||
git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
|
git commit -m "feat(server): gd image re-encode (strip payload, uuid, hash)"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -719,7 +719,7 @@ git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
|
||||||
## Task 6: Http Request/Response
|
## Task 6: Http Request/Response
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Http/Request.php`, `cloud/src/Http/Response.php`, `cloud/tests/HttpTest.php`
|
- Create: `server/src/Http/Request.php`, `server/src/Http/Response.php`, `server/tests/HttpTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
|
|
@ -727,7 +727,7 @@ git commit -m "feat(cloud): gd image re-encode (strip payload, uuid, hash)"
|
||||||
- `App\Http\Request(string $method, string $path, array $headers, string $body, array $query, array $post)` with getters `method(), path(), header(string $name): ?string, body(), json(): array, query(string $k): ?string, post(string $k): ?string`, and static `fromGlobals(): self`.
|
- `App\Http\Request(string $method, string $path, array $headers, string $body, array $query, array $post)` with getters `method(), path(), header(string $name): ?string, body(), json(): array, query(string $k): ?string, post(string $k): ?string`, and static `fromGlobals(): self`.
|
||||||
- `App\Http\Response`: static `json(array $data, int $status=200): self`, `text(string $s, int $status=200): self`, `bytes(string $b, string $contentType, int $status=200): self`, `redirect(string $to): self`; getters `status(), headers(), body()`; `send(): void`.
|
- `App\Http\Response`: static `json(array $data, int $status=200): self`, `text(string $s, int $status=200): self`, `bytes(string $b, string $contentType, int $status=200): self`, `redirect(string $to): self`; getters `status(), headers(), body()`; `send(): void`.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/HttpTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/HttpTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -755,7 +755,7 @@ final class HttpTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Http/Request.php`
|
- [ ] **Step 3: 구현** — `server/src/Http/Request.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -792,7 +792,7 @@ final class Request
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/src/Http/Response.php`:
|
`server/src/Http/Response.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -829,8 +829,8 @@ final class Response
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Http/Request.php cloud/src/Http/Response.php cloud/tests/HttpTest.php
|
git add server/src/Http/Request.php server/src/Http/Response.php server/tests/HttpTest.php
|
||||||
git commit -m "feat(cloud): Http Request/Response primitives"
|
git commit -m "feat(server): Http Request/Response primitives"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -838,13 +838,13 @@ git commit -m "feat(cloud): Http Request/Response primitives"
|
||||||
## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합
|
## Task 7: IngestController (POST /api/events) — 보안 파이프라인 통합
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Ingest/IngestController.php`, `cloud/tests/IngestControllerTest.php`
|
- Create: `server/src/Ingest/IngestController.php`, `server/tests/IngestControllerTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `Hmac` (T2), `EventValidator`/`ImageProcessor` (T4/T5), `Request`/`Response` (T6), `DeviceRepository`/`NonceRepository`/`RateLimiterRepository`/`EventRepository`/`SnapshotRepository` (T3), `Config` (T1)
|
- Consumes: `Hmac` (T2), `EventValidator`/`ImageProcessor` (T4/T5), `Request`/`Response` (T6), `DeviceRepository`/`NonceRepository`/`RateLimiterRepository`/`EventRepository`/`SnapshotRepository` (T3), `Config` (T1)
|
||||||
- Produces: `App\Ingest\IngestController(Config, DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository)` with `handle(Request $req, int $now): Response`. 순서: rate limit → device secret 조회 → HMAC 검증 → timestamp 윈도우 → nonce replay → 검증 → 이미지 재인코딩·저장 → 이벤트+스냅샷 insert. 실패는 401/400/429, 성공 201 `{event_id}`.
|
- Produces: `App\Ingest\IngestController(Config, DeviceRepository, NonceRepository, RateLimiterRepository, EventRepository, SnapshotRepository)` with `handle(Request $req, int $now): Response`. 순서: rate limit → device secret 조회 → HMAC 검증 → timestamp 윈도우 → nonce replay → 검증 → 이미지 재인코딩·저장 → 이벤트+스냅샷 insert. 실패는 401/400/429, 성공 201 `{event_id}`.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/IngestControllerTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/IngestControllerTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -919,7 +919,7 @@ final class IngestControllerTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Ingest/IngestController.php`
|
- [ ] **Step 3: 구현** — `server/src/Ingest/IngestController.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -991,8 +991,8 @@ final class IngestController
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Ingest/IngestController.php cloud/tests/IngestControllerTest.php
|
git add server/src/Ingest/IngestController.php server/tests/IngestControllerTest.php
|
||||||
git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
|
git commit -m "feat(server): ingest controller (HMAC+nonce+ratelimit+reencode+persist)"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1000,7 +1000,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
|
||||||
## Task 8: Password (argon2id) + Csrf
|
## Task 8: Password (argon2id) + Csrf
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Security/Password.php`, `cloud/src/Security/Csrf.php`, `cloud/tests/PasswordCsrfTest.php`
|
- Create: `server/src/Security/Password.php`, `server/src/Security/Csrf.php`, `server/tests/PasswordCsrfTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
|
|
@ -1008,7 +1008,7 @@ git commit -m "feat(cloud): ingest controller (HMAC+nonce+ratelimit+reencode+per
|
||||||
- `App\Security\Password::hash(string $p): string` (PASSWORD_ARGON2ID), `Password::verify(string $p, string $hash): bool`.
|
- `App\Security\Password::hash(string $p): string` (PASSWORD_ARGON2ID), `Password::verify(string $p, string $hash): bool`.
|
||||||
- `App\Security\Csrf::issue(callable $randHex): string`, `Csrf::check(string $expected, string $given): bool` (hash_equals).
|
- `App\Security\Csrf::issue(callable $randHex): string`, `Csrf::check(string $expected, string $given): bool` (hash_equals).
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/PasswordCsrfTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/PasswordCsrfTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1036,7 +1036,7 @@ final class PasswordCsrfTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Security/Password.php`
|
- [ ] **Step 3: 구현** — `server/src/Security/Password.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1054,7 +1054,7 @@ final class Password
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/src/Security/Csrf.php`:
|
`server/src/Security/Csrf.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1073,8 +1073,8 @@ final class Csrf
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Security/Password.php cloud/src/Security/Csrf.php cloud/tests/PasswordCsrfTest.php
|
git add server/src/Security/Password.php server/src/Security/Csrf.php server/tests/PasswordCsrfTest.php
|
||||||
git commit -m "feat(cloud): argon2id password + CSRF token helpers"
|
git commit -m "feat(server): argon2id password + CSRF token helpers"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1082,13 +1082,13 @@ git commit -m "feat(cloud): argon2id password + CSRF token helpers"
|
||||||
## Task 9: Rbac (역할→권한)
|
## Task 9: Rbac (역할→권한)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Auth/Rbac.php`, `cloud/tests/RbacTest.php`
|
- Create: `server/src/Auth/Rbac.php`, `server/tests/RbacTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: (없음)
|
- Consumes: (없음)
|
||||||
- Produces: `App\Auth\Rbac::can(string $role, string $permission): bool`. 권한: `view_events`(all), `acknowledge`(admin,operator), `edit_zones`(admin,operator), `manage_thresholds`(admin), `manage_compliance`(admin).
|
- Produces: `App\Auth\Rbac::can(string $role, string $permission): bool`. 권한: `view_events`(all), `acknowledge`(admin,operator), `edit_zones`(admin,operator), `manage_thresholds`(admin), `manage_compliance`(admin).
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/RbacTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/RbacTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1123,7 +1123,7 @@ final class RbacTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Auth/Rbac.php`
|
- [ ] **Step 3: 구현** — `server/src/Auth/Rbac.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1146,8 +1146,8 @@ final class Rbac
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Auth/Rbac.php cloud/tests/RbacTest.php
|
git add server/src/Auth/Rbac.php server/tests/RbacTest.php
|
||||||
git commit -m "feat(cloud): RBAC permission matrix"
|
git commit -m "feat(server): RBAC permission matrix"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1155,7 +1155,7 @@ git commit -m "feat(cloud): RBAC permission matrix"
|
||||||
## Task 10: Session + AuthController (로그인/brute-force 잠금)
|
## Task 10: Session + AuthController (로그인/brute-force 잠금)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Auth/Session.php`, `cloud/src/Auth/AuthController.php`, `cloud/tests/AuthControllerTest.php`
|
- Create: `server/src/Auth/Session.php`, `server/src/Auth/AuthController.php`, `server/tests/AuthControllerTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3)
|
- Consumes: `Password` (T8), `Request`/`Response` (T6), `UserRepository`/`AuditRepository` (T3)
|
||||||
|
|
@ -1163,7 +1163,7 @@ git commit -m "feat(cloud): RBAC permission matrix"
|
||||||
- `interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }`, `ArraySessionStore` (test), `PhpSessionStore` (prod, secure cookie).
|
- `interface App\Auth\SessionStore { get(string $k): mixed; set(string $k, mixed $v): void; clear(): void; }`, `ArraySessionStore` (test), `PhpSessionStore` (prod, secure cookie).
|
||||||
- `App\Auth\AuthController(UserRepository $users, AuditRepository $audit, int $maxAttempts=5, int $lockSeconds=900)` with `login(Request $req, SessionStore $sess, int $now): Response` and `logout(SessionStore $sess): Response`. Locks after maxAttempts; 200 on success (sets user in session), 401 on bad creds, 423 when locked.
|
- `App\Auth\AuthController(UserRepository $users, AuditRepository $audit, int $maxAttempts=5, int $lockSeconds=900)` with `login(Request $req, SessionStore $sess, int $now): Response` and `logout(SessionStore $sess): Response`. Locks after maxAttempts; 200 on success (sets user in session), 401 on bad creds, 423 when locked.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/AuthControllerTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/AuthControllerTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1210,7 +1210,7 @@ final class AuthControllerTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Auth/Session.php`
|
- [ ] **Step 3: 구현** — `server/src/Auth/Session.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1240,7 +1240,7 @@ final class PhpSessionStore implements SessionStore {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/src/Auth/AuthController.php`:
|
`server/src/Auth/AuthController.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1300,8 +1300,8 @@ final class AuthController
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Auth/Session.php cloud/src/Auth/AuthController.php cloud/tests/AuthControllerTest.php
|
git add server/src/Auth/Session.php server/src/Auth/AuthController.php server/tests/AuthControllerTest.php
|
||||||
git commit -m "feat(cloud): session store + auth controller with lockout"
|
git commit -m "feat(server): session store + auth controller with lockout"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1309,13 +1309,13 @@ git commit -m "feat(cloud): session store + auth controller with lockout"
|
||||||
## Task 11: Router + 프론트컨트롤러 준비
|
## Task 11: Router + 프론트컨트롤러 준비
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Http/Router.php`, `cloud/tests/RouterTest.php`
|
- Create: `server/src/Http/Router.php`, `server/tests/RouterTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `Request`/`Response` (T6)
|
- Consumes: `Request`/`Response` (T6)
|
||||||
- Produces: `App\Http\Router` with `add(string $method, string $path, callable $handler): void` and `dispatch(Request $req): Response` (404 if no match; path params `{id}` → passed to handler as second arg assoc array).
|
- Produces: `App\Http\Router` with `add(string $method, string $path, callable $handler): void` and `dispatch(Request $req): Response` (404 if no match; path params `{id}` → passed to handler as second arg assoc array).
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/RouterTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/RouterTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1347,7 +1347,7 @@ final class RouterTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Http/Router.php`
|
- [ ] **Step 3: 구현** — `server/src/Http/Router.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1379,8 +1379,8 @@ final class Router
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Http/Router.php cloud/tests/RouterTest.php
|
git add server/src/Http/Router.php server/tests/RouterTest.php
|
||||||
git commit -m "feat(cloud): path-param router + dispatch"
|
git commit -m "feat(server): path-param router + dispatch"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1388,7 +1388,7 @@ git commit -m "feat(cloud): path-param router + dispatch"
|
||||||
## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시
|
## Task 12: 대시보드 컨트롤러 (목록·상세·확인) + 스냅샷 프록시
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Dashboard/EventListController.php`, `cloud/src/Dashboard/EventDetailController.php`, `cloud/src/Dashboard/SnapshotProxy.php`, `cloud/tests/DashboardTest.php`
|
- Create: `server/src/Dashboard/EventListController.php`, `server/src/Dashboard/EventDetailController.php`, `server/src/Dashboard/SnapshotProxy.php`, `server/tests/DashboardTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3)
|
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac` (T9/T10), `EventRepository`/`SnapshotRepository`/`AuditRepository` (T3)
|
||||||
|
|
@ -1397,7 +1397,7 @@ git commit -m "feat(cloud): path-param router + dispatch"
|
||||||
- `EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response`; `acknowledge(Request, array $params): Response` (RBAC `acknowledge`).
|
- `EventDetailController(EventRepository, SnapshotRepository, AuditRepository, SessionStore)::show(Request, array $params): Response`; `acknowledge(Request, array $params): Response` (RBAC `acknowledge`).
|
||||||
- `SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response` — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404.
|
- `SnapshotProxy(SnapshotRepository, SessionStore)::serve(Request, array $params): Response` — 인증 필요, uuid→웹루트밖 파일 바이트 서빙(image/jpeg), 미인증 401, 없음 404.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/DashboardTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/DashboardTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1451,7 +1451,7 @@ final class DashboardTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Dashboard/EventListController.php`
|
- [ ] **Step 3: 구현** — `server/src/Dashboard/EventListController.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1476,7 +1476,7 @@ final class EventListController
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/src/Dashboard/EventDetailController.php`:
|
`server/src/Dashboard/EventDetailController.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1516,7 +1516,7 @@ final class EventDetailController
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/src/Dashboard/SnapshotProxy.php`:
|
`server/src/Dashboard/SnapshotProxy.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1545,8 +1545,8 @@ final class SnapshotProxy
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Dashboard/EventListController.php cloud/src/Dashboard/EventDetailController.php cloud/src/Dashboard/SnapshotProxy.php cloud/tests/DashboardTest.php
|
git add server/src/Dashboard/EventListController.php server/src/Dashboard/EventDetailController.php server/src/Dashboard/SnapshotProxy.php server/tests/DashboardTest.php
|
||||||
git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot proxy"
|
git commit -m "feat(server): dashboard list/detail/ack + auth-gated snapshot proxy"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1554,7 +1554,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
|
||||||
## Task 13: Polygon 검증 + ZoneConfigController
|
## Task 13: Polygon 검증 + ZoneConfigController
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Geometry/Polygon.php`, `cloud/src/Dashboard/ZoneConfigController.php`, `cloud/tests/ZoneConfigTest.php`
|
- Create: `server/src/Geometry/Polygon.php`, `server/src/Dashboard/ZoneConfigController.php`, `server/tests/ZoneConfigTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3)
|
- Consumes: `Request`/`Response` (T6), `SessionStore`/`Rbac`, `ZoneRepository`/`AuditRepository` (T3)
|
||||||
|
|
@ -1562,7 +1562,7 @@ git commit -m "feat(cloud): dashboard list/detail/ack + auth-gated snapshot prox
|
||||||
- `App\Geometry\Polygon::validate(array $points): bool` — ≥3점, 각 점 `[x,y]` 숫자.
|
- `App\Geometry\Polygon::validate(array $points): bool` — ≥3점, 각 점 `[x,y]` 숫자.
|
||||||
- `ZoneConfigController(ZoneRepository, AuditRepository, SessionStore)`: `list(Request, array): Response` (camera별), `save(Request): Response` (RBAC edit_zones; level ∈ {warning,restricted,pinch}; polygon 검증; zone_id 반환), `delete(Request, array): Response`.
|
- `ZoneConfigController(ZoneRepository, AuditRepository, SessionStore)`: `list(Request, array): Response` (camera별), `save(Request): Response` (RBAC edit_zones; level ∈ {warning,restricted,pinch}; polygon 검증; zone_id 반환), `delete(Request, array): Response`.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ZoneConfigTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/ZoneConfigTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1610,7 +1610,7 @@ final class ZoneConfigTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Geometry/Polygon.php`
|
- [ ] **Step 3: 구현** — `server/src/Geometry/Polygon.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1631,7 +1631,7 @@ final class Polygon
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/src/Dashboard/ZoneConfigController.php`:
|
`server/src/Dashboard/ZoneConfigController.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1698,8 +1698,8 @@ final class ZoneConfigController
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Geometry/Polygon.php cloud/src/Dashboard/ZoneConfigController.php cloud/tests/ZoneConfigTest.php
|
git add server/src/Geometry/Polygon.php server/src/Dashboard/ZoneConfigController.php server/tests/ZoneConfigTest.php
|
||||||
git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
|
git commit -m "feat(server): polygon validation + zone config controller (RBAC)"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1707,7 +1707,7 @@ git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
|
||||||
## Task 14: ConfigController + HeartbeatController (Pi 동기 API)
|
## Task 14: ConfigController + HeartbeatController (Pi 동기 API)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Api/ConfigController.php`, `cloud/src/Api/HeartbeatController.php`, `cloud/tests/ApiSyncTest.php`
|
- Create: `server/src/Api/ConfigController.php`, `server/src/Api/HeartbeatController.php`, `server/tests/ApiSyncTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1)
|
- Consumes: `Request`/`Response` (T6), `Hmac` (T2), `DeviceRepository`/`ZoneRepository`/`ThresholdRepository` (T3), `Config` (T1)
|
||||||
|
|
@ -1715,7 +1715,7 @@ git commit -m "feat(cloud): polygon validation + zone config controller (RBAC)"
|
||||||
- `App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response` — `?device_id=`; device secret HMAC 검증(엣지 폴링). 반환 `{version, zones, thresholds}`.
|
- `App\Api\ConfigController(DeviceRepository, ZoneRepository, ThresholdRepository)::get(Request): Response` — `?device_id=`; device secret HMAC 검증(엣지 폴링). 반환 `{version, zones, thresholds}`.
|
||||||
- `App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response` — HMAC 검증 후 `touchHeartbeat(device_id, {fps,temp,throttle,config_version,at})`. 200.
|
- `App\Api\HeartbeatController(Config, DeviceRepository)::post(Request, int $now): Response` — HMAC 검증 후 `touchHeartbeat(device_id, {fps,temp,throttle,config_version,at})`. 200.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `cloud/tests/ApiSyncTest.php`
|
- [ ] **Step 1: 실패 테스트** — `server/tests/ApiSyncTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1768,7 +1768,7 @@ final class ApiSyncTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** → FAIL
|
- [ ] **Step 2: 실패 확인** → FAIL
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `cloud/src/Api/ConfigController.php`
|
- [ ] **Step 3: 구현** — `server/src/Api/ConfigController.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1802,7 +1802,7 @@ final class ConfigController
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/src/Api/HeartbeatController.php`:
|
`server/src/Api/HeartbeatController.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1840,8 +1840,8 @@ final class HeartbeatController
|
||||||
- [ ] **Step 4: 통과 + 커밋**
|
- [ ] **Step 4: 통과 + 커밋**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Api/ConfigController.php cloud/src/Api/HeartbeatController.php cloud/tests/ApiSyncTest.php
|
git add server/src/Api/ConfigController.php server/src/Api/HeartbeatController.php server/tests/ApiSyncTest.php
|
||||||
git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
|
git commit -m "feat(server): device config + heartbeat sync API (HMAC)"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1849,11 +1849,11 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
|
||||||
## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합)
|
## Task 15: 프론트컨트롤러 배선 + 뷰 + .htaccess (통합)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/public/index.php`, `cloud/public/.htaccess`, `cloud/public/assets/{app.css,app.js,zone-draw.js}`, `cloud/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `cloud/tests/WiringSmokeTest.php`
|
- Create: `server/public/index.php`, `server/public/.htaccess`, `server/public/assets/{app.css,app.js,zone-draw.js}`, `server/src/View/{layout.php,login.php,events.php,event_detail.php,zones.php}`, `server/tests/WiringSmokeTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만).
|
- Consumes: 전 태스크. `Router`, 모든 컨트롤러, `PhpSessionStore`, PDO repos (T16에서 실제 구현; 여기선 배선만).
|
||||||
- Produces: `cloud/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
|
- Produces: `server/public/index.php` — Config 로드 → PDO repos 생성 → Router에 모든 경로 등록 → dispatch → send. 뷰는 서버렌더 PHP. **테스트는 라우팅 표만 스모크 검증**(핸들러 스텁).
|
||||||
|
|
||||||
라우트 표:
|
라우트 표:
|
||||||
| method | path | handler |
|
| method | path | handler |
|
||||||
|
|
@ -1872,7 +1872,7 @@ git commit -m "feat(cloud): device config + heartbeat sync API (HMAC)"
|
||||||
| GET | /snap/{uuid} | SnapshotProxy::serve |
|
| GET | /snap/{uuid} | SnapshotProxy::serve |
|
||||||
| GET | /, /zones, /login | View render |
|
| GET | /, /zones, /login | View render |
|
||||||
|
|
||||||
- [ ] **Step 1: 라우터 스모크 테스트** — `cloud/tests/WiringSmokeTest.php`
|
- [ ] **Step 1: 라우터 스모크 테스트** — `server/tests/WiringSmokeTest.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1902,7 +1902,7 @@ final class WiringSmokeTest extends TestCase
|
||||||
|
|
||||||
- [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS.
|
- [ ] **Step 2: 실패/통과 확인** → 라우터만 검증(회귀 방지). `./vendor/bin/phpunit tests/WiringSmokeTest.php` → PASS.
|
||||||
|
|
||||||
- [ ] **Step 3: 프론트컨트롤러** — `cloud/public/index.php`
|
- [ ] **Step 3: 프론트컨트롤러** — `server/public/index.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -1970,7 +1970,7 @@ $r->add('GET', '/zones', fn() => $render('zones'));
|
||||||
$r->dispatch(Request::fromGlobals())->send();
|
$r->dispatch(Request::fromGlobals())->send();
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: .htaccess** — `cloud/public/.htaccess`
|
- [ ] **Step 4: .htaccess** — `server/public/.htaccess`
|
||||||
|
|
||||||
```apache
|
```apache
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
@ -1994,8 +1994,8 @@ RewriteRule ^ index.php [QSA,L]
|
||||||
Run: `./vendor/bin/phpunit` (전체) — green.
|
Run: `./vendor/bin/phpunit` (전체) — green.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/public cloud/src/View cloud/tests/WiringSmokeTest.php
|
git add server/public server/src/View server/tests/WiringSmokeTest.php
|
||||||
git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
|
git commit -m "feat(server): front controller wiring + views + zone-draw UI"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -2005,13 +2005,13 @@ git commit -m "feat(cloud): front controller wiring + views + zone-draw UI"
|
||||||
> ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차.
|
> ⚠️ **DB 통합 태스크.** 로컬에 PDO 드라이버 미로드 시 이 태스크의 DB 실행 테스트는 **게이트**(로컬에서 `pdo_sqlite` 활성 or Cafe24 MySQL). 단위 로직은 이미 T1~T15에서 mock repo로 검증됨. 이 태스크는 PDO 구현 + DDL + 배포 절차.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `cloud/src/Db/Database.php`, `cloud/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `cloud/schema.sql`, `cloud/docs/cloud-deploy.md`, `cloud/tests/PdoIntegrationTest.php`
|
- Create: `server/src/Db/Database.php`, `server/src/Repository/Pdo/Pdo{Device,Nonce,RateLimiter,Event,Snapshot,User,Zone,Threshold,Audit}Repository.php` (9개), `server/schema.sql`, `server/docs/server-deploy.md`, `server/tests/PdoIntegrationTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: repository 인터페이스(T3), `Config`(T1)
|
- Consumes: repository 인터페이스(T3), `Config`(T1)
|
||||||
- Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement).
|
- Produces: `App\Db\Database::connect(Config): PDO` (ERRMODE_EXCEPTION, prepared). 각 `Pdo*Repository`가 인터페이스 구현(전부 prepared statement).
|
||||||
|
|
||||||
- [ ] **Step 1: Database factory** — `cloud/src/Db/Database.php`
|
- [ ] **Step 1: Database factory** — `server/src/Db/Database.php`
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -2035,7 +2035,7 @@ final class Database
|
||||||
|
|
||||||
- [ ] **Step 2: schema.sql** — MySQL DDL (P1-B 서브셋: users, devices, cameras, zones, events, event_snapshots, thresholds, nonces, rate_limits, audit_log). §7.4 스펙 기준. `event_type`/`severity`/`level`은 애플리케이션 화이트리스트(+ 선택 CHECK). `nonces(nonce PK, expires_at INDEX)`, `rate_limits(rate_key, window)` 유니크. sqlite 호환 노트(AUTOINCREMENT 등) 주석. metrics/polygon_json은 TEXT/JSON.
|
- [ ] **Step 2: schema.sql** — MySQL DDL (P1-B 서브셋: users, devices, cameras, zones, events, event_snapshots, thresholds, nonces, rate_limits, audit_log). §7.4 스펙 기준. `event_type`/`severity`/`level`은 애플리케이션 화이트리스트(+ 선택 CHECK). `nonces(nonce PK, expires_at INDEX)`, `rate_limits(rate_key, window)` 유니크. sqlite 호환 노트(AUTOINCREMENT 등) 주석. metrics/polygon_json은 TEXT/JSON.
|
||||||
|
|
||||||
- [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `cloud/src/Repository/Pdo/PdoNonceRepository.php`:
|
- [ ] **Step 3: PDO repositories (9개)** — 각 인터페이스를 prepared statement로 구현. 예 `server/src/Repository/Pdo/PdoNonceRepository.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -2069,7 +2069,7 @@ final class PdoNonceRepository implements NonceRepository
|
||||||
- `PdoThresholdRepository`: forDevice(SELECT key/value → assoc).
|
- `PdoThresholdRepository`: forDevice(SELECT key/value → assoc).
|
||||||
- `PdoAuditRepository`: append(INSERT, before/after=json_encode).
|
- `PdoAuditRepository`: append(INSERT, before/after=json_encode).
|
||||||
|
|
||||||
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `cloud/tests/PdoIntegrationTest.php`:
|
- [ ] **Step 4: (게이트) sqlite 통합 테스트** — `server/tests/PdoIntegrationTest.php`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
|
|
@ -2082,15 +2082,15 @@ protected function setUp(): void {
|
||||||
|
|
||||||
로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지.
|
로컬 PDO 미로드 환경에서도 `markTestSkipped`로 스위트 green 유지.
|
||||||
|
|
||||||
- [ ] **Step 5: 배포 문서** — `cloud/docs/cloud-deploy.md`: Cafe24 업로드 절차(`public/`만 웹루트; `storage/`·`src/`·`config.php`는 웹루트 밖 또는 상위 디렉토리), `config.php` 작성(config.example.php 복사), `schema.sql` phpMyAdmin import, per-device secret 발급(admin), 최초 admin 계정 생성 CLI 스니펫(`password_hash` argon2id), HTTPS/HSTS 강제, PHP 버전·gd·openssl 확인, `vendor/`는 배포 제외(런타임 의존성 0), storage/snapshots 쓰기권한.
|
- [ ] **Step 5: 배포 문서** — `server/docs/server-deploy.md`: Cafe24 업로드 절차(`public/`만 웹루트; `storage/`·`src/`·`config.php`는 웹루트 밖 또는 상위 디렉토리), `config.php` 작성(config.example.php 복사), `schema.sql` phpMyAdmin import, per-device secret 발급(admin), 최초 admin 계정 생성 CLI 스니펫(`password_hash` argon2id), HTTPS/HSTS 강제, PHP 버전·gd·openssl 확인, `vendor/`는 배포 제외(런타임 의존성 0), storage/snapshots 쓰기권한.
|
||||||
|
|
||||||
- [ ] **Step 6: 전체 스위트 + 커밋**
|
- [ ] **Step 6: 전체 스위트 + 커밋**
|
||||||
|
|
||||||
Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip).
|
Run: `./vendor/bin/phpunit` — green(통합은 sqlite 없으면 skip).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/Db cloud/src/Repository/Pdo cloud/schema.sql cloud/docs/cloud-deploy.md cloud/tests/PdoIntegrationTest.php
|
git add server/src/Db server/src/Repository/Pdo server/schema.sql server/docs/server-deploy.md server/tests/PdoIntegrationTest.php
|
||||||
git commit -m "feat(cloud): PDO repositories + schema + Cafe24 deploy doc"
|
git commit -m "feat(server): PDO repositories + schema + Cafe24 deploy doc"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -2118,7 +2118,7 @@ git commit -m "feat(cloud): PDO repositories + schema + Cafe24 deploy doc"
|
||||||
|
|
||||||
## Execution Handoff
|
## Execution Handoff
|
||||||
|
|
||||||
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-cloud-ingest-dashboard.md`.**
|
**Plan complete. 저장: `docs/superpowers/plans/2026-07-11-p1b-server-ingest-dashboard.md`.**
|
||||||
|
|
||||||
실행 옵션:
|
실행 옵션:
|
||||||
1. **Subagent-Driven (추천)** — 태스크마다 새 subagent + 리뷰. `superpowers:subagent-driven-development`.
|
1. **Subagent-Driven (추천)** — 태스크마다 새 subagent + 리뷰. `superpowers:subagent-driven-development`.
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(클라우드) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 클라우드 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.)
|
**Goal:** 이미 구현·병합된 P1-A(엣지 감지 라이브러리) + P1-B(서버) 사이의 **끊긴 배선을 연결**해, 노트북 웹캠으로 실행되는 **엔드투엔드 파일럿**을 만든다: 실제 프레임 → CPU pose → 감지 → 서명 POST → 서버 → 관리자가 **사진 보고 알림 받고 확인**. 역루프(대시보드 구역 → 엣지 적용)와 하트비트도 살린다. (실장비 Hailo·폐쇄망 번들·드릴 게이트는 별도 후속.)
|
||||||
|
|
||||||
**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 클라우드 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) CloudClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 클라우드 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트.
|
**Architecture:** 순수 배선. 기존 부품(`run_once`, EventEngine, PostClient, OfflineQueue, ZoneIntrusionDetector, 서버 ingest/detail/snapshot)은 그대로 두고 (1) 엣지 데몬(main 루프)이 부품을 조립·구동, (2) ServerClient가 config 폴링·하트비트, (3) MoveNet ONNX 어댑터가 PosePort 구현, (4) 서버 detail 뷰·Web Audio가 응답루프 완성. 모든 데몬/클라이언트 로직은 주입(fake source/pose/clock/http)으로 TDD; 실 pose는 정적이미지로 게이트 테스트.
|
||||||
|
|
||||||
**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 클라우드는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지.
|
**Tech Stack:** Python 3.11+(dev 3.13), pytest, numpy, opencv, requests, **onnxruntime**(CPU) + **MoveNet SinglePose Lightning ONNX**(COCO-17, Apache-2). 서버는 기존 바닐라 PHP + JS(Web Audio, 의존성 0). 런타임 ultralytics(AGPL) 금지 유지.
|
||||||
|
|
||||||
## Global Constraints
|
## Global Constraints
|
||||||
|
|
||||||
- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 클라우드 스위트 **0 warnings/0 deprecations** 유지.
|
- Python **3.11+**, 타입힌트, `edge/` 기존 패턴 준수. PHP 8.5 `declare(strict_types=1)`, 서버 스위트 **0 warnings/0 deprecations** 유지.
|
||||||
- **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`).
|
- **판정/루프 로직은 주입 타임스탬프·주입 의존성으로 TDD** — 데몬 루프 내부에서 `time.sleep`/`time.time` 직접 호출 금지(주입된 clock/sleeper 사용). 실 pose 추론만 게이트(모델/onnxruntime 없으면 `skip`).
|
||||||
- **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐.
|
- **폐쇄망 지속:** 새 의존성(onnxruntime)·모델(.onnx)은 **빌드머신서 받아 번들**할 것 — 후속 패키징 태스크 대상. 런타임 인터넷은 Cafe24 POST + config 폴링뿐.
|
||||||
- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(클라우드 T14가 이미 검증). PostClient 서명 재사용.
|
- **HMAC 계약 유지:** config 폴링·하트비트도 P1-A `sign(nonce+ts+body)` 동일 서명(서버 T14가 이미 검증). PostClient 서명 재사용.
|
||||||
- **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑.
|
- **키포인트 계약:** PosePort는 `list[Person]` (COCO-17 `Keypoint`) 반환 — MoveNet 17관절을 그 순서로 매핑.
|
||||||
- YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음.
|
- YAGNI: 파일럿에 필요한 배선만. 멀티캠·감도·처리결과 등 P2/P3는 손대지 않음.
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
edge/
|
edge/
|
||||||
cloudclient.py # CloudClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
|
serverclient.py # ServerClient: fetch_config(HMAC GET), send_heartbeat(HMAC POST)
|
||||||
zones.py # zone rows(dict) -> Zone objects; thresholds
|
zones.py # zone rows(dict) -> Zone objects; thresholds
|
||||||
runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones()
|
runtime.py # PipelineRuntime: assemble source/pose/tracker/smoother/engine/ring/snapshotter/sink; step(); apply_zones()
|
||||||
daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main()
|
daemon.py # main loop: step every frame + periodic sync/heartbeat/queue-flush; signals; main()
|
||||||
|
|
@ -30,33 +30,33 @@ edge/
|
||||||
movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap
|
movenet_pose.py # MoveNetPoseAdapter(PosePort) via onnxruntime + COCO-17 remap
|
||||||
tools/
|
tools/
|
||||||
fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/
|
fetch_movenet.py # build-machine: download MoveNet Lightning ONNX to models/
|
||||||
cloud/
|
server/
|
||||||
src/View/event_detail.php # (replace stub) HTML detail shell + script
|
src/View/event_detail.php # (replace stub) HTML detail shell + script
|
||||||
public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}>
|
public/assets/detail.js # fetch GET /events/{id} -> render meta + <img src=/snap/{uuid}>
|
||||||
public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical
|
public/assets/app.js # (edit) link rows to /events/{id}/view, desk Web Audio on new critical
|
||||||
public/index.php # (edit) add GET /events/{id}/view HTML route
|
public/index.php # (edit) add GET /events/{id}/view HTML route
|
||||||
tests/ (edge) + cloud/tests/
|
tests/ (edge) + server/tests/
|
||||||
docs/pilot-runbook.md
|
docs/pilot-runbook.md
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 1: CloudClient — config 폴링 + 하트비트 (HMAC 서명)
|
## Task 1: ServerClient — config 폴링 + 하트비트 (HMAC 서명)
|
||||||
|
|
||||||
**Files:** Create `edge/cloudclient.py`, `tests/test_cloudclient.py`
|
**Files:** Create `edge/serverclient.py`, `tests/test_serverclient.py`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
|
- Consumes: `sign` (edge/alert.py), injected `http_get`/`http_post`, `nonce_fn`, `now_fn`.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `CloudClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
|
- `ServerClient(base_url, device_id, secret, nonce_fn, now_fn, http_get=None, http_post=None, timeout=5.0)`
|
||||||
- `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None.
|
- `fetch_config() -> dict | None` — GET `{base}/api/config?device_id=`, headers X-Nonce/X-Timestamp/X-Signature (sign over empty body), returns parsed `{version, zones, thresholds}` on 200 else None.
|
||||||
- `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx.
|
- `send_heartbeat(metrics: dict) -> bool` — POST `{base}/api/heartbeat` body `{device_id, **metrics}` signed, True on 2xx.
|
||||||
|
|
||||||
- [ ] **Step 1: 실패 테스트** — `tests/test_cloudclient.py`
|
- [ ] **Step 1: 실패 테스트** — `tests/test_serverclient.py`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import json
|
import json
|
||||||
from edge.cloudclient import CloudClient
|
from edge.serverclient import ServerClient
|
||||||
from edge.alert import sign
|
from edge.alert import sign
|
||||||
|
|
||||||
class _Resp:
|
class _Resp:
|
||||||
|
|
@ -68,30 +68,30 @@ def test_fetch_config_signs_and_parses():
|
||||||
def fake_get(url, headers=None, timeout=None):
|
def fake_get(url, headers=None, timeout=None):
|
||||||
captured["url"] = url; captured["headers"] = headers
|
captured["url"] = url; captured["headers"] = headers
|
||||||
return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}}))
|
return _Resp(200, json.dumps({"version": 3, "zones": [], "thresholds": {}}))
|
||||||
c = CloudClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
|
c = ServerClient("https://x", "pi-1", "sek", nonce_fn=lambda: "n1", now_fn=lambda: 1000.0, http_get=fake_get)
|
||||||
cfg = c.fetch_config()
|
cfg = c.fetch_config()
|
||||||
assert cfg["version"] == 3
|
assert cfg["version"] == 3
|
||||||
assert "device_id=pi-1" in captured["url"]
|
assert "device_id=pi-1" in captured["url"]
|
||||||
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "")
|
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", "")
|
||||||
|
|
||||||
def test_fetch_config_none_on_error():
|
def test_fetch_config_none_on_error():
|
||||||
c = CloudClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
|
c = ServerClient("https://x", "pi-1", "sek", lambda: "n", lambda: 1.0, http_get=lambda *a, **k: _Resp(401))
|
||||||
assert c.fetch_config() is None
|
assert c.fetch_config() is None
|
||||||
|
|
||||||
def test_send_heartbeat_signs_body():
|
def test_send_heartbeat_signs_body():
|
||||||
captured = {}
|
captured = {}
|
||||||
def fake_post(url, data=None, headers=None, timeout=None):
|
def fake_post(url, data=None, headers=None, timeout=None):
|
||||||
captured["data"] = data; captured["headers"] = headers; return _Resp(200)
|
captured["data"] = data; captured["headers"] = headers; return _Resp(200)
|
||||||
c = CloudClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
|
c = ServerClient("https://x", "pi-1", "sek", lambda: "n1", lambda: 1000.0, http_post=fake_post)
|
||||||
assert c.send_heartbeat({"fps": 12.0}) is True
|
assert c.send_heartbeat({"fps": 12.0}) is True
|
||||||
body = captured["data"]
|
body = captured["data"]
|
||||||
assert json.loads(body)["device_id"] == "pi-1"
|
assert json.loads(body)["device_id"] == "pi-1"
|
||||||
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body)
|
assert captured["headers"]["X-Signature"] == sign("sek", "n1", "1000.0", body)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: 실패 확인** — `pytest tests/test_cloudclient.py -v` → FAIL (ModuleNotFoundError)
|
- [ ] **Step 2: 실패 확인** — `pytest tests/test_serverclient.py -v` → FAIL (ModuleNotFoundError)
|
||||||
|
|
||||||
- [ ] **Step 3: 구현** — `edge/cloudclient.py`
|
- [ ] **Step 3: 구현** — `edge/serverclient.py`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import json
|
import json
|
||||||
|
|
@ -99,7 +99,7 @@ import json
|
||||||
from edge.alert import sign
|
from edge.alert import sign
|
||||||
|
|
||||||
|
|
||||||
class CloudClient:
|
class ServerClient:
|
||||||
def __init__(self, base_url: str, device_id: str, secret: str,
|
def __init__(self, base_url: str, device_id: str, secret: str,
|
||||||
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
|
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
|
|
@ -146,16 +146,16 @@ class CloudClient:
|
||||||
return 200 <= resp.status_code < 300
|
return 200 <= resp.status_code < 300
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_cloudclient.py -v` → PASS
|
- [ ] **Step 4: 통과 + 커밋** — `pytest tests/test_serverclient.py -v` → PASS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add edge/cloudclient.py tests/test_cloudclient.py
|
git add edge/serverclient.py tests/test_serverclient.py
|
||||||
git commit -m "feat(edge): CloudClient for config poll + heartbeat (HMAC-signed)"
|
git commit -m "feat(edge): ServerClient for config poll + heartbeat (HMAC-signed)"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 2: 클라우드 zone 행 → Zone 객체 로딩
|
## Task 2: 서버 zone 행 → Zone 객체 로딩
|
||||||
|
|
||||||
**Files:** Create `edge/zones.py`, `tests/test_zones.py`
|
**Files:** Create `edge/zones.py`, `tests/test_zones.py`
|
||||||
|
|
||||||
|
|
@ -225,7 +225,7 @@ def thresholds_from_config(config: dict) -> dict:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add edge/zones.py tests/test_zones.py
|
git add edge/zones.py tests/test_zones.py
|
||||||
git commit -m "feat(edge): parse cloud config zones/thresholds into Zone objects"
|
git commit -m "feat(edge): parse server config zones/thresholds into Zone objects"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -341,7 +341,7 @@ git commit -m "feat(edge): PipelineRuntime assembles components + hot-swaps zone
|
||||||
**Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml`
|
**Files:** Create `edge/daemon.py`, `edge/__main__.py`, `tests/test_daemon.py`; Modify `pyproject.toml`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `PipelineRuntime` (T3), `CloudClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
|
- Consumes: `PipelineRuntime` (T3), `ServerClient` (T1), `zones_from_config` (T2), `OfflineQueue` (edge/queue.py), `PostClient` (edge/alert.py).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict.
|
- `run_loop(runtime, client, queue, *, clock, sleep, iterations=None, sync_every=150, heartbeat_every=900, flush_every=300, frame_interval=0.0) -> dict` — `iterations` 프레임 루프(None이면 무한, 테스트는 유한). 매 프레임 `runtime.step(clock())`(예외 삼켜 카운트); sync_every 프레임마다 `client.fetch_config()`→`runtime.apply_zones(zones_from_config(...))`; heartbeat_every마다 `send_heartbeat`; flush_every마다 `queue.flush()`. 반환 카운터 dict.
|
||||||
- `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리)
|
- `main()` — env로 구성 후 무한 루프. `edge/__main__.py`가 호출. (`# pragma: no cover` — 실장비 엔트리)
|
||||||
|
|
@ -427,7 +427,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
|
def main() -> None: # pragma: no cover (integration entrypoint, exercised on device)
|
||||||
import time
|
import time
|
||||||
from edge.config import load_config
|
from edge.config import load_config
|
||||||
from edge.cloudclient import CloudClient
|
from edge.serverclient import ServerClient
|
||||||
from edge.alert import PostClient
|
from edge.alert import PostClient
|
||||||
from edge.queue import OfflineQueue
|
from edge.queue import OfflineQueue
|
||||||
from edge.runtime import PipelineRuntime
|
from edge.runtime import PipelineRuntime
|
||||||
|
|
@ -438,7 +438,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
|
||||||
nonce = lambda: os.urandom(8).hex()
|
nonce = lambda: os.urandom(8).hex()
|
||||||
now = time.time
|
now = time.time
|
||||||
base = cfg.cafe24_url.rstrip("/")
|
base = cfg.cafe24_url.rstrip("/")
|
||||||
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
|
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now)
|
||||||
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
|
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
|
||||||
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
|
queue = OfflineQueue(os.environ.get("QUEUE_DB", "queue.db"), post)
|
||||||
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
|
source = UvcSource(int(os.environ.get("UVC_DEVICE", "0")))
|
||||||
|
|
@ -591,15 +591,15 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 6: 클라우드 사고 상세 뷰 — 스냅샷 표시
|
## Task 6: 서버 사고 상세 뷰 — 스냅샷 표시
|
||||||
|
|
||||||
**Files:** Modify `cloud/src/View/event_detail.php`; Create `cloud/public/assets/detail.js`; Modify `cloud/public/index.php`; Modify `cloud/tests/WiringSmokeTest.php`
|
**Files:** Modify `server/src/View/event_detail.php`; Create `server/public/assets/detail.js`; Modify `server/public/index.php`; Modify `server/tests/WiringSmokeTest.php`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image).
|
- Consumes: existing `GET /events/{id}` (JSON: event+snapshots) + `GET /snap/{uuid}` (auth image).
|
||||||
- Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe).
|
- Produces: `GET /events/{id}/view` → renders `event_detail.php` (HTML shell + `<script src="/assets/detail.js">`). `detail.js` parses the id from the URL, fetches `GET /events/{id}`, renders meta + each snapshot as `<img src="/snap/{uuid}">` (createElement, XSS-safe).
|
||||||
|
|
||||||
- [ ] **Step 1: 스모크 테스트 갱신** — `cloud/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd cloud && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
|
- [ ] **Step 1: 스모크 테스트 갱신** — `server/tests/WiringSmokeTest.php`의 paths 배열에 `['GET','/events/{id}/view']` 추가. `cd server && ./vendor/bin/phpunit tests/WiringSmokeTest.php` — (index 라우트 추가 전이라 실제 앱엔 없지만 스모크는 자체 라우터에 등록하므로) 통과 유지 확인. (스모크는 라우터 매칭만 검증.)
|
||||||
|
|
||||||
- [ ] **Step 2: event_detail.php (스텁 교체)**
|
- [ ] **Step 2: event_detail.php (스텁 교체)**
|
||||||
|
|
||||||
|
|
@ -612,7 +612,7 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
|
||||||
<script src="/assets/detail.js" defer></script>
|
<script src="/assets/detail.js" defer></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: detail.js** — `cloud/public/assets/detail.js`
|
- [ ] **Step 3: detail.js** — `server/public/assets/detail.js`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
(function () {
|
(function () {
|
||||||
|
|
@ -653,18 +653,18 @@ git commit -m "feat(edge): MoveNet ONNX CPU pose adapter (COCO-17 remap)"
|
||||||
$r->add('GET', '/events/{id}/view', fn() => $render('event_detail'));
|
$r->add('GET', '/events/{id}/view', fn() => $render('event_detail'));
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd cloud && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l cloud/public/index.php`.
|
- [ ] **Step 5: 통과 + 커밋** — 전체 `cd server && ./vendor/bin/phpunit --display-warnings --display-deprecations` green; `php -l server/public/index.php`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/src/View/event_detail.php cloud/public/assets/detail.js cloud/public/index.php cloud/tests/WiringSmokeTest.php
|
git add server/src/View/event_detail.php server/public/assets/detail.js server/public/index.php server/tests/WiringSmokeTest.php
|
||||||
git commit -m "feat(cloud): incident detail view renders snapshot images"
|
git commit -m "feat(server): incident detail view renders snapshot images"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
|
## Task 7: 응답 루프 — 목록 링크 + 데스크 Web Audio
|
||||||
|
|
||||||
**Files:** Modify `cloud/public/assets/app.js`
|
**Files:** Modify `server/public/assets/app.js`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `GET /api/events` (기존 폴링).
|
- Consumes: `GET /api/events` (기존 폴링).
|
||||||
|
|
@ -702,11 +702,11 @@ function updateAlarm(events) {
|
||||||
|
|
||||||
그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출.
|
그리고: (a) 행 빌더에서 각 행을 `/events/{event_id}/view` 링크로 감싸기 — `var a = document.createElement('a'); a.href = '/events/' + e.event_id + '/view'; a.appendChild(rowEl);` 후 컨테이너에 append. (b) 폴링 성공 콜백에서 `renderList(events)` 뒤에 `updateAlarm(events)` 호출.
|
||||||
|
|
||||||
- [ ] **Step 2: 통과 + 커밋** — `node --check cloud/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
|
- [ ] **Step 2: 통과 + 커밋** — `node --check server/public/assets/app.js`(node 있으면), 전체 phpunit green(JS만 변경).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add cloud/public/assets/app.js
|
git add server/public/assets/app.js
|
||||||
git commit -m "feat(cloud): dashboard rows link to detail + desk Web Audio on critical"
|
git commit -m "feat(server): dashboard rows link to detail + desk Web Audio on critical"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -735,8 +735,8 @@ def test_real_movenet_runs():
|
||||||
|
|
||||||
- [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차:
|
- [ ] **Step 2: 파일럿 런북** — `docs/pilot-runbook.md`, 노트북 파일럿 절차:
|
||||||
1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`).
|
1. **빌드:** `pip install onnxruntime`; `python -m edge.tools.fetch_movenet` (모델 → `models/`).
|
||||||
2. **클라우드:** `php -S localhost:8000 -t cloud/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록.
|
2. **서버:** `php -S localhost:8000 -t server/public` (config.php에 로컬 DB) 또는 Cafe24. admin 계정 생성 + device secret을 `devices` 테이블에 등록.
|
||||||
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(클라우드와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
|
3. **env:** `DEVICE_ID`, `DEVICE_SECRET`(서버와 일치), `CAFE24_URL`(예 http://localhost:8000), `MOVENET_ONNX=models/movenet_lightning.onnx`, `UVC_DEVICE=0`, `TARGET_FPS=12`.
|
||||||
4. **실행:** `move-detect-edge` (또는 `python -m edge`).
|
4. **실행:** `move-detect-edge` (또는 `python -m edge`).
|
||||||
5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush.
|
5. **데모:** 웹캠 앞 눕기 → 3초 후 fall, 10초 후 no_motion(위급, 데스크 비프) → 목록행 클릭 → 상세서 스냅샷 확인 → 확인처리(비프 멈춤). 대시보드서 구역 그리기 → 발끝 진입 → 침입 이벤트(역루프). 네트워크 끊고 이벤트 → 복구 후 큐 flush.
|
||||||
6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속.
|
6. **한계(명시):** 단일캠·CPU pose(저fps)·SMS 미연동(Web Audio만)·前中後 아닌 단일프레임. 실장비 Hailo·폐쇄망 번들·드릴 게이트·F14 컴플라이언스는 후속.
|
||||||
|
|
@ -753,7 +753,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
|
||||||
## Self-Review
|
## Self-Review
|
||||||
|
|
||||||
**1. 최소경로 커버리지 (기능검토 1~7):**
|
**1. 최소경로 커버리지 (기능검토 1~7):**
|
||||||
- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→클라우드 흐름(3) → T4 데몬+기존 PostClient ✓
|
- 엣지 데몬(1) → T4 ✓ · CPU pose(2) → T5 ✓ · edge→서버 흐름(3) → T4 데몬+기존 PostClient ✓
|
||||||
- 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓
|
- 사고 가시화(4) → T6 ✓ · 응답 1채널(5) → T7 Web Audio ✓
|
||||||
- config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓
|
- config-sync 역루프(6) → T1+T2+T3(apply_zones)+T4(주기 sync) ✓ · 하트비트(7) → T1+T4 ✓ · 큐 flush(rank7) → T4 ✓
|
||||||
- **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지).
|
- **범위 밖(명시):** 실 Hailo(하드웨어), 폐쇄망 전체 번들(T5/T8은 fetch+게이트만; 번들·차단설치는 후속 패키징), 드릴 게이트(P1-C), F14 컴플라이언스(별도), 前中後 링버퍼(rank11 — 파일럿 단일프레임 유지).
|
||||||
|
|
@ -761,7 +761,7 @@ git commit -m "docs: pilot runbook + gated MoveNet smoke test"
|
||||||
|
|
||||||
**2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시).
|
**2. Placeholder scan:** 데몬/클라이언트/런타임/어댑터/zone은 실코드+TDD. T5 실모델 스모크·T8은 게이트(모델 없으면 skip) — 의도적. fetch URL은 빌드 편의(구현자 노트로 검증 지시).
|
||||||
|
|
||||||
**3. Type consistency:** `CloudClient.sign` = PostClient/클라우드 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관.
|
**3. Type consistency:** `ServerClient.sign` = PostClient/서버 T14 동일(`nonce+ts+body`). `PipelineRuntime.step`가 `run_once(source,pose,tracker,pool,engine,ring,snapshotter,sink,now_fn)` — P1-A 최종형(per-track smoother 수정 후) 일치. `apply_zones`가 `ZoneIntrusionDetector.zones` 재할당. MoveNet [1,1,17,3](y,x,score)→COCO-17 Keypoint(x,y,conf) 일관.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from edge.events import Event
|
||||||
|
|
||||||
# Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent
|
# Shared-hosting WAFs (cafe24) frequently block the default "python-requests/x.y" User-Agent
|
||||||
# with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC
|
# with an HTML 403 while curl passes — so every event would silently 403 with a correct HMAC
|
||||||
# and URL. Send an explicit UA on every request. CloudClient imports this too.
|
# and URL. Send an explicit UA on every request. ServerClient imports this too.
|
||||||
USER_AGENT = "move-detect-edge/1.0"
|
USER_AGENT = "move-detect-edge/1.0"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ class PostClient:
|
||||||
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
|
resp = self._post(self.url, data=body, headers=headers, timeout=self.timeout,
|
||||||
allow_redirects=False)
|
allow_redirects=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Connection refused / DNS / timeout — the cloud is unreachable, not rejecting us.
|
# Connection refused / DNS / timeout — the server is unreachable, not rejecting us.
|
||||||
return SendResult.TRANSIENT
|
return SendResult.TRANSIENT
|
||||||
code = resp.status_code
|
code = resp.status_code
|
||||||
if 200 <= code < 300:
|
if 200 <= code < 300:
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ def queue_db_path(env: dict) -> str:
|
||||||
# Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real
|
# Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real
|
||||||
# provisioned value. A unit left at any of these uploads nothing while looking healthy.
|
# provisioned value. A unit left at any of these uploads nothing while looking healthy.
|
||||||
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
|
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
|
||||||
# Per-device secrets issued by the cloud are HMAC-derived (long hex); anything shorter is a
|
# Per-device secrets issued by the server are HMAC-derived (long hex); anything shorter is a
|
||||||
# stub left in the template, not a real key.
|
# stub left in the template, not a real key.
|
||||||
_MIN_SECRET_LEN = 16
|
_MIN_SECRET_LEN = 16
|
||||||
|
|
||||||
|
|
@ -82,13 +82,13 @@ def require_upload_config(cfg: Config) -> None:
|
||||||
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
|
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
|
||||||
elif len(secret) < _MIN_SECRET_LEN:
|
elif len(secret) < _MIN_SECRET_LEN:
|
||||||
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
|
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
|
||||||
"per-device secret issued by the cloud, not a stub")
|
"per-device secret issued by the server, not a stub")
|
||||||
|
|
||||||
if problems:
|
if problems:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
|
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
|
||||||
"but upload nothing. CAFE24_URL must include the app path, e.g. "
|
"but upload nothing. CAFE24_URL must include the app path, e.g. "
|
||||||
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the cloud."
|
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the server."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ def run_loop(runtime, client, queue, *, clock, sleep, iterations=None,
|
||||||
counters["syncs"] += 1
|
counters["syncs"] += 1
|
||||||
if heartbeat_every and i % heartbeat_every == 0:
|
if heartbeat_every and i % heartbeat_every == 0:
|
||||||
# Measured fps over the window since the last beat, and per-camera health, so
|
# Measured fps over the window since the last beat, and per-camera health, so
|
||||||
# the cloud can tell a live device from a degraded/dead one (was hardcoded 0.0
|
# the server can tell a live device from a degraded/dead one (was hardcoded 0.0
|
||||||
# + a frames counter that ticked even when no frame was read).
|
# + a frames counter that ticked even when no frame was read).
|
||||||
t0, f0 = beat_anchor
|
t0, f0 = beat_anchor
|
||||||
elapsed = now - t0
|
elapsed = now - t0
|
||||||
|
|
@ -133,7 +133,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
|
from edge.config import (frame_interval_from_env, load_config, queue_db_path,
|
||||||
require_upload_config)
|
require_upload_config)
|
||||||
from edge.cloudclient import CloudClient
|
from edge.serverclient import ServerClient
|
||||||
from edge.alert import PostClient
|
from edge.alert import PostClient
|
||||||
from edge.queue import OfflineQueue
|
from edge.queue import OfflineQueue
|
||||||
from edge.runtime import PipelineRuntime
|
from edge.runtime import PipelineRuntime
|
||||||
|
|
@ -161,7 +161,7 @@ def main() -> None: # pragma: no cover (integration entrypoint, exercised on de
|
||||||
nonce = lambda: os.urandom(8).hex()
|
nonce = lambda: os.urandom(8).hex()
|
||||||
now = time.time
|
now = time.time
|
||||||
base = cfg.cafe24_url.rstrip("/")
|
base = cfg.cafe24_url.rstrip("/")
|
||||||
client = CloudClient(base, cfg.device_id, cfg.secret, nonce, now)
|
client = ServerClient(base, cfg.device_id, cfg.secret, nonce, now)
|
||||||
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
|
post = PostClient(base + "/api/events", cfg.device_id, cfg.secret, nonce, now)
|
||||||
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
|
queue = OfflineQueue(queue_db_path(dict(os.environ)), post)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ class Event:
|
||||||
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
|
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
|
||||||
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
|
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
|
||||||
# offline queue and every retry, so a POST that succeeded server-side but timed out
|
# offline queue and every retry, so a POST that succeeded server-side but timed out
|
||||||
# client-side is deduplicated by the cloud (see cloud IngestController) instead of stored twice.
|
# client-side is deduplicated by the server (see server IngestController) instead of stored twice.
|
||||||
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
|
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
|
||||||
|
|
||||||
def to_payload(self, device_id: str) -> dict:
|
def to_payload(self, device_id: str) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class OfflineQueue:
|
||||||
|
|
||||||
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
|
def _rehydrate(self, payload: dict, snaps: list[bytes]) -> tuple[Event, list[bytes]]:
|
||||||
# Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the
|
# Preserve the stored event_uuid so a re-sent event keeps its idempotency key and the
|
||||||
# cloud dedups it. Rows queued before this field existed simply get a fresh uuid (they
|
# server dedups it. Rows queued before this field existed simply get a fresh uuid (they
|
||||||
# predate dedup, so a first-and-only delivery is correct for them).
|
# predate dedup, so a first-and-only delivery is correct for them).
|
||||||
uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
|
uuid_kw = {"event_uuid": payload["event_uuid"]} if payload.get("event_uuid") else {}
|
||||||
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
|
e = Event(EventType(payload["event_type"]), Severity(payload["severity"]),
|
||||||
|
|
@ -70,7 +70,7 @@ class OfflineQueue:
|
||||||
# A row queued before event_uuid existed has none stored; _rehydrate minted a fresh
|
# A row queued before event_uuid existed has none stored; _rehydrate minted a fresh
|
||||||
# one just now. Persist it so every retry of this row reuses the SAME key — otherwise
|
# one just now. Persist it so every retry of this row reuses the SAME key — otherwise
|
||||||
# each flush sends a different uuid and a timeout-retried delivery duplicates on the
|
# each flush sends a different uuid and a timeout-retried delivery duplicates on the
|
||||||
# cloud (the P1-9 bug, still open for transitional rows).
|
# server (the P1-9 bug, still open for transitional rows).
|
||||||
if not payload.get("event_uuid"):
|
if not payload.get("event_uuid"):
|
||||||
payload["event_uuid"] = event.event_uuid
|
payload["event_uuid"] = event.event_uuid
|
||||||
self.db.execute("UPDATE queue SET payload = ? WHERE id = ?",
|
self.db.execute("UPDATE queue SET payload = ? WHERE id = ?",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import json
|
||||||
from edge.alert import USER_AGENT, sign
|
from edge.alert import USER_AGENT, sign
|
||||||
|
|
||||||
|
|
||||||
class CloudClient:
|
class ServerClient:
|
||||||
def __init__(self, base_url: str, device_id: str, secret: str,
|
def __init__(self, base_url: str, device_id: str, secret: str,
|
||||||
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
|
nonce_fn, now_fn, http_get=None, http_post=None, timeout: float = 5.0):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
|
|
@ -18,7 +18,7 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
def zones_from_config(config: dict, camera_id: str | None = None) -> list[Zone]:
|
||||||
"""Parse cloud config zone rows into Zone objects. When camera_id is given, only
|
"""Parse server config zone rows into Zone objects. When camera_id is given, only
|
||||||
rows for that camera are kept (multi-camera routing); None keeps all rows."""
|
rows for that camera are kept (multi-camera routing); None keeps all rows."""
|
||||||
out: list[Zone] = []
|
out: list[Zone] = []
|
||||||
rows = config.get("zones", []) or []
|
rows = config.get("zones", []) or []
|
||||||
|
|
|
||||||
0
cloud/.gitignore → server/.gitignore
vendored
0
cloud/.gitignore → server/.gitignore
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "move-detect/cloud",
|
"name": "move-detect/server",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"require": { "php": ">=8.2" },
|
"require": { "php": ">=8.2" },
|
||||||
"require-dev": { "phpunit/phpunit": "^11" },
|
"require-dev": { "phpunit/phpunit": "^11" },
|
||||||
0
cloud/composer.lock → server/composer.lock
generated
0
cloud/composer.lock → server/composer.lock
generated
|
|
@ -5,11 +5,11 @@
|
||||||
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
|
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
|
||||||
# folder. The hosting account executes nothing — no shell, no composer, no installer: the
|
# folder. The hosting account executes nothing — no shell, no composer, no installer: the
|
||||||
# operator uploads files over FTP and edits config.php. That is also why this lives under
|
# operator uploads files over FTP and edits config.php. That is also why this lives under
|
||||||
# cloud/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
|
# server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
|
||||||
# root on the target.
|
# root on the target.
|
||||||
#
|
#
|
||||||
# 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):
|
||||||
# cloud/deploy/build_cafe24_package.sh
|
# server/deploy/build_cafe24_package.sh
|
||||||
#
|
#
|
||||||
# Output: dist/cafe24/ and dist/move-detect-cafe24.zip
|
# Output: dist/cafe24/ and dist/move-detect-cafe24.zip
|
||||||
#
|
#
|
||||||
|
|
@ -42,7 +42,7 @@ APP_DIR="${APP_DIR-move_detect}"
|
||||||
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
|
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
CLOUD="$REPO_ROOT/cloud"
|
SERVER="$REPO_ROOT/server"
|
||||||
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
|
||||||
STAGE="$OUT_DIR/cafe24"
|
STAGE="$OUT_DIR/cafe24"
|
||||||
ZIP="$OUT_DIR/move-detect-cafe24.zip"
|
ZIP="$OUT_DIR/move-detect-cafe24.zip"
|
||||||
|
|
@ -75,15 +75,15 @@ echo ">> [2/6] generating the no-dev autoloader"
|
||||||
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
|
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
|
||||||
# `new PhpSessionStore()` — the 4th statement of every request.
|
# `new PhpSessionStore()` — the 4th statement of every request.
|
||||||
#
|
#
|
||||||
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites cloud/vendor/
|
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/
|
||||||
# in place, so restore the dev autoloader on exit or local phpunit breaks.
|
# in place, so restore the dev autoloader on exit or local phpunit breaks.
|
||||||
restore_dev_autoloader() {
|
restore_dev_autoloader() {
|
||||||
echo ">> restoring the dev autoloader in cloud/vendor (so local phpunit keeps working)"
|
echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)"
|
||||||
( cd "$CLOUD" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
|
( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
|
||||||
}
|
}
|
||||||
trap restore_dev_autoloader EXIT
|
trap restore_dev_autoloader EXIT
|
||||||
|
|
||||||
( cd "$CLOUD" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
|
( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
|
||||||
|
|
||||||
# Precondition for everything below: this package stages ONLY composer's generated
|
# Precondition for everything below: this package stages ONLY composer's generated
|
||||||
# autoloader, which is correct exactly while the app has no runtime packages. The day
|
# autoloader, which is correct exactly while the app has no runtime packages. The day
|
||||||
|
|
@ -91,7 +91,7 @@ trap restore_dev_autoloader EXIT
|
||||||
# the host fatals on a missing class. Fail here, on the commit that adds the dependency,
|
# the host fatals on a missing class. Fail here, on the commit that adds the dependency,
|
||||||
# rather than on the hosting account.
|
# rather than on the hosting account.
|
||||||
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
|
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
|
||||||
"$CLOUD/composer.lock" || {
|
"$SERVER/composer.lock" || {
|
||||||
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
|
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
|
||||||
echo " autoloader, so their code would be missing from the package. Teach" >&2
|
echo " autoloader, so their code would be missing from the package. Teach" >&2
|
||||||
echo " step [3/6] to stage the real vendor tree before shipping again." >&2
|
echo " step [3/6] to stage the real vendor tree before shipping again." >&2
|
||||||
|
|
@ -100,14 +100,14 @@ php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ?
|
||||||
|
|
||||||
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
|
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
|
||||||
mkdir -p "$PRIV/vendor/composer"
|
mkdir -p "$PRIV/vendor/composer"
|
||||||
cp "$CLOUD/vendor/autoload.php" "$PRIV/vendor/autoload.php"
|
cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php"
|
||||||
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in
|
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in
|
||||||
# phpunit/, sebastian/, myclabs/ and friends.
|
# phpunit/, sebastian/, myclabs/ and friends.
|
||||||
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
|
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
|
||||||
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
|
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
|
||||||
cp "$CLOUD/vendor/composer/$f" "$PRIV/vendor/composer/$f"
|
cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f"
|
||||||
done
|
done
|
||||||
cp "$CLOUD/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
|
cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
|
||||||
|
|
||||||
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
|
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
|
||||||
# regenerate them — they are the package inventory written by the last `composer install`,
|
# regenerate them — they are the package inventory written by the last `composer install`,
|
||||||
|
|
@ -141,18 +141,18 @@ if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ">> [4/6] staging app source"
|
echo ">> [4/6] staging app source"
|
||||||
cp -r "$CLOUD/src/." "$PRIV/src/"
|
cp -r "$SERVER/src/." "$PRIV/src/"
|
||||||
cp -r "$CLOUD/public/." "$WEB/"
|
cp -r "$SERVER/public/." "$WEB/"
|
||||||
cp "$CLOUD/schema.sql" "$PRIV/schema.sql"
|
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
|
||||||
# The hardened DocumentRoot .htaccess replaces cloud/public/'s dev-local rewrite-only one.
|
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
|
||||||
cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess"
|
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
|
||||||
cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php"
|
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
|
||||||
cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php"
|
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 "$CLOUD/deploy/user.ini" "$WEB/.user.ini"
|
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
|
||||||
cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
|
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
|
||||||
|
|
||||||
# `cp -r` above copies whatever is in cloud/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
|
||||||
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
|
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
|
||||||
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \
|
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Cafe24 Cloud Deploy Guide (P1-B)
|
# Cafe24 Server Deploy Guide (P1-B)
|
||||||
|
|
||||||
This document covers deploying the `cloud/` PHP application (ingest API + dashboard) to a
|
This document covers deploying the `server/` PHP application (ingest API + dashboard) to a
|
||||||
Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer
|
Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer
|
||||||
dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with
|
dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with
|
||||||
prepared statements — there is nothing to `composer install` on the production host.
|
prepared statements — there is nothing to `composer install` on the production host.
|
||||||
|
|
||||||
## 1. Directory layout on the server
|
## 1. Directory layout on the server
|
||||||
|
|
||||||
Only `cloud/public/` may be reachable from the web. Everything else must sit **outside**
|
Only `server/public/` may be reachable from the web. Everything else must sit **outside**
|
||||||
the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is),
|
the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is),
|
||||||
or be blocked by hosting-level access rules if your plan cannot place files outside the
|
or be blocked by hosting-level access rules if your plan cannot place files outside the
|
||||||
web root at all.
|
web root at all.
|
||||||
|
|
@ -27,7 +27,7 @@ web root at all.
|
||||||
└── assets/ sit at the FTP root — see below.
|
└── assets/ sit at the FTP root — see below.
|
||||||
```
|
```
|
||||||
|
|
||||||
`cloud/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
|
`server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
|
||||||
itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
|
itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
|
||||||
hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a
|
hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a
|
||||||
subdirectory of one — in the latter case the app root is two levels up, not one, because
|
subdirectory of one — in the latter case the app root is two levels up, not one, because
|
||||||
|
|
@ -39,7 +39,7 @@ views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without t
|
||||||
declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
|
declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
|
||||||
resolve against the co-hosted site.
|
resolve against the co-hosted site.
|
||||||
|
|
||||||
`cloud/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
|
`server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
|
||||||
(empty) to mount the app directly in `www/`.
|
(empty) to mount the app directly in `www/`.
|
||||||
|
|
||||||
**`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
|
**`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
|
||||||
|
|
@ -49,7 +49,7 @@ every request, including §10's own smoke check. There are no runtime Composer *
|
||||||
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
|
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cloud && composer dump-autoload --no-dev --classmap-authoritative --no-interaction
|
cd server && composer dump-autoload --no-dev --classmap-authoritative --no-interaction
|
||||||
```
|
```
|
||||||
|
|
||||||
That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy
|
That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy
|
||||||
|
|
@ -63,7 +63,7 @@ deliberately excludes.
|
||||||
them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain
|
them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain
|
||||||
PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`.
|
PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`.
|
||||||
|
|
||||||
`cloud/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the
|
`server/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the
|
||||||
tree below automatically — prefer it over assembling the transfer by hand.
|
tree below automatically — prefer it over assembling the transfer by hand.
|
||||||
|
|
||||||
If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is
|
If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is
|
||||||
|
|
@ -81,7 +81,7 @@ web-readable.
|
||||||
Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over
|
Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over
|
||||||
SSH if available) — do not assume, since PHP builds vary by hosting plan:
|
SSH if available) — do not assume, since PHP builds vary by hosting plan:
|
||||||
|
|
||||||
- [ ] **PHP version** matches `cloud/composer.json`'s `"php": ">=8.2"` (the app is written
|
- [ ] **PHP version** matches `server/composer.json`'s `"php": ">=8.2"` (the app is written
|
||||||
and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact
|
and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact
|
||||||
version Cafe24 provisions for your plan).
|
version Cafe24 provisions for your plan).
|
||||||
- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include
|
- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include
|
||||||
|
|
@ -112,7 +112,7 @@ SSH if available) — do not assume, since PHP builds vary by hosting plan:
|
||||||
## 3. `config.php`
|
## 3. `config.php`
|
||||||
|
|
||||||
Copy the example and fill in real values — **never commit `config.php`** (already covered
|
Copy the example and fill in real values — **never commit `config.php`** (already covered
|
||||||
by `cloud/.gitignore`):
|
by `server/.gitignore`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.example.php config.php
|
cp config.example.php config.php
|
||||||
|
|
@ -139,7 +139,7 @@ or version control.
|
||||||
|
|
||||||
## 4. Import the schema
|
## 4. Import the schema
|
||||||
|
|
||||||
`cloud/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
|
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
|
||||||
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
|
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
|
||||||
|
|
||||||
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
|
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
|
||||||
|
|
@ -32,7 +32,7 @@ final class EventValidator
|
||||||
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
|
if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id';
|
||||||
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
|
if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s';
|
||||||
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
|
// Optional idempotency key: the edge generates a stable 32-hex uuid per event and re-sends
|
||||||
// it on retry so the cloud can dedup. Enforce the exact shape (matches CHAR(32) + the
|
// it on retry so the server can dedup. Enforce the exact shape (matches CHAR(32) + the
|
||||||
// snapshot-uuid convention); a malformed key is a bug, not a silent pass.
|
// snapshot-uuid convention); a malformed key is a bug, not a silent pass.
|
||||||
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) {
|
if (isset($p['event_uuid']) && (!is_string($p['event_uuid']) || !preg_match('/^[0-9a-f]{32}$/', $p['event_uuid']))) {
|
||||||
$errors[] = 'bad:event_uuid';
|
$errors[] = 'bad:event_uuid';
|
||||||
|
|
@ -31,7 +31,7 @@ final class PdoDeviceRepository implements DeviceRepository
|
||||||
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
|
// A plain UPDATE (not upsert) is safe here: the caller (HeartbeatController)
|
||||||
// only reaches this after secretFor() already succeeded for the same
|
// only reaches this after secretFor() already succeeded for the same
|
||||||
// device_id, so the row is guaranteed to exist. Device rows are provisioned
|
// device_id, so the row is guaranteed to exist. Device rows are provisioned
|
||||||
// out-of-band by an admin (see cloud/docs/cloud-deploy.md), never created
|
// out-of-band by an admin (see server/docs/server-deploy.md), never created
|
||||||
// implicitly from an inbound heartbeat.
|
// implicitly from an inbound heartbeat.
|
||||||
$st = $this->pdo->prepare(
|
$st = $this->pdo->prepare(
|
||||||
'UPDATE move_devices
|
'UPDATE move_devices
|
||||||
|
|
@ -125,7 +125,7 @@ final class IngestControllerTest extends TestCase
|
||||||
|
|
||||||
public function testDeduplicatesRepeatedEventUuid(): void {
|
public function testDeduplicatesRepeatedEventUuid(): void {
|
||||||
// A POST that succeeded server-side but timed out client-side is re-sent with the SAME
|
// A POST that succeeded server-side but timed out client-side is re-sent with the SAME
|
||||||
// event_uuid (fresh nonce). The cloud must return the already-stored event, not duplicate.
|
// event_uuid (fresh nonce). The server must return the already-stored event, not duplicate.
|
||||||
[$c, $events] = $this->make();
|
[$c, $events] = $this->make();
|
||||||
$uuid = str_repeat('a', 32);
|
$uuid = str_repeat('a', 32);
|
||||||
$p = $this->payload(); $p['event_uuid'] = $uuid;
|
$p = $this->payload(); $p['event_uuid'] = $uuid;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue