1635 lines
48 KiB
Markdown
1635 lines
48 KiB
Markdown
# Superbox Go API Reference
|
||
|
||
> 后端 API 端点详尽文档。源自动词检索表 + 路由注册表 (`internal/router/router.go`) + handler/model 实现。
|
||
|
||
## 目录
|
||
|
||
- [基础信息](#基础信息)
|
||
- [统一响应信封](#统一响应信封)
|
||
- [认证机制](#认证机制)
|
||
- [访问控制矩阵](#访问控制矩阵)
|
||
- [错误码与 HTTP 状态码](#错误码与-http-状态码)
|
||
- [公共端点](#公共端点)
|
||
- [健康检查](#健康检查)
|
||
- [系统初始化](#系统初始化)
|
||
- [认证](#认证)
|
||
- [事件流 (SSE / WebSocket)](#事件流-sse--websocket)
|
||
- [API 端点](#api-端点)
|
||
- [Detectors 检测器管理](#detectors-检测器管理)
|
||
- [Cameras 摄像头管理](#cameras-摄像头管理)
|
||
- [Events 事件管理](#events-事件管理)
|
||
- [Rules 规则管理](#rules-规则管理)
|
||
- [Mediums 媒体文件](#mediums-媒体文件)
|
||
- [Webhooks 回调配置](#webhooks-回调配置)
|
||
- [Algorithms 算法管理](#algorithms-算法管理)
|
||
- [Samples 样本管理](#samples-样本管理)
|
||
- [Users 用户管理](#users-用户管理)
|
||
- [Settings 系统设置](#settings-系统设置)
|
||
- [Stream 视频流](#stream-视频流)
|
||
- [静态资源](#静态资源)
|
||
- [数据模型](#数据模型)
|
||
- [附录:约束与默认值](#附录约束与默认值)
|
||
- [JWT 与登录](#jwt-与登录)
|
||
- [Refresh token 行为](#refresh-token-行为)
|
||
- [客户端 silent refresh 协议](#客户端-silent-refresh-协议)
|
||
- [多 tab 已知限制](#多-tab-已知限制)
|
||
|
||
---
|
||
|
||
## 基础信息
|
||
|
||
- 基础路径: `/api/v1` (`/health` 例外,直接挂在根路径)
|
||
- 默认监听地址: `0.0.0.0:8000`(可在 `config.json → server.addr` 修改或 `serve --addr` 覆盖)
|
||
- 内容类型: `application/json; charset=utf-8`(除文件上传、媒体文件、HLS 之外)
|
||
- 字符编码: UTF-8
|
||
- 跨域: `Access-Control-Allow-Origin: *`,`Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS`
|
||
- 限流: 暂未实现
|
||
|
||
> 全部错误均带 JSON 状态码/详情字段(见下)。所有受保护接口需先 `/api/v1/auth/login` 拿到 JWT。
|
||
|
||
---
|
||
|
||
## 统一响应信封
|
||
|
||
所有非文件流的成功响应均使用 `response/response.go` 中定义的信封:
|
||
|
||
```json
|
||
{
|
||
"err": {
|
||
"ec": 0,
|
||
"dm": "ok",
|
||
"em": ""
|
||
},
|
||
"ret": <data>
|
||
}
|
||
```
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `err.ec` | int | 自定义错误码,`0` = 成功;与 HTTP 状态码同步 |
|
||
| `err.dm` | string | 错误描述宏(`ok` / `Bad Request` / `Not Found` / `Internal Server Error` 等) |
|
||
| `err.em` | string | 错误详情原文(成功时省略) |
|
||
| `ret` | any | 业务负载,结构视端点而异 |
|
||
|
||
### 列表型分页负载(`response.RetList`)
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"count": 42,
|
||
"next": null,
|
||
"previous": null,
|
||
"results": [ ... ]
|
||
}
|
||
}
|
||
```
|
||
|
||
`count` 为符合条件的总行数;`results` 为当前页的对象数组。`next` / `previous` 始终为 `null`(Django 兼容字段),分页通过 `limit` / `offset` query 参数控制。
|
||
|
||
### 数组型负载(`response.RetArray`)
|
||
|
||
`/api/v1/algorithms` 的 GET 接口直接返回数组,不带分页包装:
|
||
|
||
```json
|
||
{ "err": { "ec": 0, "dm": "ok" }, "ret": [ {...}, {...} ] }
|
||
```
|
||
|
||
### 空响应
|
||
|
||
`response.NoContent` 直接返回 `204 No Content`,无 body(用于 DELETE、PATCH 等无业务数据的接口)。
|
||
|
||
---
|
||
|
||
## 认证机制
|
||
|
||
### Token 体系(access + refresh)
|
||
|
||
后端使用 **stateful 双 token 模型**:
|
||
|
||
| Token | 格式 | 存储 | TTL | 用途 |
|
||
|-------|------|------|-----|------|
|
||
| `access_token` | HS256 JWT(含 `user_id` / `username` / `is_admin` / `exp` / `iat` claims) | 仅客户端 | `jwt.access_token_ttl`(默认 `15m`) | 访问受保护 API |
|
||
| `refresh_token` | 32 字节随机 → 43 字符 base64-url | 客户端明文 + 服务端存 **sha256 哈希** | `jwt.refresh_token_ttl`(默认 `7d`,**sliding**) | 换新 access + refresh 对 |
|
||
|
||
**关键属性**:
|
||
|
||
- **严格轮换**(strict rotation):每次 `POST /api/v1/auth/refresh` 成功都会签发新 pair,旧的 refresh 立即失效(在 DB 行上标记 `rotated_at = now()`,不删除)。
|
||
- **Reuse detection**:被轮换过的 refresh token 若被再次使用,**整个 family(同一登录会话的 token 链)全部吊销**,原持有者被迫重新登录。视为凭据泄露的应对策略。
|
||
- **Sliding window**:每次成功 refresh 都会把 `expires_at` 重新设为 `now + 7d`,活跃用户不需要重新输入密码。**真正决定"多久要重新登录"的是 refresh token 的不活跃时长**。
|
||
- **7 天不活跃就要重新登录**;7 天内活跃用户**永不重新登录**(直到主动 logout 或 reuse detection 触发)。
|
||
|
||
### 登录获取 token pair
|
||
|
||
调用 `POST /api/v1/auth/login`,返回:
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||
"refresh_token": "Z3pFmT7uVxKqY8sN2Lc4hJ1bP9aD5wE0rM6iO3yU2kX8vN4cB1gQ7zH5fS9a",
|
||
"expires_at": "2026-06-03T12:34:56Z",
|
||
"refresh_expires_at": "2026-06-10T12:34:56Z",
|
||
"user": { "id": 1, "username": "admin", "is_admin": true, "is_active": true }
|
||
}
|
||
}
|
||
```
|
||
|
||
JWT 负载 (`Claims`):
|
||
|
||
```json
|
||
{
|
||
"user_id": 1,
|
||
"username": "admin",
|
||
"is_admin": true,
|
||
"exp": 1748944496,
|
||
"iat": 1748943596
|
||
}
|
||
```
|
||
|
||
签名算法:HS256,密钥取自 `config.json → jwt.secret_key`,TTL 由 `jwt.access_token_ttl` 控制(默认 `15m`)。
|
||
|
||
### 续期 token(silent refresh)
|
||
|
||
access 过期前无需主动操作;前端 axios 拦截器会在 401 时自动:
|
||
|
||
1. 用 `localStorage.refresh_token` 调 `POST /api/v1/auth/refresh`
|
||
2. 成功后更新 localStorage + 重发原请求
|
||
3. 失败(refresh 也过期 / 被吊销 / reuse detected)则清状态并跳 `/admin/login`
|
||
|
||
5 个并发 401 共用一个 in-flight refresh 请求(模块级 Promise 缓存),避免 strict rotation 误触发 reuse detection 把整个 family 杀光。
|
||
|
||
### 携带 access token
|
||
|
||
任选其一:
|
||
|
||
```http
|
||
# Header 方式(推荐)
|
||
Authorization: Bearer <access_token>
|
||
|
||
# Query 方式(用于浏览器 HLS / SSE / WebSocket)
|
||
GET /api/v1/events/stream?token=<access_token>
|
||
GET /api/v1/cameras/1/stream?token=<access_token>
|
||
GET /hls/camera_1.m3u8?token=<access_token>
|
||
```
|
||
|
||
> 中间件优先读 `Authorization` 头,缺失时回退到 `?token=` query。
|
||
|
||
### 携带 refresh token
|
||
|
||
refresh token **不通过 header 传递**(永远不进 URL / Authorization 头),仅用于以下两种途径:
|
||
|
||
```http
|
||
# 1. 主动调 /auth/refresh 时放在 body
|
||
POST /api/v1/auth/refresh
|
||
Content-Type: application/json
|
||
{ "refresh_token": "<raw>" }
|
||
|
||
# 2. 主动 logout 时放在 body(让服务端能删 DB 行)
|
||
POST /api/v1/auth/logout
|
||
Authorization: Bearer <access>
|
||
{ "refresh_token": "<raw>" }
|
||
```
|
||
|
||
### 鉴权模式
|
||
|
||
| 模式 | 描述 | 失败返回 |
|
||
|------|------|----------|
|
||
| `Auth` | 必须携带有效 access JWT | `401 Unauthorized` |
|
||
| `AuthOrDetector` | access JWT 有效 *或* 调用方 IP 在 `detectors` 表中注册过 | `401 Unauthorized` |
|
||
| `AdminOnly` | 紧接 `Auth` 使用,要求 `is_admin == true` | `403 Forbidden` |
|
||
| 公开 | 无任何校验 | — |
|
||
|
||
### Detector 接入
|
||
|
||
当 `AuthOrDetector` 中间件检测到 `Authorization` 无效时,会读取 `c.ClientIP()`,查询 `detectors` 表的 `ip` 字段;命中即放行(在 gin context 写入 `detector_ip`),否则 401。Detector 自身用 `/auth/login` 登录后也走同一 refresh 机制。
|
||
|
||
---
|
||
|
||
## 访问控制矩阵
|
||
|
||
下表汇总每个端点的访问控制(C = 受 CORS 公开;A = JWT 必填;AD = 仅管理员;AO = JWT 或 Detector IP;AOO = JWT 或 Detector IP)。
|
||
|
||
| 路径 | 方法 | 鉴权 | 备注 |
|
||
|------|------|------|------|
|
||
| `/health` | GET | C | — |
|
||
| `/api/v1/health` | GET | C | — |
|
||
| `/api/v1/setup/config` | POST | C | 初始化写入 |
|
||
| `/api/v1/init` | GET, POST | C | 系统初始化 |
|
||
| `/api/v1/auth/login` | POST | C | 登录,颁发 access + refresh pair |
|
||
| `/api/v1/auth/refresh` | POST | C | 用 refresh token 换新 access + refresh pair |
|
||
| `/api/v1/auth/logout` | POST | A | 注销当前会话(access + refresh 双 token) |
|
||
| `/api/v1/events/stream` | GET (SSE) | A | 需 query token |
|
||
| `/api/v1/events/ws` | GET (WS) | A | 需 query token |
|
||
| `/api/v1/detectors` | GET, POST, DELETE | AD | 按角色限制 |
|
||
| `/api/v1/detectors/register` | POST | A | 检测器自助注册 |
|
||
| `/api/v1/detectors/:ip` | GET, PUT, PATCH, DELETE | AD | — |
|
||
| `/api/v1/cameras` | GET | A | — |
|
||
| `/api/v1/cameras` | POST | AD | — |
|
||
| `/api/v1/cameras/:id` | GET, PATCH | AO | — |
|
||
| `/api/v1/cameras/:id` | PUT | AD | — |
|
||
| `/api/v1/cameras/:id` | DELETE | AD | — |
|
||
| `/api/v1/cameras/:id/enable_sampling` | POST | AD | — |
|
||
| `/api/v1/cameras/:id/disable_sampling` | POST | AD | — |
|
||
| `/api/v1/cameras/:id/stream` | GET | A | 拉取 / 启动信息 |
|
||
| `/api/v1/cameras/:id/stream/start` | POST | AD | 启动转码 |
|
||
| `/api/v1/cameras/:id/stream/stop` | POST | AD | 停止转码 |
|
||
| `/api/v1/cameras/:id/snapshot` | GET | A | 拉取 JPEG 快照 |
|
||
| `/api/v1/events` | GET | A | 分页过滤 |
|
||
| `/api/v1/events` | POST | AO | 创建事件 |
|
||
| `/api/v1/events/:id` | GET | A | — |
|
||
| `/api/v1/events/:id` | PUT, PATCH, DELETE | AD | — |
|
||
| `/api/v1/events/:id/close_event` | POST | AD | — |
|
||
| `/api/v1/events/:id/push_event` | POST | AO | — |
|
||
| `/api/v1/events/queue_analysis` | POST | AO | — |
|
||
| `/api/v1/events/people_count` | POST | AO | — |
|
||
| `/api/v1/events/unmarked` | GET | A | — |
|
||
| `/api/v1/events/retrieve_ids` | GET | A | 拉取 ID 列表 |
|
||
| `/api/v1/events/retrieves` | GET | A | 拉取对象列表 |
|
||
| `/api/v1/events/delete_all` | POST | AD | 批量删除 |
|
||
| `/api/v1/rules` | GET | A | — |
|
||
| `/api/v1/rules` | POST | AD | — |
|
||
| `/api/v1/rules/:id` | GET | A | — |
|
||
| `/api/v1/rules/:id` | PUT, PATCH, DELETE | AD | — |
|
||
| `/api/v1/rules/camera/:camera_id` | GET | A | — |
|
||
| `/api/v1/rules/camera/:camera_id` | POST | AD | — |
|
||
| `/api/v1/mediums` | GET | A | — |
|
||
| `/api/v1/mediums` | POST | AO | multipart/form-data |
|
||
| `/api/v1/mediums/:id` | GET | A | — |
|
||
| `/api/v1/mediums/:id` | PUT, PATCH, DELETE | AD | — |
|
||
| `/api/v1/mediums/:id/file` | GET | A | 文件下载 |
|
||
| `/api/v1/webhooks` | GET, POST | AD | — |
|
||
| `/api/v1/webhooks/:id` | GET, PUT, PATCH, DELETE | AD | — |
|
||
| `/api/v1/webhooks/echo` | POST | AD | 回声测试 |
|
||
| `/api/v1/algorithms` | GET | AO | 数组返回 |
|
||
| `/api/v1/algorithms` | POST | AD | — |
|
||
| `/api/v1/algorithms/:code_name` | GET | AO | — |
|
||
| `/api/v1/algorithms/:code_name` | DELETE | AD | — |
|
||
| `/api/v1/samples` | GET, POST | AO | — |
|
||
| `/api/v1/samples/:id` | GET, PUT, PATCH, DELETE | AO | — |
|
||
| `/api/v1/samples/:id/upload_file` | POST | AO | 创建 SampleFile |
|
||
| `/api/v1/samples/files/:id/mark_uploaded` | POST | AO | 标记完成 |
|
||
| `/api/v1/users` | GET, POST | AD | — |
|
||
| `/api/v1/users/:id` | GET, PUT, PATCH, DELETE | AD | — |
|
||
| `/api/v1/users/:id/change_password` | POST | AD | — |
|
||
| `/api/v1/settings` | GET, POST | AD | — |
|
||
| `/api/v1/settings/:id` | GET, PUT, PATCH, DELETE | AD | — |
|
||
| `/api/v1/settings/name/:name` | GET | AD | — |
|
||
| `/api/v1/settings/by-name/:name` | PUT | AD | Upsert |
|
||
| `/api/v1/stream/webrtc/proxy` | GET | A | WebRTC → go2rtc 代理 |
|
||
| `/hls/*filepath` | GET | A | HLS 切片(query token) |
|
||
| `/storage/*filepath` | GET | C | 静态文件 |
|
||
| `/`, `/admin`, `/admin/*`, `/assets/*` | GET | C | 前端 SPA |
|
||
|
||
> 警告:`Auth` 中间件对未带 token 的请求直接 401,不进入 handler;`AuthOrDetector` 失败也回 401。
|
||
|
||
---
|
||
|
||
## 错误码与 HTTP 状态码
|
||
|
||
| HTTP | 场景 |
|
||
|------|------|
|
||
| `200 OK` | 默认成功 |
|
||
| `201 Created` | POST 创建成功(`response.RetCreated`) |
|
||
| `204 No Content` | DELETE / 无业务数据 PATCH |
|
||
| `400 Bad Request` | 参数缺失、JSON 解析失败、字段校验未通过 |
|
||
| `401 Unauthorized` | Token 缺失或无效 |
|
||
| `403 Forbidden` | 非管理员访问受控端点 |
|
||
| `404 Not Found` | 资源不存在 |
|
||
| `500 Internal Server Error` | 未捕获异常 / 服务层错误 / panic(被 Recovery 中间件捕获) |
|
||
|
||
SSE/WS/HLS 在 token 缺失或无效时返回 `401 Unauthorized`(`{"error": "..."}`,信封外)。
|
||
|
||
---
|
||
|
||
## 公共端点
|
||
|
||
### 健康检查
|
||
|
||
#### `GET /health`
|
||
|
||
无鉴权。仅返回 `{"status":"ok"}`。
|
||
|
||
```http
|
||
GET /health HTTP/1.1
|
||
|
||
HTTP/1.1 200 OK
|
||
Content-Type: application/json
|
||
|
||
{ "status": "ok" }
|
||
```
|
||
|
||
#### `GET /api/v1/health`
|
||
|
||
同上的别名挂载(`/api/v1/health`)。
|
||
|
||
---
|
||
|
||
### 系统初始化
|
||
|
||
> 仅用于系统首次部署;正常情况下使用 `superbox config init` CLI。
|
||
|
||
#### `GET /api/v1/init`
|
||
|
||
读取数据库中管理员用户数量。
|
||
|
||
```json
|
||
// Response 200
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": { "initialized": true, "needs_setup": false }
|
||
}
|
||
```
|
||
|
||
> 数据库不可达时也返回 `{"initialized": false, "needs_setup": true}`(兜底)。
|
||
|
||
#### `POST /api/v1/init`
|
||
|
||
创建初始管理员(仅当尚无管理员用户时成功)。
|
||
|
||
请求体:
|
||
|
||
```json
|
||
{ "username": "ops", "password": "Str0ng-Pass!" }
|
||
```
|
||
|
||
校验规则:
|
||
- username 非空
|
||
- password 长度 ≥ 8,且不等于 username
|
||
- 命中弱密码黑名单(`password`、`12345678`、`qwerty`、`admin`、`letmein`、`welcome`,不区分大小写)会被拒
|
||
|
||
成功:
|
||
|
||
```json
|
||
{ "err": { "ec": 0, "dm": "ok" }, "ret": { "success": true } }
|
||
```
|
||
|
||
失败:
|
||
- 管理员已存在 → `400 admin user already exists`
|
||
- 密码过弱 → `400 password too weak`
|
||
|
||
#### `POST /api/v1/setup/config`
|
||
|
||
无数据库时仍可调用:写 `config.json` + 连接数据库 + AutoMigrate + 创建管理员,一次性走完。
|
||
|
||
请求体(`SetupConfigRequest`):
|
||
|
||
```json
|
||
{
|
||
"database": {
|
||
"type": "sqlite | postgres | mysql",
|
||
"host": "127.0.0.1",
|
||
"port": 5432,
|
||
"name": "superbox.db",
|
||
"user": "superbox",
|
||
"password": "secret"
|
||
},
|
||
"cloud": { "box_file": "...", "http_server": "...", "ws_server": "..." },
|
||
"storage": {
|
||
"type": "local",
|
||
"local": { "path": "./data" }
|
||
},
|
||
"admin": { "username": "ops", "password": "Str0ng-Pass!" }
|
||
}
|
||
```
|
||
|
||
成功:
|
||
|
||
```json
|
||
{ "err": { "ec": 0, "dm": "ok" }, "ret": { "success": true, "config_path": "/etc/superbox/config.json" } }
|
||
```
|
||
|
||
副作用:
|
||
- 写入文件权限 `0600` 的 `config.json`
|
||
- 自动生成 32 字节随机 `jwt.secret_key`(hex)
|
||
- 默认 `access_token_ttl=15m`、`refresh_token_ttl=7d`、`storage.type=local`、go2rtc 默认参数
|
||
|
||
---
|
||
|
||
### 认证
|
||
|
||
> 全部 3 个端点走 `application/json` 信封(`{"err":{...},"ret":...}`),见前文「统一响应信封」。
|
||
|
||
#### `POST /api/v1/auth/login`
|
||
|
||
- **鉴权**:公开(无 token)
|
||
- **Content-Type**: `application/json`
|
||
- **副作用**:
|
||
- 在 `refresh_tokens` 表 INSERT 一行(`family_id` 为新 UUIDv4,`expires_at = now + 7d`)
|
||
- 更新 `users.last_login = now`(失败时记 WARN,不影响登录结果)
|
||
|
||
请求:
|
||
|
||
```json
|
||
{ "username": "ops", "password": "Str0ng-Pass!" }
|
||
```
|
||
|
||
字段约束:
|
||
- `username`、`password` 均为 `binding:"required"`,缺失 → `400 Bad Request`
|
||
- 用户名 / 密码错误 或 用户 `is_active=false` → `401 invalid credentials`(不区分两种情况,避免账号枚举)
|
||
|
||
成功响应(`LoginResult`):
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||
"refresh_token": "Z3pFmT7uVxKqY8sN2Lc4hJ1bP9aD5wE0rM6iO3yU2kX8vN4cB1gQ7zH5fS9a",
|
||
"expires_at": "2026-06-03T12:34:56Z",
|
||
"refresh_expires_at": "2026-06-10T12:34:56Z",
|
||
"user": {
|
||
"id": 1,
|
||
"username": "ops",
|
||
"is_admin": true,
|
||
"is_active": true
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `access_token` | string | HS256 JWT;客户端用于 `Authorization: Bearer ...` |
|
||
| `refresh_token` | string | 43 字符 base64-url 随机串;客户端**必须**持久化(localStorage);服务端只存 sha256 哈希 |
|
||
| `expires_at` | RFC 3339 string | access token 过期时间(默认 = now + 15m) |
|
||
| `refresh_expires_at` | RFC 3339 string | refresh token 过期时间(默认 = now + 7d) |
|
||
| `user` | object | 登录用户对象(同 `UserResponse`) |
|
||
|
||
失败:
|
||
- 缺字段 / JSON 解析失败 → `400 Bad Request`
|
||
- 用户名 / 密码错 / 用户禁用 → `401 invalid credentials`
|
||
- 内部错误(DB 写失败等)→ `500 Internal server error`
|
||
|
||
---
|
||
|
||
#### `POST /api/v1/auth/refresh`
|
||
|
||
- **鉴权**:公开(无 access token);用 **body 里的 refresh_token** 验真
|
||
- **作用**:用 refresh token 换新 access + refresh pair(**strict rotation**)
|
||
- **副作用**:
|
||
- 在事务中:把旧行的 `rotated_at` 设为 `now()`,并 INSERT 新行(同 `family_id`,新 `token_hash`,新 `expires_at = now + 7d`)
|
||
- **不**更新 `users.last_login`(refresh 不算"登录")
|
||
- **行为**(按结果分类):
|
||
|
||
| 条件 | 行为 | HTTP |
|
||
|------|------|------|
|
||
| body 缺 `refresh_token` | 400 | `400 Bad Request` |
|
||
| DB 找不到该 `sha256(refresh_token)` | 401(= invalid) | `401 refresh failed` |
|
||
| 旧行 `rotated_at` 非空(reuse) | **杀整个 family**(DELETE WHERE family_id = ?),401 | `401 refresh failed` |
|
||
| `expires_at` 已过 | 401 | `401 refresh failed` |
|
||
| 用户不存在 / `is_active=false` | **杀 family**,401 | `401 refresh failed` |
|
||
| 其它 DB 错误 | 500 | `500 Internal server error` |
|
||
| 成功 | 200 + 新 pair | `200 OK` |
|
||
|
||
> **故意统一 401 响应**(无论 invalid / expired / reused),前端不区分具体原因,UX 一致即可。
|
||
|
||
请求:
|
||
|
||
```json
|
||
{ "refresh_token": "Z3pFmT7uVxKqY8sN2Lc4hJ1bP9aD5wE0rM6iO3yU2kX8vN4cB1gQ7zH5fS9a" }
|
||
```
|
||
|
||
成功响应(同 `LoginResult` 形状):
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"access_token": "<NEW access JWT>",
|
||
"refresh_token": "<NEW refresh token>",
|
||
"expires_at": "2026-06-03T12:50:00Z",
|
||
"refresh_expires_at": "2026-06-10T12:50:00Z",
|
||
"user": { "id": 1, "username": "ops", "is_admin": true, "is_active": true }
|
||
}
|
||
}
|
||
```
|
||
|
||
> 旧 refresh token 在响应后立即失效;继续用旧 token 调本端点会触发 reuse detection,杀 family。
|
||
|
||
---
|
||
|
||
#### `POST /api/v1/auth/logout`
|
||
|
||
- **鉴权**:必填 access token(`Authorization: Bearer <access>`)—— 中间件从中解析 `user_id`
|
||
- **作用**:删 `refresh_tokens` 表中**当前会话**对应的那一行;不删其它会话(多端点登录互不影响)
|
||
- **Idempotent**:找不到行或 `refresh_token` 为空都返 `200`
|
||
|
||
请求:
|
||
|
||
```http
|
||
POST /api/v1/auth/logout
|
||
Authorization: Bearer <access_token>
|
||
Content-Type: application/json
|
||
|
||
{ "refresh_token": "Z3pFmT7uVxKqY8sN2Lc4hJ1bP9aD5wE0rM6iO3yU2kX8vN4cB1gQ7zH5fS9a" }
|
||
```
|
||
|
||
> `refresh_token` 在 body 里是**可选**的(缺省 / 解析失败都容忍),便于前端在 access 已过期的情况下也能 logout。
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{ "err": { "ec": 0, "dm": "ok" }, "ret": {} }
|
||
```
|
||
|
||
失败:
|
||
- 缺 `Authorization` 头 / access token 无效 → `401 Unauthorized`(由中间件返)
|
||
- service 层 DB 错误 → `500 Internal server error`
|
||
|
||
> 当前只退出"当前会话"。**没有**"退出所有会话"端点,也不主动吊销其它 family 的 token。要彻底吊销需要 `DELETE FROM refresh_tokens WHERE user_id = ?` 手工执行(DB 层)。
|
||
|
||
|
||
---
|
||
|
||
### 事件流 (SSE / WebSocket)
|
||
|
||
#### `GET /api/v1/events/stream` (Server-Sent Events)
|
||
|
||
- 鉴权:通过 `?token=` query 传递 JWT
|
||
- Content-Type: `text/event-stream`
|
||
- 帧格式:`event: <type>\ndata: <json>\n\n`
|
||
|
||
事件类型(`sse.EventType`):
|
||
|
||
| `type` | 触发时机 | `data` 字段 |
|
||
|--------|----------|-------------|
|
||
| `event_create` | 新事件入库(POST /events、`POST /events/:id/push_event`、detector 上报成功) | `{ id, camera_id, camera_name, types, status, started_at, thumbnail_url }` |
|
||
| `event_update` | 事件字段更新(保留) | 与 `event_create` 同结构 |
|
||
|
||
控制开关:
|
||
- 读取 `settings.frontend.event_popup_enabled`(值 `"false"` 时停止推送,默认/缺失 → 启用)
|
||
- 设置后由 `SettingHandler.UpsertByName` 同步失效 SSE 内部缓存(30s TTL)
|
||
|
||
> 详情:见 `internal/sse/events.go` 与 `internal/handler/event_handler.go` 的 `publishEventCreate`。
|
||
|
||
#### `GET /api/v1/events/ws` (WebSocket)
|
||
|
||
- 鉴权:`?token=` query JWT
|
||
- 同源策略已禁用(`CheckOrigin: return true`)
|
||
- 心跳:服务端每 54s 发 Ping;客户端需在 60s 内响应 Pong
|
||
- 接收消息格式与 SSE 一致(`type` + `data`),由 `ws.Manager.BroadcastJSON` 转发
|
||
|
||
握手失败(无 token / 无效 token)返回 `401 {"error": "..."}`。
|
||
|
||
---
|
||
|
||
## API 端点
|
||
|
||
### Detectors 检测器管理
|
||
|
||
> 边缘 AI 检测器注册表。`AuthOrDetector` 中间件用这里的 IP 实现"凭 IP 信任"。
|
||
|
||
#### `GET /api/v1/detectors`
|
||
|
||
**鉴权**: Admin
|
||
**响应**: `{ ret: { count, results: DetectorResponse[] } }`
|
||
|
||
```json
|
||
// DetectorResponse
|
||
{ "ip": "192.168.1.20", "type": "jetson", "version": "1.4.0" }
|
||
```
|
||
|
||
#### `POST /api/v1/detectors`
|
||
|
||
**鉴权**: Admin
|
||
|
||
请求体:
|
||
|
||
```json
|
||
{ "ip": "192.168.1.20", "type": "jetson", "version": "1.4.0" }
|
||
```
|
||
|
||
> `ip` 必填(数据库唯一索引)。
|
||
|
||
成功返回 `201 Created` + 新建对象。
|
||
|
||
#### `GET /api/v1/detectors/:ip`
|
||
|
||
**鉴权**: Admin
|
||
**路径参数**: `ip` — 字符串 IP 地址
|
||
|
||
成功返回单个 `DetectorResponse`,未找到 → `404 detector not found`。
|
||
|
||
#### `PUT /api/v1/detectors/:ip` / `PATCH /api/v1/detectors/:ip`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 局部更新(`type`、`version`),`ip` 不可改。
|
||
|
||
返回更新后的对象。
|
||
|
||
#### `DELETE /api/v1/detectors/:ip`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204 No Content`。
|
||
|
||
#### `POST /api/v1/detectors/register`
|
||
|
||
**鉴权**: 任意已登录用户
|
||
**用途**: 检测器自服务注册/心跳。
|
||
|
||
请求体:
|
||
|
||
```json
|
||
{ "ip": "192.168.1.20", "type": "jetson", "version": "1.4.0" }
|
||
```
|
||
|
||
> 不存在则创建、存在则更新 `type` + `version`(凭 IP)。返回 `DetectorResponse`。
|
||
|
||
#### `DELETE /api/v1/detectors?id=<id>`
|
||
|
||
**鉴权**: Admin
|
||
**Query**: `id` (uint, 必填)
|
||
返回 `204 No Content`。未找到 → `404 detector not found`。
|
||
|
||
---
|
||
|
||
### Cameras 摄像头管理
|
||
|
||
#### `GET /api/v1/cameras`
|
||
|
||
**鉴权**: Auth
|
||
**响应**: `CameraResponse[]` 的分页包装。
|
||
|
||
```json
|
||
// CameraResponse
|
||
{
|
||
"id": 1,
|
||
"name": "Front Door",
|
||
"uri": "rtsp://user:pass@192.168.1.10/stream1",
|
||
"streaming_uri": "rtsp://192.168.1.10/stream1",
|
||
"mode": "detect",
|
||
"schedule": { /* JSON, 见 model.Camera.Schedule */ },
|
||
"status": "online",
|
||
"detect_params": { /* JSON */ },
|
||
"default_params": { /* JSON */ },
|
||
"rules": [ /* RuleResponse[] */ ],
|
||
"should_push": false,
|
||
"config_params": { /* JSON */ },
|
||
"sampling": "5m",
|
||
"note": null,
|
||
"snapshot": "snapshots/cam1.jpg",
|
||
"remote_id": "",
|
||
"raw_address": "192.168.1.10",
|
||
"ip": "192.168.1.10",
|
||
"port": 554
|
||
}
|
||
```
|
||
|
||
#### `POST /api/v1/cameras`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: `Camera` JSON(除 `id`/`created_at`/`updated_at` 外)。
|
||
成功 `201`,返回带数据库写入后字段的对象。
|
||
|
||
#### `GET /api/v1/cameras/:id`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**路径参数**: `id` uint
|
||
|
||
未找到 → `404 camera not found`。
|
||
|
||
#### `PUT /api/v1/cameras/:id`
|
||
|
||
**鉴权**: Admin
|
||
全量替换(`id` 来自 URL)。
|
||
|
||
#### `PATCH /api/v1/cameras/:id`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
局部更新(仅出现在 body 里的字段被覆盖,handler 未做 diff,传整个对象即可)。
|
||
|
||
#### `DELETE /api/v1/cameras/:id`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204`。
|
||
|
||
#### `POST /api/v1/cameras/:id/enable_sampling`
|
||
|
||
**鉴权**: Admin
|
||
将 `Camera.Sampling` 设为启用(`enableSampling` 在 service 层实现具体策略)。返回 `200 { ret: {} }`。
|
||
|
||
#### `POST /api/v1/cameras/:id/disable_sampling`
|
||
|
||
**鉴权**: Admin
|
||
对应关闭采样。
|
||
|
||
#### `GET /api/v1/cameras/:id/stream`
|
||
|
||
**鉴权**: Auth
|
||
|
||
返回当前拉流状态:
|
||
|
||
```json
|
||
// 已有 HLS 转码
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"status": "running",
|
||
"playlist": "<internal playlist path>",
|
||
"hls_url": "/hls/camera_1.m3u8",
|
||
"webrtc_url": "/api/v1/stream/webrtc/proxy?src=<encoded-uri>",
|
||
"webrtc_src": "rtsp://..."
|
||
}
|
||
}
|
||
```
|
||
|
||
```json
|
||
// 未启动
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"status": "stopped",
|
||
"webrtc_url": "/api/v1/stream/webrtc/proxy?src=<encoded-uri>",
|
||
"webrtc_src": "rtsp://..."
|
||
}
|
||
}
|
||
```
|
||
|
||
> 优先使用 `camera.streaming_uri`,未设置时退回 `camera.uri`。
|
||
|
||
#### `POST /api/v1/cameras/:id/stream/start`
|
||
|
||
**鉴权**: Admin
|
||
启动 ffmpeg → go2rtc → HLS。
|
||
|
||
返回:
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"status": "started",
|
||
"playlist": "<internal playlist path>",
|
||
"hls_url": "/hls/camera_1.m3u8",
|
||
"webrtc_url": "/api/v1/stream/webrtc/proxy?src=<encoded-uri>",
|
||
"webrtc_src": "rtsp://..."
|
||
}
|
||
}
|
||
```
|
||
|
||
> HLS 切片存放:`{storage.local.path}/hls/camera_<id>.{ts,m3u8}`,经 `/hls/*` 路由鉴权后由 `ServeHLS` 读取。
|
||
|
||
#### `POST /api/v1/cameras/:id/stream/stop`
|
||
|
||
**鉴权**: Admin
|
||
|
||
```json
|
||
{ "err": { "ec": 0, "dm": "ok" }, "ret": { "status": "stopped" } }
|
||
```
|
||
|
||
#### `GET /api/v1/cameras/:id/snapshot`
|
||
|
||
**鉴权**: Auth
|
||
**响应**: `image/jpeg` 二进制(`c.Data(200, "image/jpeg", snapshot)`),由 `stream.Manager.TakeSnapshot` 通过 ffmpeg 单帧抓取。
|
||
|
||
未配置 URI → `400 camera has no stream URI configured`。
|
||
|
||
---
|
||
|
||
### Events 事件管理
|
||
|
||
#### `GET /api/v1/events`
|
||
|
||
**鉴权**: Auth
|
||
**支持的 Query 参数**:
|
||
|
||
| 参数 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `camera_id` | uint / 逗号分隔 uint | 单值精确匹配;多值转 `camera_id__in` 语义 |
|
||
| `status` | string | 精确匹配 `events.status` |
|
||
| `types` | 逗号分隔 string | 不区分大小写;空段会被丢弃 |
|
||
| `time_after` | RFC3339 | `started_at >= value` |
|
||
| `time_before` | RFC3339 | `started_at <= value` |
|
||
| `limit` | int | 默认 20,上限 1000 |
|
||
| `offset` | int | 默认 0 |
|
||
| `ordering` | string | `"field"`(ASC) / `"-field"`(DESC),字段白名单见 repo;非法 → `id DESC` |
|
||
|
||
**响应**: 标准分页。`results: EventResponse[]`:
|
||
|
||
```json
|
||
{
|
||
"id": 123,
|
||
"camera_id": 1,
|
||
"started_at": "2026-06-03T08:30:00Z",
|
||
"ended_at": null,
|
||
"mediums": [
|
||
{ "id": 456, "name": "frame.jpg", "file": "mediums/event_1/.../frame.jpg", "event_id": 1 }
|
||
],
|
||
"camera": { /* CameraResponse */ },
|
||
"types": "person,vehicle",
|
||
"types_bits": 6,
|
||
"obj_types": null,
|
||
"uuid": "0c1b...",
|
||
"status": "open",
|
||
"remark": "",
|
||
"should_push": false,
|
||
"metadata": null
|
||
}
|
||
```
|
||
|
||
> 任意 query 参数解析失败会**静默忽略**(不返回 400),与 Python 版本行为一致。
|
||
|
||
#### `GET /api/v1/events/:id`
|
||
|
||
**鉴权**: Auth
|
||
返回单个 `EventResponse`。未找到 → `404 event not found`。
|
||
|
||
#### `POST /api/v1/events`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**Body**: `Event` JSON
|
||
|
||
必填字段:
|
||
- `camera_id` (uint)
|
||
- `types` (string,去空白后非空)
|
||
|
||
可选:
|
||
- `mediums: [{id, name, file, ...}]` — 仅 `id` 会被采纳,自动设置 `event.medium_id = mediums[0].id`,然后 `event.mediums` 在 handler 中清空(重新加载时由 service 预加载)。
|
||
|
||
成功 `201`,返回 `EventResponse`,并**广播** SSE/WS `event_create`(受 `frontend.event_popup_enabled` 开关控制)。
|
||
|
||
#### `PUT /api/v1/events/:id` / `PATCH /api/v1/events/:id`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 完整 `Event`(`id` 来自 URL)。
|
||
|
||
> 当前实现:把 `id` 之外的字段**全量覆盖**到数据库行(service 层 `Save` / `Updates`),不是真正的 PATCH 语义。
|
||
|
||
#### `DELETE /api/v1/events/:id`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204`。
|
||
|
||
#### `POST /api/v1/events/:id/close_event`
|
||
|
||
**鉴权**: Admin
|
||
关闭事件(设置 `ended_at` 等,具体见 `EventService.CloseEvent`)。
|
||
|
||
#### `POST /api/v1/events/delete_all`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: `[1, 2, 3]` — uint ID 数组
|
||
返回 `204`。
|
||
|
||
#### `POST /api/v1/events/:id/push_event`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
|
||
同 `POST /events`,但在 service 层走 `PushEvent`(语义:可能触发"事件升级 / 推送规则"链路)。成功同样广播 SSE `event_create`。
|
||
|
||
#### `POST /api/v1/events/queue_analysis`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**Body**:
|
||
|
||
```json
|
||
{ "camera_name": "Front Door", "type": "motion" }
|
||
```
|
||
|
||
成功 `201`,返回 `EventResponse`。`camera_name` 与 `type` 均必填。
|
||
|
||
#### `POST /api/v1/events/people_count`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**Body**:
|
||
|
||
```json
|
||
{
|
||
"macAddress": "AA:BB:CC:DD:EE:FF",
|
||
"eventType": "PeopleCounting",
|
||
"enter": 3,
|
||
"exit": 1,
|
||
"startTime": "2026-06-03T08:00:00Z",
|
||
"endTime": "2026-06-03T08:30:00Z"
|
||
}
|
||
```
|
||
|
||
校验:
|
||
- `macAddress` 必填
|
||
- `eventType` 必须严格等于 `"PeopleCounting"`(其他值 → `400 event_type must be 'PeopleCounting'`)
|
||
|
||
成功 `201`,返回 `EventResponse`。
|
||
|
||
#### `GET /api/v1/events/unmarked`
|
||
|
||
**鉴权**: Auth
|
||
返回每个摄像头未标记(`remark == ''`)的事件数:
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": [
|
||
{ "camera_id": 1, "count": 5 },
|
||
{ "camera_id": 2, "count": 0 }
|
||
]
|
||
}
|
||
```
|
||
|
||
> 数据结构由 `EventService.CountUnmarkedByCamera` 返回。
|
||
|
||
#### `GET /api/v1/events/retrieve_ids`
|
||
|
||
**鉴权**: Auth
|
||
**用途**: Python 兼容 cursor 风格拉取 ID 列表(`id >= start_id`,升序,最多 10000)。
|
||
|
||
Query 参数:
|
||
- `start_id` (uint, 默认 `1`)
|
||
- `id` (uint) — 限定单条
|
||
- 其它同 `GET /events`(`camera_id` / `status` / `types` / 时间范围 / `limit` / `offset` / `ordering`)
|
||
|
||
响应:
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": { "ids": [101, 102, 103], "total": 3 }
|
||
}
|
||
```
|
||
|
||
#### `GET /api/v1/events/retrieves`
|
||
|
||
**鉴权**: Auth
|
||
**用途**: Python 兼容"倒序 + has_next"分页。
|
||
|
||
Query 参数:
|
||
- `start_id` (uint, 默认 `0xffffffff`) — 拉取 `id < start_id`
|
||
- `id` (uint) — 限定单条
|
||
- `limit` (int, 默认 20, 上限 1000, 负数 → 0)
|
||
- 其它同 `GET /events`
|
||
|
||
响应:
|
||
|
||
```json
|
||
{
|
||
"err": { "ec": 0, "dm": "ok" },
|
||
"ret": {
|
||
"objects": [ /* EventResponse[] */ ],
|
||
"total": 7,
|
||
"has_next": true
|
||
}
|
||
}
|
||
```
|
||
|
||
> 当传 `?id=<n>` 时走快速路径,直接返回单条 + `has_next: false`。
|
||
|
||
---
|
||
|
||
### Rules 规则管理
|
||
|
||
#### `GET /api/v1/rules`
|
||
|
||
**鉴权**: Auth
|
||
**响应**: `RuleResponse[]` 分页包装。
|
||
|
||
```json
|
||
// RuleResponse
|
||
{
|
||
"id": 7,
|
||
"camera": 1,
|
||
"unique_id": "uuid-v4",
|
||
"name": "person-rule",
|
||
"mode": "detect",
|
||
"algo": "yolov8n",
|
||
"params": { /* JSON */ },
|
||
"params_base": { /* JSON */ },
|
||
"schedule": { /* JSON */ },
|
||
"event_types": { "person": "Person" }
|
||
}
|
||
```
|
||
|
||
> `event_types` 是从数据库 `JSON` 列中只保留 string→string 键值对后的精简版(`model.RuleResponse` 注释)。
|
||
|
||
#### `POST /api/v1/rules`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 完整 `Rule`(`camera_id` 必填)。返回 `201` + `RuleResponse`。
|
||
|
||
#### `GET /api/v1/rules/:id`
|
||
|
||
**鉴权**: Auth
|
||
未找到 → `404 rule not found`。
|
||
|
||
#### `PUT /api/v1/rules/:id` / `PATCH /api/v1/rules/:id`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 局部覆盖(`name` / `mode` / `algo` / `schedule` 出现即覆盖)。
|
||
|
||
> 注意:`camera_id` 一旦设置后通过此接口**不能修改**。
|
||
|
||
#### `DELETE /api/v1/rules/:id`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204`。
|
||
|
||
#### `GET /api/v1/rules/camera/:camera_id`
|
||
|
||
**鉴权**: Auth
|
||
返回该摄像头下的所有规则(标准分页包装)。
|
||
|
||
#### `POST /api/v1/rules/camera/:camera_id`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: `Rule` JSON(无需包含 `camera_id`,URL 提供)。
|
||
返回 `201`。
|
||
|
||
---
|
||
|
||
### Mediums 媒体文件
|
||
|
||
> 文件存于 `{storage.local.path}/mediums/{event_<id>|unassigned}/<timestamp>_<name>.<ext>`,最大 **100MB**。
|
||
|
||
#### `GET /api/v1/mediums`
|
||
|
||
**鉴权**: Auth
|
||
**响应**: `MediumResponse[]` 分页包装:
|
||
|
||
```json
|
||
{
|
||
"id": 100,
|
||
"name": "frame.jpg",
|
||
"file": "mediums/event_1/1717400000_frame.jpg",
|
||
"event_id": 1
|
||
}
|
||
```
|
||
|
||
#### `POST /api/v1/mediums`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**Content-Type**: `multipart/form-data`
|
||
**字段**:
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| `file` | file | 是 | 最大 100MB |
|
||
| `name` | string | 否 | 默认取 `file.Filename` |
|
||
| `event_id` | uint (form 字段) | 否 | 关联事件 ID |
|
||
|
||
成功 `201`,返回 `MediumResponse`。
|
||
|
||
#### `GET /api/v1/mediums/:id`
|
||
|
||
**鉴权**: Auth
|
||
未找到 → `404 medium not found`。
|
||
|
||
#### `PUT /api/v1/mediums/:id` / `PATCH /api/v1/mediums/:id`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 局部更新(`name` / `file`)。
|
||
|
||
> ⚠️ 当前实现直接以 `file` 字符串覆盖原相对路径,**不会**把文件复制/移动到新位置。
|
||
|
||
#### `DELETE /api/v1/mediums/:id`
|
||
|
||
**鉴权**: Admin
|
||
**副作用**: 同时删除磁盘文件(若 `medium.file` 非空)。返回 `204`。
|
||
|
||
#### `GET /api/v1/mediums/:id/file`
|
||
|
||
**鉴权**: Auth
|
||
**响应**: 二进制流,附带 `Content-Disposition: inline; filename*=UTF-8''<encoded-name>` 头。
|
||
|
||
文件缺失 → `404 file not found`。
|
||
|
||
---
|
||
|
||
### Webhooks 回调配置
|
||
|
||
#### `GET /api/v1/webhooks`
|
||
|
||
**鉴权**: Admin
|
||
**响应**: `WebhookResponse[]` 分页:
|
||
|
||
```json
|
||
{
|
||
"id": 1,
|
||
"types": "person,vehicle",
|
||
"url": "https://example.com/hook",
|
||
"serializer": "default"
|
||
}
|
||
```
|
||
|
||
#### `POST /api/v1/webhooks`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 完整 `Webhook` JSON。返回 `201`。
|
||
|
||
#### `GET /api/v1/webhooks/:id`
|
||
|
||
**鉴权**: Admin
|
||
|
||
#### `PUT /api/v1/webhooks/:id` / `PATCH /api/v1/webhooks/:id`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 局部覆盖(`url` / `types` / `serializer`)。
|
||
|
||
#### `DELETE /api/v1/webhooks/:id`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204`。
|
||
|
||
#### `POST /api/v1/webhooks/echo`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 任意 JSON / 文本(最多读取 1MB)。
|
||
**响应**: 把请求体原样以字符串形式回显:
|
||
|
||
```json
|
||
{ "err": { "ec": 0, "dm": "ok" }, "ret": "{\"foo\":\"bar\"}" }
|
||
```
|
||
|
||
> 用于配置/连通性测试。
|
||
|
||
---
|
||
|
||
### Algorithms 算法管理
|
||
|
||
> 与 `Rule.algo` 字段关联的算法字典表(`code_name` 为主键)。
|
||
|
||
#### `GET /api/v1/algorithms`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**响应**: 直接数组(**无**分页包装,`RetArray`):
|
||
|
||
```json
|
||
{ "err": { "ec": 0, "dm": "ok" }, "ret": [ { "code_name": "yolov8n", "name": "YOLOv8 Nano" } ] }
|
||
```
|
||
|
||
#### `POST /api/v1/algorithms`
|
||
|
||
**鉴权**: Admin
|
||
**Body**:
|
||
|
||
```json
|
||
{ "code_name": "yolov8n", "name": "YOLOv8 Nano" }
|
||
```
|
||
|
||
#### `GET /api/v1/algorithms/:code_name`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**路径参数**: `code_name` (string, 主键)
|
||
未找到 → `404 algorithm not found`。
|
||
|
||
#### `DELETE /api/v1/algorithms/:code_name`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204`。
|
||
|
||
---
|
||
|
||
### Samples 样本管理
|
||
|
||
> 视频样本聚合(含若干 `SampleFile`),用于后续分析、上传到云端。
|
||
|
||
#### `GET /api/v1/samples`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**响应**: `Sample[]` 分页包装。
|
||
|
||
```json
|
||
// Sample
|
||
{
|
||
"id": "uuid-string",
|
||
"camera_id": 1,
|
||
"started_at": "2026-06-03T08:00:00Z",
|
||
"ended_at": "2026-06-03T08:05:00Z",
|
||
"notified_cloud": false,
|
||
"notified_cloud_at": null,
|
||
"expired": 0,
|
||
"file_count": 3,
|
||
"types": "person,vehicle",
|
||
"types_at": null,
|
||
"metadata": null,
|
||
"notified_guardian": false,
|
||
"notified_guardian_at": null,
|
||
"event_at": null,
|
||
"created_at": "2026-06-03T08:00:00Z",
|
||
"updated_at": "2026-06-03T08:00:00Z",
|
||
"sample_files": []
|
||
}
|
||
```
|
||
|
||
> `id` 是 `char(36)` 字符串(UUID),区别于其它资源的 uint 主键。
|
||
|
||
#### `POST /api/v1/samples`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**Body**: 完整 `Sample`(`id` 必填,需为合法 UUID)。
|
||
|
||
#### `GET /api/v1/samples/:id`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
未找到 → `404 sample not found`。
|
||
|
||
#### `PUT /api/v1/samples/:id` / `PATCH /api/v1/samples/:id`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**Body**: 完整 `Sample`(`id` 来自 URL)。
|
||
|
||
#### `DELETE /api/v1/samples/:id`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
返回 `204`。
|
||
|
||
#### `POST /api/v1/samples/:id/upload_file`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**Body**: 完整 `SampleFile` JSON(`id` 字段填入 URL 中的数字 ID):
|
||
|
||
```json
|
||
{
|
||
"path": "samples/<uuid>/file1.mp4",
|
||
"content_type": "mp4",
|
||
"s3_data": { /* optional JSON */ }
|
||
}
|
||
```
|
||
|
||
成功 `201`,返回 `SampleFile`:
|
||
|
||
```json
|
||
{
|
||
"id": 12,
|
||
"sample_id": "<uuid>",
|
||
"path": "samples/<uuid>/file1.mp4",
|
||
"content_type": "mp4",
|
||
"s3_data": null,
|
||
"uploaded_at": null,
|
||
"file_deleted": false,
|
||
"created_at": "2026-06-03T08:00:00Z",
|
||
"updated_at": "2026-06-03T08:00:00Z"
|
||
}
|
||
```
|
||
|
||
> 常量 `ContentTypeJPEG = "jpeg"` / `ContentTypePNG = "png"` / `ContentTypeMP4 = "mp4"`(`model.Sample`)。
|
||
|
||
#### `POST /api/v1/samples/files/:id/mark_uploaded`
|
||
|
||
**鉴权**: Auth **或** Detector IP
|
||
**路径参数**: `id` (uint, `SampleFile.ID`)
|
||
|
||
将 `SampleFile.UploadedAt` 设为当前时间。返回 `200 { ret: {} }`。
|
||
|
||
---
|
||
|
||
### Users 用户管理
|
||
|
||
#### `GET /api/v1/users`
|
||
|
||
**鉴权**: Admin
|
||
**响应**: `UserResponse[]` 分页:
|
||
|
||
```json
|
||
{ "id": 1, "username": "ops", "is_admin": true, "is_active": true }
|
||
```
|
||
|
||
> `password` 不会出现在响应中(`json:"-"`)。
|
||
|
||
#### `POST /api/v1/users`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 完整 `User` JSON。返回 `201`。
|
||
|
||
> 创建时密码通过 `AuthService.CreateUser` 加 bcrypt(默认 cost)后存储。重复用户名 → 服务层 `ErrUserExists` → `500`(当前实现未专门捕获 409 语义)。
|
||
|
||
#### `GET /api/v1/users/:id`
|
||
|
||
**鉴权**: Admin
|
||
|
||
#### `PUT /api/v1/users/:id` / `PATCH /api/v1/users/:id`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 局部覆盖(`username` / `is_active` / `is_admin`)。
|
||
|
||
> 通过此接口**不能**修改密码 — 使用 `POST /api/v1/users/:id/change_password`。
|
||
|
||
#### `DELETE /api/v1/users/:id`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204`。
|
||
|
||
#### `POST /api/v1/users/:id/change_password`
|
||
|
||
**鉴权**: Admin
|
||
**Body**:
|
||
|
||
```json
|
||
{ "old_password": "old", "new_password": "new-Str0ng!" }
|
||
```
|
||
|
||
> `AuthService.UpdatePassword`(`user_service.go`)的实际语义是仅验证 `old_password` 与新密码均非空后覆盖哈希。返回 `200 { ret: {} }`。
|
||
|
||
---
|
||
|
||
### Settings 系统设置
|
||
|
||
> KV 表(`name` 唯一索引)。SSE 推送开关即存于此。
|
||
|
||
#### `GET /api/v1/settings`
|
||
|
||
**鉴权**: Admin
|
||
**响应**: `SettingResponse[]` 分页:
|
||
|
||
```json
|
||
{ "id": 1, "name": "frontend.event_popup_enabled", "value": "true" }
|
||
```
|
||
|
||
#### `POST /api/v1/settings`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 完整 `Setting` JSON。返回 `201`。
|
||
|
||
#### `GET /api/v1/settings/:id`
|
||
|
||
**鉴权**: Admin
|
||
|
||
#### `GET /api/v1/settings/name/:name`
|
||
|
||
**鉴权**: Admin
|
||
**路径参数**: `name` (string)
|
||
未找到 → `404 setting not found`。
|
||
|
||
#### `PUT /api/v1/settings/:id` / `PATCH /api/v1/settings/:id`
|
||
|
||
**鉴权**: Admin
|
||
**Body**: 局部覆盖(`name` / `value`)。
|
||
|
||
#### `PUT /api/v1/settings/by-name/:name`
|
||
|
||
**鉴权**: Admin
|
||
**Body**:
|
||
|
||
```json
|
||
{ "value": "true" }
|
||
```
|
||
|
||
**Upsert 语义**:若 `name` 存在则更新 `value`,否则创建新行。
|
||
|
||
> **副作用**:调用成功后立即失效 SSE 内部的 `frontend.event_popup_enabled` 缓存(30s TTL),使管理界面开关立即生效。
|
||
|
||
#### `DELETE /api/v1/settings/:id`
|
||
|
||
**鉴权**: Admin
|
||
返回 `204`。
|
||
|
||
> 预置默认设置(启动时由 `cmd/superbox` 自动创建):
|
||
> - `frontend.event_popup_enabled` = `"true"`
|
||
|
||
---
|
||
|
||
### Stream 视频流
|
||
|
||
#### `GET /api/v1/stream/webrtc/proxy?src=<uri>`
|
||
|
||
**鉴权**: Auth
|
||
**Query**: `src` (string, 必填) — 原始媒体 URI
|
||
|
||
内部把 `src` 包装为 `ffmpeg:<src>#video=h264`,转发到 go2rtc 的 `http://127.0.0.1:<port>/api/stream.mp4?src=<encoded>`,并将响应体流式复制回客户端,保留 `Content-Type` / `Content-Length` 头。
|
||
|
||
错误:
|
||
- `src` 缺失 → `400 missing src parameter`
|
||
- go2rtc 返回非 200 → `500 go2rtc returned status <n>`
|
||
- 网络错误 → `500 failed to fetch stream: ...`
|
||
|
||
#### `GET /hls/*filepath`
|
||
|
||
**鉴权**: 通过 `?token=` query JWT
|
||
**用途**: 暴露 `{storage.local.path}/hls/` 下的转码产物。
|
||
**限制**: 文件后缀必须为 `.m3u8` 或 `.ts`,否则 `400 invalid file type`。
|
||
|
||
> 浏览器 `<video>` 元素播放 HLS 时,URL 模板:
|
||
> ```
|
||
> /hls/camera_<id>.m3u8?token=<JWT>
|
||
> ```
|
||
|
||
---
|
||
|
||
## 静态资源
|
||
|
||
| 路径 | 说明 |
|
||
|------|------|
|
||
| `/storage/*` | `{storage.local.path}` 下的所有文件(公开) |
|
||
| `/assets/*` | 嵌入的前端静态资源(公开) |
|
||
| `/admin`, `/admin/*` | 前端 SPA 入口(公开;带路径时回落到 `index.html`) |
|
||
| `/` | 前端根入口(公开) |
|
||
|
||
> 静态资源未走认证,直接 200。
|
||
|
||
---
|
||
|
||
## 数据模型
|
||
|
||
> 完整字段以 GORM tag 为准;以下 JSON 字段是 API 响应里出现的精简版。
|
||
|
||
### CameraResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `id` | uint | 主键 |
|
||
| `name` | string | 名称 |
|
||
| `uri` | string | 主码流 RTSP / HTTP URI |
|
||
| `streaming_uri` | string | 备用流地址(缺省时回退到 `uri`) |
|
||
| `mode` | string | `detect` / `sample` 等 |
|
||
| `schedule` | JSON | 排期(具体 schema 见 `Rule.Schedule`) |
|
||
| `status` | string | `online` / `offline` 等(由 `CameraMonitor` 周期刷新) |
|
||
| `detect_params` | JSON | 检测相关参数 |
|
||
| `default_params` | JSON | 默认参数 |
|
||
| `rules` | `RuleResponse[]` | 该摄像头的规则(外键) |
|
||
| `should_push` | bool | 是否推送至云 |
|
||
| `config_params` | JSON | 其它自定义配置 |
|
||
| `sampling` | string | 采样频率字符串(如 `5m`) |
|
||
| `note` | JSON | 备注 |
|
||
| `snapshot` | string | 静态预览图相对路径(`/storage/<snapshot>` 访问) |
|
||
| `remote_id` | string | 云端 ID(边缘同步) |
|
||
| `raw_address` | string | 裸 IP / 域名 |
|
||
| `ip` | string | 等同于 `raw_address`(保持 Django 字段兼容) |
|
||
| `port` | int | RTSP 端口 |
|
||
|
||
### EventResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `id` | uint | 主键 |
|
||
| `camera_id` | uint | 外键 |
|
||
| `started_at` | string (RFC3339Z) | 事件开始 |
|
||
| `ended_at` | string? (RFC3339Z) | 事件结束(可空) |
|
||
| `mediums` | `MediumResponse[]` | 关联媒体(外键 `EventID`) |
|
||
| `camera` | `CameraResponse?` | 嵌套摄像头(service 预加载) |
|
||
| `types` | string | 逗号分隔事件类型 |
|
||
| `types_bits` | uint64 | 类型位掩码 |
|
||
| `obj_types` | JSON | 目标类别详情 |
|
||
| `uuid` | string | 唯一 UUID |
|
||
| `status` | string | `open` / `closed` / `marked` 等 |
|
||
| `remark` | string | 备注(用于"标记"语义) |
|
||
| `should_push` | bool | 是否推送至云 |
|
||
| `metadata` | JSON | 任意附加元数据 |
|
||
|
||
### RuleResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `id` | uint | 主键 |
|
||
| `camera` | uint | `camera_id`(字段名 `camera` 保持 Django 兼容) |
|
||
| `unique_id` | string | UUID |
|
||
| `name` | string | — |
|
||
| `mode` | string | `detect` / `sample` |
|
||
| `algo` | string | 对应 `algorithms.code_name` |
|
||
| `params` | JSON | 当前参数 |
|
||
| `params_base` | JSON | 基础参数 |
|
||
| `schedule` | JSON | 排期 |
|
||
| `event_types` | map[string]string | 仅保留字符串键值对 |
|
||
|
||
### MediumResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `id` | uint | 主键 |
|
||
| `name` | string | 文件名 |
|
||
| `file` | string | 相对 `storage.local.path` 的路径 |
|
||
| `event_id` | uint* | 关联事件(可空) |
|
||
|
||
### DetectorResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `ip` | string | 唯一索引 |
|
||
| `type` | string | 设备类型 |
|
||
| `version` | string | 固件 / 软件版本 |
|
||
|
||
> 与 Django 一致:**不**返回 `id`、`created_at`、`updated_at`。
|
||
|
||
### WebhookResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `id` | uint | 主键 |
|
||
| `types` | string | 触发事件类型,逗号分隔 |
|
||
| `url` | string | 目标 URL |
|
||
| `serializer` | string | 序列化器名(默认 `default`) |
|
||
|
||
### UserResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `id` | uint | 主键 |
|
||
| `username` | string | 唯一索引 |
|
||
| `is_admin` | bool | 是否管理员 |
|
||
| `is_active` | bool | 是否启用 |
|
||
|
||
### SettingResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `id` | uint | 主键 |
|
||
| `name` | string | 唯一索引 |
|
||
| `value` | string | 字符串值(无类型) |
|
||
|
||
### AlgorithmResponse
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `code_name` | string | 主键 |
|
||
| `name` | string | 显示名 |
|
||
|
||
### Sample / SampleFile
|
||
|
||
见 `Samples` 章节中的字段说明。
|
||
|
||
---
|
||
|
||
## 附录:约束与默认值
|
||
|
||
### JWT 与登录
|
||
|
||
- **access token 签名**:HS256
|
||
- **密钥**:`config.json → jwt.secret_key`(32 字节 hex,CLI 命令 `superbox jwt key generate -s` 可生成并写回)
|
||
- **access token TTL**:`access_token_ttl`(默认 `15m`);由 JWT `exp` 声明携带,过期即失效
|
||
- **refresh token TTL**:`refresh_token_ttl`(默认 `7d`);由 `refresh_tokens.expires_at` 列存 DB,每次成功 refresh 都重置为 `now + 7d`(**sliding window**)
|
||
- **refresh token 格式**:`crypto/rand` 32 字节 → `base64.RawURLEncoding` → 43 字符 `[A-Za-z0-9_-]`
|
||
- **refresh token 存储**:服务端**只存 sha256 哈希**(`refresh_tokens.token_hash`),DB 泄露也不暴露明文
|
||
- **密码**:bcrypt `DefaultCost`(10)
|
||
- **弱密码黑名单**:`password`、`12345678`、`qwerty`、`admin`、`letmein`、`welcome`(不区分大小写)
|
||
- **用户名不可等于密码,不可为 `admin`**
|
||
|
||
### Refresh token 行为
|
||
|
||
- **Login** 颁发新 pair,INSERT 一行 `refresh_tokens`(`family_id` = 新 UUIDv4)
|
||
- **Refresh**(成功):事务里把旧行 `rotated_at = now()`,INSERT 新行(同 `family_id`、新 hash、`expires_at = now + 7d`)
|
||
- **Reuse detection**:被轮换过的旧 token 再次被提交 → `DELETE FROM refresh_tokens WHERE family_id = ?`(杀整个 family),返 401
|
||
- **User deactivation / deletion** 在 refresh 时被检测 → 杀 family,401
|
||
- **Logout**:删 `user_id + token_hash` 匹配的 1 行;找不到也返 `200`(idempotent)
|
||
- **无后台清理任务**(YAGNI):过期行自然累积,可手工 `DELETE FROM refresh_tokens WHERE expires_at < now()` 或跑 `RefreshTokenRepository.DeleteExpired()`
|
||
|
||
### 客户端 silent refresh 协议
|
||
|
||
- 前端 axios 响应拦截器在收到 `401` 时:
|
||
1. 若 `localStorage.refresh_token` 存在且请求**不是** `/auth/refresh` 本身且**未**重试过:调 `/auth/refresh`,成功后用新 token 重发原请求
|
||
2. 否则:清 localStorage、`window.location.href = '/admin/login'`
|
||
- **in-flight 去重**:5 个并发 401 共享一个 `/auth/refresh` 请求(模块级 Promise 缓存),避免 strict rotation 误触发 reuse detection
|
||
|
||
### 多 tab 已知限制
|
||
|
||
in-flight 去重是**单 page-load** 级别。两个 tab 同时触发 401 时:
|
||
|
||
- Tab 1 调 `/auth/refresh` 成功,family 已轮换
|
||
- Tab 2 调 `/auth/refresh` 命中 reuse detection,family 杀光
|
||
- 结果:两个 tab 都被强制重新登录
|
||
|
||
v1 不修。NVR admin 多 tab 不常见。下次重登即可。后续可加 `BroadcastChannel` 或 `Web Locks API`。
|
||
|
||
### 事件查询
|
||
|
||
- `limit` 默认 20,上限 **1000**
|
||
- `retrieve_ids` 返回最多 **10000** 条
|
||
- `retrieves` 的 `start_id` 默认 `0xffffffff`
|
||
- 任何 query 参数解析失败 → 静默忽略(不返回 400)
|
||
|
||
### 上传与存储
|
||
|
||
- `mediums` 文件大小上限 **100MB**(`storage.maxUploadSize`)
|
||
- 路径规则:`{storage.local.path}/mediums/{event_<id>|unassigned}/<ts_ms>_<safe-name>.<ext>`
|
||
- 文件名清洗:保留 `[a-zA-Z0-9_-]`,其它字符 → `_`,最长 50 字符
|
||
- `Medium.ServeFile` 时使用 `http.ServeContent`(支持 Range 与 `Last-Modified`)
|
||
|
||
### HLS 切片
|
||
|
||
- 文件后缀必须为 `.m3u8` 或 `.ts`,否则 400
|
||
- 路径限制在 `{storage.local.path}/hls/` 下
|
||
|
||
### WebRTC 代理
|
||
|
||
- 始终以 `ffmpeg:<src>#video=h264` 形式包装原始流,确保浏览器 H264 兼容
|
||
- go2rtc 默认端口 `1984`(由 `config.json → stream.go2rtc_port` 修改)
|
||
|
||
### SSE 推送
|
||
|
||
- 事件类型:`event_create`(新增)、`event_update`(保留)
|
||
- 触发点:`POST /api/v1/events`、`POST /api/v1/events/:id/push_event`(detector 上报 / 业务方创建)
|
||
- 受 `settings.frontend.event_popup_enabled` 开关控制(默认启用)
|
||
- 缓存 TTL:30 秒;`PUT /settings/by-name/<name>` 会主动失效
|
||
|
||
### Detector IP 信任
|
||
|
||
- 仅 `AuthOrDetector` 模式生效
|
||
- `detector.ip` 是 `size:64` 字符串(兼容 IPv4/IPv6)
|
||
- 触发时 `gin.Context` 写入 `detector_ip`,业务层可读取
|
||
|
||
### 中间件链顺序(全局)
|
||
|
||
1. `middleware.Recovery(logger)` — panic 兜底
|
||
2. `middleware.Logger(logger)` — 请求日志
|
||
3. `middleware.CORS()` — 跨域
|
||
|
||
### 启动行为(`cmd/superbox`)
|
||
|
||
- 配置文件查找顺序:`--config` → `viper.AddConfigPath(".", "./config", "/etc/superbox/")` → 默认数据目录
|
||
- 缺失时自动生成默认 `config.json`(含随机 JWT key)
|
||
- 启动时执行 `AutoMigrate` + `ensureDefaultSettings`(注入 `frontend.event_popup_enabled=true`)
|
||
- `CameraMonitor` 默认 `enabled=true`,`interval_sec=30`
|
||
|
||
### 数据库支持
|
||
|
||
- `sqlite` / `sqlite3`(默认)
|
||
- `postgres` / `postgresql`
|
||
- `mysql`(默认)
|