first commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
-- Face photo enrolment columns on AccessProfile (tenant DBs).
|
||||
--
|
||||
-- TenantDbContext has no EF migration history -- its schema is created by EnsureCreated --
|
||||
-- so schema changes are applied with an idempotent script, matching how this project
|
||||
-- already works. Safe to re-run: each ADD COLUMN is skipped when the column exists.
|
||||
--
|
||||
-- The face image itself is NEVER stored here. It travels in DeviceCommand.PayloadJson only
|
||||
-- until a Hikvision terminal confirms enrolment, at which point AgentService purges the
|
||||
-- payload. What these columns keep is the enrolment state plus the device-side record id,
|
||||
-- which is what a later replace/delete needs.
|
||||
--
|
||||
-- Run once per tenant database. Replace the USE line, or run the whole file per schema:
|
||||
--
|
||||
-- mysql -u root hexa_tenant_ironcore < add-face-photo-columns.sql
|
||||
-- mysql -u root hexa_tenant_pulsefit < add-face-photo-columns.sql
|
||||
-- mysql -u root hexa_tenant_flexzone < add-face-photo-columns.sql
|
||||
-- mysql -u root hexa_tenant_titanfit < add-face-photo-columns.sql
|
||||
|
||||
DROP PROCEDURE IF EXISTS add_face_photo_columns;
|
||||
|
||||
DELIMITER //
|
||||
|
||||
CREATE PROCEDURE add_face_photo_columns()
|
||||
BEGIN
|
||||
-- True once a terminal has accepted a face for this profile.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'accessprofiles'
|
||||
AND COLUMN_NAME = 'HasFacePhoto'
|
||||
) THEN
|
||||
ALTER TABLE accessprofiles
|
||||
ADD COLUMN HasFacePhoto tinyint(1) NOT NULL DEFAULT 0;
|
||||
END IF;
|
||||
|
||||
-- None | Pending | Synced | Failed.
|
||||
-- Distinguishes "still uploading" from "the terminal rejected the face", which
|
||||
-- HasFacePhoto alone cannot express.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'accessprofiles'
|
||||
AND COLUMN_NAME = 'FacePhotoStatus'
|
||||
) THEN
|
||||
ALTER TABLE accessprofiles
|
||||
ADD COLUMN FacePhotoStatus varchar(20) NOT NULL DEFAULT 'None';
|
||||
END IF;
|
||||
|
||||
-- Hikvision face-record id (FPID) returned on enrolment; the handle for replace/delete.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'accessprofiles'
|
||||
AND COLUMN_NAME = 'FacePhotoFPID'
|
||||
) THEN
|
||||
ALTER TABLE accessprofiles
|
||||
ADD COLUMN FacePhotoFPID varchar(64) NULL;
|
||||
END IF;
|
||||
|
||||
-- Why the terminal rejected the face (not frontal, eyes closed, poor lighting).
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'accessprofiles'
|
||||
AND COLUMN_NAME = 'FacePhotoError'
|
||||
) THEN
|
||||
ALTER TABLE accessprofiles
|
||||
ADD COLUMN FacePhotoError varchar(500) NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'accessprofiles'
|
||||
AND COLUMN_NAME = 'FacePhotoUpdatedAt'
|
||||
) THEN
|
||||
ALTER TABLE accessprofiles
|
||||
ADD COLUMN FacePhotoUpdatedAt datetime(6) NULL;
|
||||
END IF;
|
||||
END //
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL add_face_photo_columns();
|
||||
DROP PROCEDURE add_face_photo_columns;
|
||||
|
||||
-- Verification: should list all five columns.
|
||||
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'accessprofiles'
|
||||
AND (COLUMN_NAME LIKE 'FacePhoto%' OR COLUMN_NAME = 'HasFacePhoto')
|
||||
ORDER BY COLUMN_NAME;
|
||||
@@ -0,0 +1,150 @@
|
||||
# Agent contract — `UpsertFacePhoto`
|
||||
|
||||
**Status: implemented on both sides.** The agent side lives in `Access-device-agent`
|
||||
(`Devices/HikvisionIsapiClient.SetUserFaceAsync` and the `UpsertFacePhoto` case in
|
||||
`Services/CommandWorker.DispatchAsync`). This document remains the reference for the wire
|
||||
format and the device call.
|
||||
|
||||
Not yet verified against a physical terminal — see "Untested" at the end.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Web ──POST client/customers/{id}/face-photo──▶ Server
|
||||
│ validates JPEG, queues DeviceCommand
|
||||
▼
|
||||
Agent ──GET api/agent/commands ─────────────────▶ receives UpsertFacePhoto
|
||||
──ISAPI POST────────────────────▶ Hikvision terminal (gym LAN)
|
||||
──POST api/agent/commands/{id}/result ────▶ Server
|
||||
│ purges payload, updates profile
|
||||
▼
|
||||
SignalR "FacePhotoUpdated" ──▶ browser
|
||||
```
|
||||
|
||||
## Command the agent receives
|
||||
|
||||
`GET api/agent/commands` returns the usual envelope; `CommandType` is `UpsertFacePhoto`.
|
||||
|
||||
`PayloadJson`:
|
||||
|
||||
```json
|
||||
{
|
||||
"bindingId": "8f1c...", // guid, AccessDeviceBinding
|
||||
"accessProfileId": 6, // long
|
||||
"employeeNo": "HF6", // the person id already used by UpsertPerson
|
||||
"name": "Prabodha",
|
||||
"faceJpegBase64": "/9j/4AAQ...", // bare base64, no data: prefix
|
||||
"desiredVersion": 4
|
||||
}
|
||||
```
|
||||
|
||||
The image is already normalised by the browser and re-checked server-side:
|
||||
|
||||
| Property | Guaranteed |
|
||||
|---|---|
|
||||
| Format | JPEG (magic bytes verified) |
|
||||
| Dimensions | 480×640 from the web client; ≤ 1920×1920 enforced |
|
||||
| Size | ≤ 200 KB enforced, typically 60–150 KB |
|
||||
|
||||
So the agent does **not** need to resize or convert. Decode the base64 and POST it.
|
||||
|
||||
**Ordering:** the person record must exist on the terminal before a face can attach to it.
|
||||
`employeeNo` is the same value `UpsertPerson` uses. If a face command arrives for a person
|
||||
the terminal doesn't have, run the person upsert first rather than failing.
|
||||
|
||||
## ISAPI call
|
||||
|
||||
Face data is attached to an existing person in a face library. Typical shape for the
|
||||
DS-K1T series (confirm against your firmware — this varies more than Hikvision's docs
|
||||
suggest):
|
||||
|
||||
```
|
||||
POST /ISAPI/Intelligent/FDLib/FDSetUp?format=json
|
||||
Content-Type: multipart/form-data; boundary=---boundary
|
||||
Authorization: Digest ...
|
||||
|
||||
---boundary
|
||||
Content-Disposition: form-data; name="FaceDataRecord"
|
||||
Content-Type: application/json
|
||||
|
||||
{ "faceLibType": "blackFD", "FDID": "1", "FPID": "HF6", "name": "Prabodha" }
|
||||
---boundary
|
||||
Content-Disposition: form-data; name="FaceImage"; filename="face.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<binary jpeg>
|
||||
---boundary--
|
||||
```
|
||||
|
||||
- `FPID` should be the `employeeNo`, so the face ties to the same person record.
|
||||
- Use **Digest** auth, as with the other endpoints.
|
||||
- Replacing a photo is the same call — the device overwrites the FPID's face.
|
||||
|
||||
## Result the agent posts back
|
||||
|
||||
`POST api/agent/commands/{id}/result`
|
||||
|
||||
**Success:**
|
||||
```json
|
||||
{ "success": true, "resultJson": "{\"fpid\":\"HF6\"}" }
|
||||
```
|
||||
|
||||
The server reads `fpid` (also accepts `FPID`, `faceId`, `faceURL`) into
|
||||
`AccessProfile.FacePhotoFPID` — the handle needed to replace or delete the face later.
|
||||
Anything longer than 64 chars is truncated.
|
||||
|
||||
**Failure:**
|
||||
```json
|
||||
{ "success": false, "resultJson": "{\"detail\":\"No frontal face detected in the image\"}" }
|
||||
```
|
||||
|
||||
Put a human-readable reason in `detail` — it is surfaced to gym staff verbatim, so it
|
||||
should say what to do about it. Real Hikvision rejections worth mapping:
|
||||
|
||||
| Device response | Suggested `detail` |
|
||||
|---|---|
|
||||
| `lowFaceQuality` / no face found | `No clear frontal face was detected. Retake with the face centred and well lit.` |
|
||||
| face already bound to another FPID | `This face is already enrolled for another member.` |
|
||||
| library full | `The terminal's face library is full.` |
|
||||
| image format rejected | `The terminal rejected the image format.` |
|
||||
|
||||
## Retry behaviour
|
||||
|
||||
`MaxAttempts` is **3** for face commands (lower than the 5 used for person/state commands):
|
||||
a face rejected for quality will be rejected identically on every retry, so failing fast
|
||||
gets the real message in front of staff sooner.
|
||||
|
||||
- Retries use the existing exponential backoff.
|
||||
- On success **or** final failure the server clears `PayloadJson`, so the image is not
|
||||
retained. A retry between those points still has the payload available.
|
||||
- A newer photo supersedes an older queued one — the stale command is marked `Superseded`
|
||||
and its payload cleared. The agent should treat `Superseded` as "stop, discard".
|
||||
|
||||
## Deleting a face
|
||||
|
||||
The **agent** side is implemented (`DeleteUserFaceAsync`, plus a `DeleteFacePhoto` dispatch
|
||||
case), calling:
|
||||
|
||||
```
|
||||
PUT /ISAPI/Intelligent/FDLib/FDSearch/Delete?format=json&FDID=1&faceLibType=blackFD
|
||||
{ "FPID": [ { "value": "HF6" } ] }
|
||||
```
|
||||
|
||||
The **server** does not queue this command yet — nothing calls it when a member is removed.
|
||||
Adding that is a small change: queue `DeleteFacePhoto` with the stored `FacePhotoFPID`
|
||||
wherever a member is deactivated, then clear the face columns on success.
|
||||
|
||||
## Untested
|
||||
|
||||
Everything here compiles and the wire format matches on both sides, but **no part of this
|
||||
has run against a physical Hikvision terminal.** Expect to verify:
|
||||
|
||||
- `FDID = "1"` and `faceLibType = "blackFD"` are the right library on your model. Terminals
|
||||
with multiple face libraries may need a different FDID.
|
||||
- The multipart part names (`FaceDataRecord`, `FaceImage`) — these vary between firmware
|
||||
generations more than Hikvision's documentation suggests.
|
||||
- Whether your model accepts 480×640 at ~150 KB, or wants smaller.
|
||||
|
||||
The agent logs the full device response for both success and failure
|
||||
(`SetUserFace {EmployeeNo} ... : {Response}`), which is the fastest way to see what the
|
||||
terminal actually objects to on the first real run.
|
||||
@@ -0,0 +1,46 @@
|
||||
param(
|
||||
[string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$violations = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
function Add-Matches {
|
||||
param(
|
||||
[string]$Label,
|
||||
[string]$Pattern,
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$matches = & rg --line-number --glob '*.cs' $Pattern $Path 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
foreach ($match in $matches) {
|
||||
$violations.Add("${Label}: ${match}")
|
||||
}
|
||||
}
|
||||
elseif ($LASTEXITCODE -ne 1) {
|
||||
throw "Architecture scan failed while checking ${Label}."
|
||||
}
|
||||
}
|
||||
|
||||
Add-Matches `
|
||||
-Label 'Service database dependency' `
|
||||
-Pattern 'AppDBContext|Microsoft\.EntityFrameworkCore|GetRequiredService<AppDBContext>' `
|
||||
-Path (Join-Path $ProjectRoot 'Services')
|
||||
|
||||
Add-Matches `
|
||||
-Label 'Controller repository dependency' `
|
||||
-Pattern 'I[A-Za-z0-9]+Repo(sitory)?\b' `
|
||||
-Path (Join-Path $ProjectRoot 'Controllers')
|
||||
|
||||
Add-Matches `
|
||||
-Label 'Repository upward dependency or commit' `
|
||||
-Pattern 'Interfaces\.Services|I[A-Za-z0-9]+Service\b|IUnitOfWork|HttpContext|SaveChangesAsync\(' `
|
||||
-Path (Join-Path $ProjectRoot 'Repos')
|
||||
|
||||
if ($violations.Count -gt 0) {
|
||||
Write-Error ("Layering violations found:`n" + ($violations -join "`n"))
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output 'Layering check passed: Controller -> Service -> Repository -> AppDBContext.'
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Cleanup for the phantom tenant-side plan-category tables.
|
||||
--
|
||||
-- WorkoutCategory / MealCategory are central-only entities (CentralDbContext owns and
|
||||
-- seeds them). Before TenantDbContext ignored them, EF's reachability discovery pulled
|
||||
-- them into the tenant model via WorkoutPlan.WorkoutCategory / MealPlan.MealCategory,
|
||||
-- which created these empty tenant tables plus a real FK from the plan tables to them.
|
||||
-- Creating a gym's own plan then failed the constraint against a never-populated table.
|
||||
--
|
||||
-- The FK constraints have already been dropped. These tables are the leftover husks:
|
||||
-- they are empty, unreferenced, and no longer part of the EF model. Dropping them is
|
||||
-- optional housekeeping -- nothing reads or writes them.
|
||||
--
|
||||
-- Verify they are still empty before running (each should return 0):
|
||||
-- SELECT COUNT(*) FROM hexa_tenant_ironcore.workoutcategory;
|
||||
-- SELECT COUNT(*) FROM hexa_tenant_ironcore.mealcategory;
|
||||
|
||||
DROP TABLE IF EXISTS hexa_tenant_flexzone.workoutcategory;
|
||||
DROP TABLE IF EXISTS hexa_tenant_flexzone.mealcategory;
|
||||
|
||||
DROP TABLE IF EXISTS hexa_tenant_ironcore.workoutcategory;
|
||||
DROP TABLE IF EXISTS hexa_tenant_ironcore.mealcategory;
|
||||
|
||||
DROP TABLE IF EXISTS hexa_tenant_pulsefit.workoutcategory;
|
||||
DROP TABLE IF EXISTS hexa_tenant_pulsefit.mealcategory;
|
||||
|
||||
DROP TABLE IF EXISTS hexa_tenant_titanfit.workoutcategory;
|
||||
DROP TABLE IF EXISTS hexa_tenant_titanfit.mealcategory;
|
||||
Reference in New Issue
Block a user