fix(security,delivery): scope improvement actions by site; correct upgrade procedure
프로젝트 8개 차원 감사(종합 82/100)에서 확정된 상위 3건 수정. 1. 개선 조치(move_actions)가 유일하게 site 스코프 규율에서 이탈 — 스코프 운영자가 타 현장의 조치 기록(카메라 ID·메모·작성자)을 목록에서 읽고, 타 현장 카메라에 조치를 기록해 그 현장 리포트의 전후 효과 지표를 오염시킬 수 있었다. - move_actions.site 컬럼 + (site, taken_at) 인덱스, 기존 행 백필 마이그레이션 - 카메라 소유 현장은 이벤트에서 해석 (move_cameras는 등록 플로우가 없어 항상 비어 있음) - 오타 카메라는 400: 전후 효과가 영원히 0/0이 되는 조치를 애초에 못 만들게 한다 - 타 현장 카메라는 404: 존재 여부를 숨기는 기존 규약 유지 2. README-UPLOAD 업그레이드 절차가 마이그레이션 7종 중 1종만 지시했다. move_ 접두 리네임을 건너뛰면 이후 전부 실패하고 전 요청이 500이 된다 — 순서·적용조건·재실행 비안전성을 표로 명시. 문서 4곳의 테이블 수(10→11)·비프리픽스 표기도 정정. 3. 두 납품 번들 재생성: 8/6 이후 4개 기능 커밋이 반영되지 않아 Report/Export/Actions 컨트롤러와 8/7 마이그레이션이 빠진 채였다. 엣지 소스 패리티도 회복. 검증: pytest 235 passed / PHPUnit 147 passed (PDO 4 skip), 번들 산출물 내부 검증 (28 클래스 해석·전 파일 php -l·SHA256SUMS 55행·휠 11개·HEF 다이제스트 일치).
This commit is contained in:
parent
6b69f22275
commit
6b1f4c483f
13 changed files with 203 additions and 30 deletions
|
|
@ -1,5 +1,13 @@
|
||||||
# INDEX — 작업 기록 (완료 결과만)
|
# INDEX — 작업 기록 (완료 결과만)
|
||||||
|
|
||||||
|
- **2026-08-08** 프로젝트 8개 차원 감사(종합 82/100) 후 상위 3건 수정. ①개선 조치 site 스코프
|
||||||
|
누락(스코프 운영자가 타 현장 조치 기록 열람·기록 가능) → `move_actions.site` 추가
|
||||||
|
(`2026-08-08-actions-site.sql`, 이벤트에서 카메라 소유 현장 해석, 오타 카메라 400·타 현장
|
||||||
|
404). ②README-UPLOAD 업그레이드 절차가 마이그레이션 1/7만 지시 → 순서·적용조건 표로 교체
|
||||||
|
(`move_` 접두 리네임 누락 시 전 요청 500). ③두 납품 번들 재생성(4커밋 스테일 해소 —
|
||||||
|
Report/Export/Actions 컨트롤러·마이그레이션 누락 복구, 엣지 소스 패리티 회복).
|
||||||
|
문서 테이블 수·프리픽스 표기 정정. pytest 235 / PHPUnit 147.
|
||||||
|
|
||||||
- **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간
|
- **2026-08-07** 데이터 활용 5안 중 4안 구현: ②시간패턴 히트맵+전조 급증 경보 ③KPI 전기간
|
||||||
추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후
|
추이(`d8b8e1f`) ①라벨 JSONL 익스포트(`/api/export/labels`, 관리자) ④개선 조치 기록·전후
|
||||||
효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장
|
효과 비교(`move_actions`, 동일 창 비교, 3일 미만 "측정 중"). ⑤멀티사이트 벤치마크는 다현장
|
||||||
|
|
|
||||||
|
|
@ -58,15 +58,15 @@ COCO-17 remap 로직은 그대로). 어댑터 remap은 `tests/test_movenet_pose.
|
||||||
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
|
로컬 MySQL 8(스키마 CHECK 제약은 8.0.16+)과 PHP 8.2+ 필요. 상세 배포·전제조건(gd/argon2/HTTPS 등)은
|
||||||
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
[server/docs/server-deploy.md](../server/docs/server-deploy.md) 참조 — 여기선 로컬 최소 절차만.
|
||||||
|
|
||||||
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (10개 테이블).
|
1. DB 생성 후 스키마 임포트: `mysql <db> < server/schema.sql` (11개 테이블, 전부 `move_` 접두사).
|
||||||
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
2. `server/config.php`를 `server/config.example.php`에서 복사, 로컬 DB DSN/계정 기입
|
||||||
(`snapshot_dir`는 쓰기 가능 경로로).
|
(`snapshot_dir`는 쓰기 가능 경로로).
|
||||||
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
3. **관리자 계정 · 디바이스 시크릿 등록** — 앱에 셀프 가입 흐름이 없으므로 직접 넣는다.
|
||||||
CLI 스니펫과 SQL은 server-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 move_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 move_users (username, password_hash, role, ...) VALUES ('admin','<해시>','admin',NULL,0,0);`
|
||||||
4. 기동: `php -S localhost:8000 -t server/public`
|
4. 기동: `php -S localhost:8000 -t server/public`
|
||||||
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
- 세션 쿠키가 `cookie_secure`라 순수 HTTP에선 로그인 쿠키가 안 붙을 수 있다. 로컬은 대개
|
||||||
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
|
`localhost`가 secure-context로 취급돼 동작하지만, 안 되면 server-deploy.md §8(HTTPS) 참조.
|
||||||
|
|
|
||||||
|
|
@ -70,19 +70,40 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
|
||||||
1. 카페24 관리자 → **phpMyAdmin** 열기
|
1. 카페24 관리자 → **phpMyAdmin** 열기
|
||||||
2. 이 앱용 DB 선택
|
2. 이 앱용 DB 선택
|
||||||
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
|
3. **Import(가져오기)** 탭 → `schema.sql` 선택 → 실행
|
||||||
4. 표 10개가 생겼는지 확인
|
4. 표 11개가 생겼는지 확인
|
||||||
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
|
5. **서버에서 `schema.sql` 삭제** — 남겨둘 이유가 없습니다
|
||||||
|
|
||||||
|
`schema.sql` 은 항상 최신 상태입니다. 처음 설치라면 아래 마이그레이션은 **실행하지 마세요**
|
||||||
|
(이미 반영되어 있어 충돌합니다).
|
||||||
|
|
||||||
### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
|
### 이미 쓰던 DB 업그레이드(기존 데이터 있음)
|
||||||
|
|
||||||
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
|
> ⚠️ 기존 DB에 `schema.sql` 을 다시 Import 하면 `CREATE TABLE` 충돌로 실패합니다.
|
||||||
> 대신 **마이그레이션**을 실행하세요.
|
> 대신 **마이그레이션**을 실행하세요.
|
||||||
|
|
||||||
|
**반드시 아래 순서대로, 아직 적용하지 않은 것부터 전부 실행합니다.** 하나라도 건너뛰면
|
||||||
|
그 컬럼·표를 쓰는 요청이 전부 500 이 됩니다. 순서가 중요한 이유는 뒤 파일이 앞 파일이 만든
|
||||||
|
표 이름(`move_` 접두사)을 전제하기 때문입니다.
|
||||||
|
|
||||||
|
| 순서 | 파일 | 무엇이 바뀌나 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `2026-07-17-p1-9-p3-2b.sql` | site / event_uuid 컬럼 + 인덱스, 기존 이벤트 site 백필 |
|
||||||
|
| 2 | `2026-07-20-move-table-prefix.sql` | 모든 표 이름에 `move_` 접두사 부여 (**이 단계를 건너뛰면 이후 전부 실패**) |
|
||||||
|
| 3 | `2026-08-06-zone-rules.sql` | 존 점유 규칙(`rules_json`) |
|
||||||
|
| 4 | `2026-08-06-heatmap-spatial.sql` | 히트맵 좌표(`spatial_x`, `spatial_y`) |
|
||||||
|
| 5 | `2026-08-06-false-positive-tag.sql` | 오탐 태그 컬럼 |
|
||||||
|
| 6 | `2026-08-07-improvement-actions.sql` | 개선 조치 표(`move_actions`) |
|
||||||
|
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
|
||||||
|
|
||||||
1. phpMyAdmin → 해당 DB → **SQL** 탭
|
1. phpMyAdmin → 해당 DB → **SQL** 탭
|
||||||
2. `migrations/2026-07-17-p1-9-p3-2b.sql` 내용을 붙여넣고 실행
|
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
|
||||||
(site / event_uuid 컬럼 + 인덱스 추가, 기존 이벤트의 site 백필 포함)
|
(각 파일 첫머리에 적용 조건이 주석으로 적혀 있습니다)
|
||||||
3. 파일 맨 아래 **검증 쿼리**로 컬럼·인덱스가 생겼는지 확인
|
3. 각 파일 맨 아래 **검증 쿼리**로 컬럼·표가 생겼는지 확인
|
||||||
4. 그 다음에 새 코드를 배포합니다 — 컬럼이 없으면 모든 요청이 500 이 됩니다
|
4. 전부 끝난 뒤에 새 코드를 배포합니다
|
||||||
|
|
||||||
|
> 이미 적용한 마이그레이션을 다시 실행하면 "Duplicate column" 같은 오류가 납니다 —
|
||||||
|
> 재실행은 안전하지 않으니, 어디까지 적용했는지 기록해 두세요. 어디까지 했는지 모르겠으면
|
||||||
|
> phpMyAdmin 의 **Structure(구조)** 탭에서 위 표의 컬럼·표가 있는지 하나씩 확인하면 됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,13 +139,14 @@ or version control.
|
||||||
|
|
||||||
## 4. Import the schema
|
## 4. Import the schema
|
||||||
|
|
||||||
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
|
`server/schema.sql` contains the full MySQL 8.0+ DDL. All tables carry the `move_` prefix
|
||||||
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
|
(`move_users`, `move_devices`, `move_cameras`, `move_actions`, `move_zones`, `move_events`,
|
||||||
|
`move_event_snapshots`, `move_thresholds`, `move_nonces`, `move_rate_limits`, `move_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
|
||||||
app.
|
app.
|
||||||
2. **Import** tab → choose file → upload `schema.sql` → Go.
|
2. **Import** tab → choose file → upload `schema.sql` → Go.
|
||||||
3. Confirm all 10 tables were created (phpMyAdmin's table list on the left, or
|
3. Confirm all 11 tables were created (phpMyAdmin's table list on the left, or
|
||||||
`SHOW TABLES;` in the SQL tab).
|
`SHOW TABLES;` in the SQL tab).
|
||||||
4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live
|
4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live
|
||||||
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
|
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
|
||||||
|
|
|
||||||
22
server/migrations/2026-08-08-actions-site.sql
Normal file
22
server/migrations/2026-08-08-actions-site.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- 개선 조치(move_actions)에 site 스코프 추가.
|
||||||
|
-- 이 표만 site 컬럼 없이 추가되어, 스코프 운영자가 타 현장의 조치 기록(카메라 ID·메모·
|
||||||
|
-- 작성자)을 목록에서 그대로 읽을 수 있었다. 다른 표(events/devices/zones)와 동일하게
|
||||||
|
-- site 를 비정규화해 SQL 단계에서 걸러낸다.
|
||||||
|
--
|
||||||
|
-- 적용 조건: 2026-08-07-improvement-actions.sql 이후.
|
||||||
|
|
||||||
|
ALTER TABLE move_actions
|
||||||
|
ADD COLUMN site VARCHAR(64) NULL AFTER camera_id,
|
||||||
|
ADD KEY ix_actions_site_taken (site, taken_at);
|
||||||
|
|
||||||
|
-- 기존 행 백필: 조치가 달린 카메라가 이벤트를 남긴 현장을 그 조치의 현장으로 본다.
|
||||||
|
-- (카메라는 별도 등록 표가 없고 events.site 가 유일한 소유 근거다.)
|
||||||
|
UPDATE move_actions a
|
||||||
|
SET a.site = (SELECT e.site FROM move_events e
|
||||||
|
WHERE e.camera_id = a.camera_id AND e.site IS NOT NULL
|
||||||
|
ORDER BY e.event_id DESC LIMIT 1)
|
||||||
|
WHERE a.site IS NULL;
|
||||||
|
|
||||||
|
-- 검증: site 가 채워졌는지 확인한다. NULL 로 남은 행은 해당 카메라의 이벤트가 없거나
|
||||||
|
-- 현장 미지정(devices.site IS NULL)인 경우로, 전역 관리자에게만 보인다.
|
||||||
|
-- SELECT site, COUNT(*) FROM move_actions GROUP BY site;
|
||||||
|
|
@ -101,12 +101,14 @@ CREATE TABLE move_cameras (
|
||||||
CREATE TABLE move_actions (
|
CREATE TABLE move_actions (
|
||||||
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
camera_id VARCHAR(64) NOT NULL,
|
camera_id VARCHAR(64) NOT NULL,
|
||||||
|
site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert
|
||||||
note VARCHAR(500) NOT NULL,
|
note VARCHAR(500) NOT NULL,
|
||||||
taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s)
|
taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s)
|
||||||
created_by VARCHAR(190) NOT NULL,
|
created_by VARCHAR(190) NOT NULL,
|
||||||
created_at BIGINT UNSIGNED NOT NULL,
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
PRIMARY KEY (action_id),
|
PRIMARY KEY (action_id),
|
||||||
KEY ix_actions_taken (taken_at)
|
KEY ix_actions_taken (taken_at),
|
||||||
|
KEY ix_actions_site_taken (site, taken_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE move_zones (
|
CREATE TABLE move_zones (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace App\Dashboard;
|
||||||
|
|
||||||
use App\Http\{Request, Response};
|
use App\Http\{Request, Response};
|
||||||
use App\Auth\{SessionStore, Rbac};
|
use App\Auth\{SessionStore, Rbac};
|
||||||
use App\Security\Csrf;
|
use App\Security\{Csrf, SiteScope};
|
||||||
use App\Repository\{ActionRepository, EventRepository};
|
use App\Repository\{ActionRepository, EventRepository};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,8 +44,22 @@ final class ActionsController
|
||||||
if ($takenAt > $now || $takenAt < $now - 365 * 86400) {
|
if ($takenAt > $now || $takenAt < $now - 365 * 86400) {
|
||||||
return Response::json(['error' => 'invalid_taken_at'], 400);
|
return Response::json(['error' => 'invalid_taken_at'], 400);
|
||||||
}
|
}
|
||||||
|
// The camera MUST be one that has actually reported: a mistyped id would record an action
|
||||||
|
// whose before/after effect is 0/0 forever — a safety investment that silently never gets
|
||||||
|
// measured. Cameras have no registry table, so their events are the ownership record.
|
||||||
|
// 400 (client input error), matching ZoneConfigController's unknown_device.
|
||||||
|
if (!$this->events->cameraExists($camera)) {
|
||||||
|
return Response::json(['error' => 'unknown_camera'], 400);
|
||||||
|
}
|
||||||
|
// Scope BEFORE the write: a scoped operator must not record an action against another
|
||||||
|
// site's camera (it would pollute that site's report). 404 hides existence, as everywhere.
|
||||||
|
$site = $this->events->siteForCamera($camera);
|
||||||
|
$scope = $this->sess->get('site_scope');
|
||||||
|
if (!SiteScope::allows(is_string($scope) ? $scope : null, $site)) {
|
||||||
|
return Response::json(['error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
$id = $this->actions->insert([
|
$id = $this->actions->insert([
|
||||||
'camera_id' => $camera, 'note' => $note, 'taken_at' => $takenAt,
|
'camera_id' => $camera, 'site' => $site, 'note' => $note, 'taken_at' => $takenAt,
|
||||||
'created_by' => (string)$this->sess->get('username'),
|
'created_by' => (string)$this->sess->get('username'),
|
||||||
]);
|
]);
|
||||||
return Response::json(['action_id' => $id], 201);
|
return Response::json(['action_id' => $id], 201);
|
||||||
|
|
@ -58,7 +72,7 @@ final class ActionsController
|
||||||
$site = is_string($scope) ? $scope : null;
|
$site = is_string($scope) ? $scope : null;
|
||||||
$now = time();
|
$now = time();
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($this->actions->recent(self::LIST_LIMIT) as $a) {
|
foreach ($this->actions->recent(self::LIST_LIMIT, $site) as $a) {
|
||||||
// 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면
|
// 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면
|
||||||
// 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중".
|
// 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중".
|
||||||
$elapsed = $now - $a['taken_at'];
|
$elapsed = $now - $a['taken_at'];
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ namespace App\Repository;
|
||||||
|
|
||||||
interface ActionRepository
|
interface ActionRepository
|
||||||
{
|
{
|
||||||
/** {camera_id, note, taken_at, created_by} -> action_id */
|
/** {camera_id, site, note, taken_at, created_by} -> action_id */
|
||||||
public function insert(array $action): int;
|
public function insert(array $action): int;
|
||||||
/** Most recent actions, newest taken_at first, capped at $limit. */
|
/**
|
||||||
public function recent(int $limit): array;
|
* Most recent actions, newest taken_at first, capped at $limit.
|
||||||
|
* $site (non-null) restricts to that site — the SQL half of the site-scope access
|
||||||
|
* control, same contract as EventRepository::page() (see App\Security\SiteScope).
|
||||||
|
*/
|
||||||
|
public function recent(int $limit, ?string $site): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,4 +59,17 @@ interface EventRepository
|
||||||
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array;
|
public function exportRows(float $sinceOccurredAt, ?string $site, int $limit): array;
|
||||||
/** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */
|
/** Detections (non-ergo) for one camera in [from, to) — the action before/after counter. */
|
||||||
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int;
|
public function countForCamera(string $cameraId, float $from, float $to, ?string $site): int;
|
||||||
|
/**
|
||||||
|
* True if this camera has ever reported an event — the closest thing to "does this camera
|
||||||
|
* exist", playing the role DeviceRepository::exists() plays for devices.
|
||||||
|
* NOT move_cameras: that table has no registration flow and no repository (see schema.sql),
|
||||||
|
* so it is empty in every deployment — checking it would reject every real camera.
|
||||||
|
*/
|
||||||
|
public function cameraExists(string $cameraId): bool;
|
||||||
|
/**
|
||||||
|
* The site this camera's events are recorded under (from its most recent event), or null
|
||||||
|
* when the camera is unknown OR its device has no site assigned. Pair with cameraExists()
|
||||||
|
* to tell those two apart, exactly as DeviceRepository::exists()/siteFor() are paired.
|
||||||
|
*/
|
||||||
|
public function siteForCamera(string $cameraId): ?string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@ final class PdoActionRepository implements ActionRepository
|
||||||
public function insert(array $action): int
|
public function insert(array $action): int
|
||||||
{
|
{
|
||||||
$st = $this->pdo->prepare(
|
$st = $this->pdo->prepare(
|
||||||
'INSERT INTO move_actions (camera_id, note, taken_at, created_by, created_at)
|
'INSERT INTO move_actions (camera_id, site, note, taken_at, created_by, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?)'
|
VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
$st->execute([
|
$st->execute([
|
||||||
(string)$action['camera_id'],
|
(string)$action['camera_id'],
|
||||||
|
$action['site'] ?? null, // denormalized from the camera's events by ActionsController
|
||||||
(string)$action['note'],
|
(string)$action['note'],
|
||||||
(int)$action['taken_at'],
|
(int)$action['taken_at'],
|
||||||
(string)$action['created_by'],
|
(string)$action['created_by'],
|
||||||
|
|
@ -25,17 +26,26 @@ final class PdoActionRepository implements ActionRepository
|
||||||
return (int)$this->pdo->lastInsertId();
|
return (int)$this->pdo->lastInsertId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function recent(int $limit): array
|
public function recent(int $limit, ?string $site): array
|
||||||
{
|
{
|
||||||
$st = $this->pdo->prepare(
|
$sql = 'SELECT action_id, camera_id, site, note, taken_at, created_by FROM move_actions';
|
||||||
'SELECT action_id, camera_id, note, taken_at, created_by
|
$binds = [];
|
||||||
FROM move_actions ORDER BY taken_at DESC, action_id DESC LIMIT ?'
|
if ($site !== null) {
|
||||||
);
|
// Fail-closed like every other scoped list: an empty-string scope matches nothing,
|
||||||
$st->bindValue(1, $limit, PDO::PARAM_INT);
|
// and a row with no site is never visible to a scoped caller.
|
||||||
|
$sql .= ' WHERE site = ?';
|
||||||
|
$binds[] = $site;
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY taken_at DESC, action_id DESC LIMIT ?';
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
$pos = 1;
|
||||||
|
foreach ($binds as $b) $st->bindValue($pos++, $b, PDO::PARAM_STR);
|
||||||
|
$st->bindValue($pos, $limit, PDO::PARAM_INT);
|
||||||
$st->execute();
|
$st->execute();
|
||||||
return array_map(static fn ($r) => [
|
return array_map(static fn ($r) => [
|
||||||
'action_id' => (int)$r['action_id'],
|
'action_id' => (int)$r['action_id'],
|
||||||
'camera_id' => $r['camera_id'],
|
'camera_id' => $r['camera_id'],
|
||||||
|
'site' => $r['site'],
|
||||||
'note' => $r['note'],
|
'note' => $r['note'],
|
||||||
'taken_at' => (int)$r['taken_at'],
|
'taken_at' => (int)$r['taken_at'],
|
||||||
'created_by' => $r['created_by'],
|
'created_by' => $r['created_by'],
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,27 @@ final class PdoEventRepository implements EventRepository
|
||||||
return (int)$st->fetchColumn();
|
return (int)$st->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function cameraExists(string $cameraId): bool
|
||||||
|
{
|
||||||
|
$st = $this->pdo->prepare('SELECT 1 FROM move_events WHERE camera_id = ? LIMIT 1');
|
||||||
|
$st->execute([$cameraId]);
|
||||||
|
return $st->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function siteForCamera(string $cameraId): ?string
|
||||||
|
{
|
||||||
|
// Newest event wins: if a camera is re-homed to another site, later actions belong to
|
||||||
|
// the site it reports to NOW. event_id DESC (not occurred_at) because ingest order is
|
||||||
|
// the authoritative "most recently seen" — occurred_at comes from the edge clock.
|
||||||
|
$st = $this->pdo->prepare(
|
||||||
|
'SELECT site FROM move_events WHERE camera_id = ? AND site IS NOT NULL
|
||||||
|
ORDER BY event_id DESC LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([$cameraId]);
|
||||||
|
$site = $st->fetchColumn();
|
||||||
|
return $site === false ? null : (string)$site;
|
||||||
|
}
|
||||||
|
|
||||||
private static function mapRow(array $row): array
|
private static function mapRow(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -67,19 +67,63 @@ final class ExportActionsTest extends TestCase
|
||||||
return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []);
|
return new Request('POST', '/api/actions', ['X-Csrf-Token' => 'tok'], json_encode($body), [], []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 카메라는 등록 표가 없다 — 이벤트가 곧 존재·소유 근거이므로 테스트도 이벤트로 만든다. */
|
||||||
|
private function eventsWithCameras(array $cameraSites): InMemoryEventRepository
|
||||||
|
{
|
||||||
|
$events = new InMemoryEventRepository();
|
||||||
|
foreach ($cameraSites as $cam => $site) {
|
||||||
|
$events->insert(['camera_id' => $cam, 'site' => $site, 'event_type' => 'near_fall',
|
||||||
|
'severity' => 'info', 'occurred_at' => (float)time(), 'metrics' => []]);
|
||||||
|
}
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
public function testActionSaveValidation(): void
|
public function testActionSaveValidation(): void
|
||||||
{
|
{
|
||||||
$c = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('operator'));
|
$events = $this->eventsWithCameras(['cam-1' => null]);
|
||||||
|
$c = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('operator'));
|
||||||
$this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status());
|
$this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '매트 설치']))->status());
|
||||||
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status());
|
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => '', 'note' => 'x']))->status());
|
||||||
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status());
|
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => '']))->status());
|
||||||
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x',
|
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x',
|
||||||
'taken_at' => time() + 999]))->status()); // 미래 시각 거부
|
'taken_at' => time() + 999]))->status()); // 미래 시각 거부
|
||||||
|
// 오타 카메라: 전후 효과가 영원히 0/0이 되므로 기록 자체를 막는다
|
||||||
|
$this->assertSame(400, $c->save($this->actionReq(['camera_id' => 'cam-오타', 'note' => 'x']))->status());
|
||||||
// viewer는 기록 불가
|
// viewer는 기록 불가
|
||||||
$cv = new ActionsController(new InMemoryActionRepository(), new InMemoryEventRepository(), $this->sess('viewer'));
|
$cv = new ActionsController(new InMemoryActionRepository(), $events, $this->sess('viewer'));
|
||||||
$this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status());
|
$this->assertSame(403, $cv->save($this->actionReq(['camera_id' => 'cam-1', 'note' => 'x']))->status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testScopedOperatorCannotWriteActionForAnotherSiteCamera(): void
|
||||||
|
{
|
||||||
|
$events = $this->eventsWithCameras(['cam-a' => 'site-a', 'cam-b' => 'site-b']);
|
||||||
|
$actions = new InMemoryActionRepository();
|
||||||
|
$c = new ActionsController($actions, $events, $this->sess('operator', 'site-a'));
|
||||||
|
$this->assertSame(201, $c->save($this->actionReq(['camera_id' => 'cam-a', 'note' => '매트']))->status());
|
||||||
|
// 타 현장 카메라: 존재 여부를 숨기며 거부(404) — 그 현장 리포트 오염 방지
|
||||||
|
$this->assertSame(404, $c->save($this->actionReq(['camera_id' => 'cam-b', 'note' => '침입']))->status());
|
||||||
|
$this->assertCount(1, $actions->rows);
|
||||||
|
$this->assertSame('site-a', $actions->rows[1]['site']); // 카메라에서 해석해 비정규화
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testScopedOperatorSeesOnlyOwnSiteActions(): void
|
||||||
|
{
|
||||||
|
$actions = new InMemoryActionRepository();
|
||||||
|
$actions->insert(['camera_id' => 'cam-a', 'site' => 'site-a', 'note' => '우리 현장',
|
||||||
|
'taken_at' => time() - 86400, 'created_by' => 'a']);
|
||||||
|
$actions->insert(['camera_id' => 'cam-b', 'site' => 'site-b', 'note' => '남의 현장',
|
||||||
|
'taken_at' => time() - 3600, 'created_by' => 'b']);
|
||||||
|
$req = new Request('GET', '/api/actions', [], '', [], []);
|
||||||
|
|
||||||
|
$scoped = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('operator', 'site-a'));
|
||||||
|
$rows = json_decode($scoped->json($req)->body(), true)['actions'];
|
||||||
|
$this->assertCount(1, $rows);
|
||||||
|
$this->assertSame('우리 현장', $rows[0]['note']); // 타 현장 메모·작성자 미노출
|
||||||
|
|
||||||
|
$global = new ActionsController($actions, new InMemoryEventRepository(), $this->sess('admin'));
|
||||||
|
$this->assertCount(2, json_decode($global->json($req)->body(), true)['actions']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testActionEffectComparesEqualWindows(): void
|
public function testActionEffectComparesEqualWindows(): void
|
||||||
{
|
{
|
||||||
$actions = new InMemoryActionRepository();
|
$actions = new InMemoryActionRepository();
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,18 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
&& ($x['event_type'] ?? '') !== 'ergo_report'
|
&& ($x['event_type'] ?? '') !== 'ergo_report'
|
||||||
&& ($site === null || ($x['site'] ?? null) === $site)));
|
&& ($site === null || ($x['site'] ?? null) === $site)));
|
||||||
}
|
}
|
||||||
|
public function cameraExists(string $cam): bool {
|
||||||
|
foreach ($this->rows as $r) if (($r['camera_id'] ?? '') === $cam) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
public function siteForCamera(string $cam): ?string {
|
||||||
|
$best = null;
|
||||||
|
foreach ($this->rows as $r) {
|
||||||
|
if (($r['camera_id'] ?? '') !== $cam || ($r['site'] ?? null) === null) continue;
|
||||||
|
if ($best === null || ($r['event_id'] ?? 0) >= ($best['event_id'] ?? 0)) $best = $r;
|
||||||
|
}
|
||||||
|
return $best === null ? null : (string)$best['site'];
|
||||||
|
}
|
||||||
public function occurrenceTimes(float $since, ?string $site, int $limit): array {
|
public function occurrenceTimes(float $since, ?string $site, int $limit): array {
|
||||||
$r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since
|
$r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since
|
||||||
&& ($x['event_type'] ?? '') !== 'ergo_report'
|
&& ($x['event_type'] ?? '') !== 'ergo_report'
|
||||||
|
|
@ -218,8 +230,9 @@ final class InMemoryZoneRepository implements ZoneRepository {
|
||||||
final class InMemoryActionRepository implements ActionRepository {
|
final class InMemoryActionRepository implements ActionRepository {
|
||||||
public array $rows = []; private int $seq = 0;
|
public array $rows = []; private int $seq = 0;
|
||||||
public function insert(array $a): int { $id = ++$this->seq; $this->rows[$id] = $a + ['action_id' => $id]; return $id; }
|
public function insert(array $a): int { $id = ++$this->seq; $this->rows[$id] = $a + ['action_id' => $id]; return $id; }
|
||||||
public function recent(int $limit): array {
|
public function recent(int $limit, ?string $site): array {
|
||||||
$r = array_values($this->rows);
|
$r = array_values(array_filter($this->rows,
|
||||||
|
fn($a) => $site === null || ($a['site'] ?? null) === $site));
|
||||||
usort($r, fn($a, $b) => [$b['taken_at'], $b['action_id']] <=> [$a['taken_at'], $a['action_id']]);
|
usort($r, fn($a, $b) => [$b['taken_at'], $b['action_id']] <=> [$a['taken_at'], $a['action_id']]);
|
||||||
return array_slice($r, 0, $limit);
|
return array_slice($r, 0, $limit);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue