feat: 完善智慧停车综合管理平台源码
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
include /etc/nginx/modules-enabled/*.conf;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
types_hash_max_size 2048;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
gzip on;
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 500M;
|
||||
|
||||
location / {
|
||||
index index.html;
|
||||
root /usr/share/smart-parking-platform/frontend/dist;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location /public/ {
|
||||
proxy_pass http://localhost:3000/public/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection upgrade;
|
||||
proxy_set_header Accept-Encoding gzip;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection upgrade;
|
||||
proxy_set_header Accept-Encoding gzip;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location /hls {
|
||||
types {
|
||||
application/vnd.apple.mpegurl m3u8;
|
||||
video/mp2t ts;
|
||||
}
|
||||
alias /dev/shm/nginx-live;
|
||||
add_header Cache-Control no-cache;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rtmp {
|
||||
server {
|
||||
listen 1515;
|
||||
chunk_size 4096;
|
||||
application live {
|
||||
live on;
|
||||
hls on;
|
||||
hls_path /dev/shm/nginx-live;
|
||||
hls_cleanup on;
|
||||
hls_type live;
|
||||
hls_fragment_naming system;
|
||||
hls_fragment 2s;
|
||||
hls_playlist_length 10s;
|
||||
hls_fragment_slicing plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "smart-parking-backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^1.0.0",
|
||||
"@nestjs/common": "^9.4.0",
|
||||
"@nestjs/config": "^2.3.1",
|
||||
"@nestjs/core": "^9.4.0",
|
||||
"@nestjs/jwt": "^10.0.3",
|
||||
"@nestjs/platform-express": "^9.4.0",
|
||||
"@nestjs/platform-socket.io": "^9.4.0",
|
||||
"@nestjs/serve-static": "^3.0.1",
|
||||
"@nestjs/typeorm": "^9.0.1",
|
||||
"@nestjs/websockets": "^9.4.0",
|
||||
"cache-manager": "^5.2.1",
|
||||
"dayjs": "^1.11.7",
|
||||
"fs-extra": "^11.1.1",
|
||||
"mysql2": "^3.2.4",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.6.1",
|
||||
"typeorm": "^0.3.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.4.2",
|
||||
"@nestjs/schematics": "^9.1.0",
|
||||
"@nestjs/testing": "^9.4.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/jest": "29.5.0",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||
"@typescript-eslint/parser": "^5.59.1",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"jest": "29.5.0",
|
||||
"prettier": "^2.8.8",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "29.0.5",
|
||||
"ts-loader": "^9.4.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^4.9.5",
|
||||
"webpack": "^5.81.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
Generated
+5601
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,8 @@
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=smart_parking
|
||||
MYSQL_USER=parking
|
||||
MYSQL_PASSWORD=parking123
|
||||
JWT_SECRET=smart-parking-secret
|
||||
JWT_EXPIRES_IN=30d
|
||||
PUBLIC_DIR_ABSOLUTE_PATH=/var/www/html/public
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ControllersModule } from './controllers/controllers.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { WsGatewaysModule } from './ws-gateways/ws-gateways.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { DatabaseSeeder } from './database-seeder';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, envFilePath: 'server.config.env' }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'mysql',
|
||||
database: configService.get('MYSQL_DATABASE'),
|
||||
host: configService.get('MYSQL_HOST'),
|
||||
port: +configService.get('MYSQL_PORT'),
|
||||
username: configService.get('MYSQL_USER'),
|
||||
password: configService.get('MYSQL_PASSWORD'),
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
timezone: 'Z',
|
||||
}),
|
||||
}),
|
||||
CacheModule.register({ isGlobal: true, ttl: 0, max: 0 }),
|
||||
ServeStaticModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => [
|
||||
{
|
||||
rootPath: configService.get('PUBLIC_DIR_ABSOLUTE_PATH'),
|
||||
serveRoot: '/public',
|
||||
},
|
||||
],
|
||||
}),
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
signOptions: { expiresIn: configService.get('JWT_EXPIRES_IN') },
|
||||
}),
|
||||
}),
|
||||
ControllersModule,
|
||||
WsGatewaysModule,
|
||||
],
|
||||
providers: [DatabaseSeeder],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
describe('AdminController', () => {
|
||||
let controller: AdminController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AdminController>(AdminController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from 'src/guards/auth/auth.guard';
|
||||
import { RoleGuard } from 'src/guards/role/role.guard';
|
||||
import { AlarmRule } from 'src/services/alarm-rule/alarm-rule.entity';
|
||||
import { AlarmRuleService } from 'src/services/alarm-rule/alarm-rule.service';
|
||||
import { Camera } from 'src/services/camera/camera.entity';
|
||||
import { CameraService } from 'src/services/camera/camera.service';
|
||||
import { MapConfigService } from 'src/services/map-config/map-config.service';
|
||||
import { UserService } from 'src/services/user/user.service';
|
||||
import { UtilsService } from 'src/services/utils/utils.service';
|
||||
import { FetchTypes } from 'src/types/fetchTypes';
|
||||
|
||||
@Controller()
|
||||
@SetMetadata('role', 'admin')
|
||||
@UseGuards(AuthGuard, RoleGuard)
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private mapConfigService: MapConfigService,
|
||||
private utilsService: UtilsService,
|
||||
private cameraService: CameraService,
|
||||
private alarmRuleService: AlarmRuleService,
|
||||
private userService: UserService,
|
||||
) {}
|
||||
|
||||
@Post('/api/admin/updateMapConfig')
|
||||
async updateMapConfig(
|
||||
@Body() body: FetchTypes['POST /api/admin/updateMapConfig']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/updateMapConfig']['res']['data']> {
|
||||
if (
|
||||
body.layer.type === 'imageOverlay' &&
|
||||
body.layer.url.startsWith('data:image')
|
||||
) {
|
||||
const filePath = await this.utilsService.writeBase64ImageToFile(
|
||||
body.layer.url,
|
||||
);
|
||||
this.mapConfigService.updateConfig({
|
||||
layerType: 'imageOverlay',
|
||||
layerUrlOrPath: filePath,
|
||||
imageLayerBounds: body.layer.bounds,
|
||||
mapCenter: body.mapOptions.center,
|
||||
mapZoom: body.mapOptions.zoom,
|
||||
minZoom: body.mapOptions.minZoom,
|
||||
maxZoom: body.mapOptions.maxZoom,
|
||||
});
|
||||
} else {
|
||||
await this.mapConfigService.updateConfig({
|
||||
layerType: 'tileLayer',
|
||||
layerUrlOrPath: body.layer.url,
|
||||
mapCenter: body.mapOptions.center,
|
||||
mapZoom: body.mapOptions.zoom,
|
||||
minZoom: body.mapOptions.minZoom,
|
||||
maxZoom: body.mapOptions.maxZoom,
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Get('/api/admin/getCameraList')
|
||||
async getCameraList(): Promise<
|
||||
FetchTypes['GET /api/admin/getCameraList']['res']['data']
|
||||
> {
|
||||
const list = await this.cameraService.getList(true, true);
|
||||
return list.map((camera) => ({
|
||||
cameraID: camera.id,
|
||||
cameraName: camera.name,
|
||||
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||
latlng: [Number(camera.latitude), Number(camera.longitude)],
|
||||
cameraModel: camera.model,
|
||||
hlsUrl: this.cameraService.getHlsUrl(camera.id),
|
||||
rtspUrl: camera.rtspUrl,
|
||||
alarmRules:
|
||||
camera.alarmRules?.map((rule) => ({
|
||||
alarmRuleID: rule.id,
|
||||
alarmRuleName: rule.name,
|
||||
})) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
@Post('/api/admin/addCamera')
|
||||
async addCamera(
|
||||
@Body() body: FetchTypes['POST /api/admin/addCamera']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/addCamera']['res']['data']> {
|
||||
await this.cameraService.addCamera({
|
||||
name: body.cameraName,
|
||||
model: body.cameraModel,
|
||||
latitude: body.latlng[0],
|
||||
longitude: body.latlng[1],
|
||||
rtspUrl: body.rtspUrl,
|
||||
alarmRules:
|
||||
body.alarmRuleIDs?.map((id) => {
|
||||
const rule = new AlarmRule();
|
||||
rule.id = id;
|
||||
return rule;
|
||||
}) || [],
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('/api/admin/updateCamera')
|
||||
async updateCamera(
|
||||
@Body() body: FetchTypes['POST /api/admin/updateCamera']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/updateCamera']['res']['data']> {
|
||||
await this.cameraService.updateCamera({
|
||||
id: body.cameraID,
|
||||
name: body.cameraName,
|
||||
model: body.cameraModel,
|
||||
latitude: body.latlng[0],
|
||||
longitude: body.latlng[1],
|
||||
rtspUrl: body.rtspUrl,
|
||||
alarmRules: body.alarmRuleIDs.map((id) => {
|
||||
const rule = new AlarmRule();
|
||||
rule.id = id;
|
||||
return rule;
|
||||
}),
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('/api/admin/deleteCamera')
|
||||
async deleteCamera(
|
||||
@Body() body: FetchTypes['POST /api/admin/deleteCamera']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/deleteCamera']['res']['data']> {
|
||||
await this.cameraService.deleteCamera(body.cameraID);
|
||||
return {};
|
||||
}
|
||||
|
||||
@Get('/api/admin/getAlarmRuleList')
|
||||
async getAlarmRuleList(): Promise<
|
||||
FetchTypes['GET /api/admin/getAlarmRuleList']['res']['data']
|
||||
> {
|
||||
const list = await this.alarmRuleService.getList(true);
|
||||
|
||||
return list.map((rule) => ({
|
||||
alarmRuleID: rule.id,
|
||||
alarmRuleName: rule.name,
|
||||
enabled: rule.enabled,
|
||||
algorithmType: rule.algorithmType,
|
||||
relatedCameras:
|
||||
rule.relatedCameras?.map((camera) => ({
|
||||
cameraID: camera.id,
|
||||
cameraName: camera.name,
|
||||
})) || [],
|
||||
triggerCondition: {
|
||||
time: {
|
||||
dayOfWeek: rule.triggerDayOfWeek,
|
||||
timeRange: [rule.triggerTimeStart, rule.triggerTimeEnd],
|
||||
},
|
||||
count: {
|
||||
min: rule.triggerCountMin,
|
||||
max: rule.triggerCountMax,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@Post('/api/admin/addAlarmRule')
|
||||
async addAlarmRule(
|
||||
@Body() body: FetchTypes['POST /api/admin/addAlarmRule']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/addAlarmRule']['res']['data']> {
|
||||
await this.alarmRuleService.addRule({
|
||||
name: body.alarmRuleName,
|
||||
enabled: body.enabled,
|
||||
algorithmType: body.algorithmType,
|
||||
relatedCameras: body.relatedCameraIds.map((id) => {
|
||||
const camera = new Camera();
|
||||
camera.id = id;
|
||||
return camera;
|
||||
}),
|
||||
triggerDayOfWeek: body.triggerCondition.time.dayOfWeek,
|
||||
triggerTimeStart: body.triggerCondition.time.timeRange[0],
|
||||
triggerTimeEnd: body.triggerCondition.time.timeRange[1],
|
||||
triggerCountMin: body.triggerCondition.count.min,
|
||||
triggerCountMax: body.triggerCondition.count.max,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('/api/admin/updateAlarmRule')
|
||||
async updateAlarmRule(
|
||||
@Body() body: FetchTypes['POST /api/admin/updateAlarmRule']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/updateAlarmRule']['res']['data']> {
|
||||
await this.alarmRuleService.updateRule({
|
||||
id: body.alarmRuleID,
|
||||
name: body.alarmRuleName,
|
||||
enabled: body.enabled,
|
||||
algorithmType: body.algorithmType,
|
||||
relatedCameras: body.relatedCameraIds.map((id) => {
|
||||
const camera = new Camera();
|
||||
camera.id = id;
|
||||
return camera;
|
||||
}),
|
||||
triggerDayOfWeek: body.triggerCondition.time.dayOfWeek,
|
||||
triggerTimeStart: body.triggerCondition.time.timeRange[0],
|
||||
triggerTimeEnd: body.triggerCondition.time.timeRange[1],
|
||||
triggerCountMin: body.triggerCondition.count.min,
|
||||
triggerCountMax: body.triggerCondition.count.max,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('/api/admin/deleteAlarmRule')
|
||||
async deleteAlarmRule(
|
||||
@Body() body: FetchTypes['POST /api/admin/deleteAlarmRule']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/deleteAlarmRule']['res']['data']> {
|
||||
await this.alarmRuleService.deleteRule(body.alarmRuleID);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Get('/api/admin/getUserList')
|
||||
async getUserList(): Promise<
|
||||
FetchTypes['GET /api/admin/getUserList']['res']['data']
|
||||
> {
|
||||
const list = await this.userService.getUserList();
|
||||
return list.map((user) => ({
|
||||
username: user.username,
|
||||
avatarURL: user.avatarFilePath
|
||||
? this.utilsService.filePathToURL(user.avatarFilePath)
|
||||
: '',
|
||||
email: user.email ?? '',
|
||||
tel: user.tel ?? '',
|
||||
nickname: user.nickname,
|
||||
role: user.role,
|
||||
}));
|
||||
}
|
||||
|
||||
@Post('/api/admin/addUser')
|
||||
async addUser(
|
||||
@Body() body: FetchTypes['POST /api/admin/addUser']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/addUser']['res']['data']> {
|
||||
await this.userService.addUser({
|
||||
username: body.username,
|
||||
password: body.password,
|
||||
role: body.role,
|
||||
nickname: body.nickname,
|
||||
email: body.email,
|
||||
tel: body.tel,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('/api/admin/updateUser')
|
||||
async updateUser(
|
||||
@Body() body: FetchTypes['POST /api/admin/updateUser']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/updateUser']['res']['data']> {
|
||||
await this.userService.updateUser({
|
||||
username: body.username,
|
||||
password: body.newPassword,
|
||||
role: body.role,
|
||||
nickname: body.nickname,
|
||||
email: body.email,
|
||||
tel: body.tel,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('/api/admin/deleteUser')
|
||||
async deleteUser(
|
||||
@Body() body: FetchTypes['POST /api/admin/deleteUser']['req'],
|
||||
): Promise<FetchTypes['POST /api/admin/deleteUser']['res']['data']> {
|
||||
await this.userService.deleteUser(body.username);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CommonController } from './common.controller';
|
||||
|
||||
describe('CommonController', () => {
|
||||
let controller: CommonController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CommonController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<CommonController>(CommonController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { CameraService } from 'src/services/camera/camera.service';
|
||||
|
||||
import { UserService } from 'src/services/user/user.service';
|
||||
import { UtilsService } from 'src/services/utils/utils.service';
|
||||
import { FetchTypes } from 'src/types/fetchTypes';
|
||||
|
||||
@Controller()
|
||||
export class CommonController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private utilService: UtilsService,
|
||||
private cameraService: CameraService,
|
||||
) {}
|
||||
|
||||
@Post('/api/user/login')
|
||||
async userLogin(
|
||||
@Body() data: FetchTypes['POST /api/user/login']['req'],
|
||||
): Promise<FetchTypes['POST /api/user/login']['res']['data']> {
|
||||
const user = await this.userService.authLogin(data.username, data.password);
|
||||
|
||||
if (user) {
|
||||
return {
|
||||
token: await this.userService.genToken(user),
|
||||
userInfo: {
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
nickname: user.nickname,
|
||||
tel: user.tel ?? '',
|
||||
email: user.email ?? '',
|
||||
avatarURL: user.avatarFilePath
|
||||
? this.utilService.filePathToURL(user.avatarFilePath)
|
||||
: '',
|
||||
},
|
||||
};
|
||||
} else throw new ForbiddenException('用户名或密码错误');
|
||||
}
|
||||
|
||||
@Get('/api/ai/getOfflineCameraList')
|
||||
async getOfflineCameraList(
|
||||
@Query() data: FetchTypes['GET /api/ai/getOfflineCameraList']['req'],
|
||||
): Promise<FetchTypes['GET /api/ai/getOfflineCameraList']['res']['data']> {
|
||||
const user = await this.userService.authLogin(
|
||||
data.adminUsername,
|
||||
data.password,
|
||||
);
|
||||
if (!user || user.role !== 'admin') throw new UnauthorizedException();
|
||||
|
||||
const list = await this.cameraService.getOfflineList();
|
||||
return list.map((camera) => ({
|
||||
cameraID: camera.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserController } from './user/user.controller';
|
||||
import { CommonController } from './common/common.controller';
|
||||
import { AdminController } from './admin/admin.controller';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ExceptionTransformerFilter } from 'src/interceptors/exception-transformer/exception-transformer.filter';
|
||||
import { ResponseTransformerInterceptor } from 'src/interceptors/response-transformer/response-transformer.interceptor';
|
||||
import { ServicesModule } from 'src/services/services.module';
|
||||
|
||||
@Module({
|
||||
imports: [ServicesModule],
|
||||
controllers: [UserController, CommonController, AdminController],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: ExceptionTransformerFilter },
|
||||
{ provide: APP_INTERCEPTOR, useClass: ResponseTransformerInterceptor },
|
||||
],
|
||||
})
|
||||
export class ControllersModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserController } from './user.controller';
|
||||
|
||||
describe('UserController', () => {
|
||||
let controller: UserController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [UserController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<UserController>(UserController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Post,
|
||||
PreconditionFailedException,
|
||||
Query,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import dayjs from 'dayjs';
|
||||
import { UserInfo } from 'src/decorators/userinfo/userinfo.decorator';
|
||||
import { AuthGuard } from 'src/guards/auth/auth.guard';
|
||||
import { RoleGuard } from 'src/guards/role/role.guard';
|
||||
import { AlarmEventService } from 'src/services/alarm-event/alarm-event.service';
|
||||
import { CameraService } from 'src/services/camera/camera.service';
|
||||
import { MapConfigService } from 'src/services/map-config/map-config.service';
|
||||
import { AlarmRuleService } from 'src/services/alarm-rule/alarm-rule.service';
|
||||
import { UserService } from 'src/services/user/user.service';
|
||||
import { UtilsService } from 'src/services/utils/utils.service';
|
||||
import { FetchTypes } from 'src/types/fetchTypes';
|
||||
|
||||
@Controller()
|
||||
@SetMetadata('role', 'user')
|
||||
@UseGuards(AuthGuard, RoleGuard)
|
||||
export class UserController {
|
||||
constructor(
|
||||
private cameraService: CameraService,
|
||||
private alarmEventService: AlarmEventService,
|
||||
private userService: UserService,
|
||||
private utilsService: UtilsService,
|
||||
private mapConfigService: MapConfigService,
|
||||
private alarmRuleService: AlarmRuleService,
|
||||
) {}
|
||||
|
||||
@Get('/api/user/getOverviewStats')
|
||||
async getOverviewStats(): Promise<{
|
||||
camera: { total: number; online: number; offline: number; alarm: number };
|
||||
alarmEvent: { total: number; pending: number; resolved: number };
|
||||
userTotal: number;
|
||||
alarmRuleTotal: number;
|
||||
onlineRate: number;
|
||||
trend: { date: string; total: number; pending: number }[];
|
||||
cameraStatusDist: { name: string; value: number }[];
|
||||
latestAlarms: {
|
||||
eventID: number;
|
||||
alarmTime: string;
|
||||
cameraName: string;
|
||||
alarmRuleName: string;
|
||||
resolved: boolean;
|
||||
}[];
|
||||
}> {
|
||||
const cameras = await this.cameraService.getList(false, true);
|
||||
let online = 0;
|
||||
let alarm = 0;
|
||||
for (const c of cameras) {
|
||||
if (c.online) online++;
|
||||
if (this.cameraService.getCameraStatus(c) === 'alarm') alarm++;
|
||||
}
|
||||
const offline = cameras.length - online;
|
||||
|
||||
const allEvents = await this.alarmEventService.getList(true, true);
|
||||
const pending = allEvents.list.filter((e) => !e.resolved).length;
|
||||
const resolved = allEvents.total - pending;
|
||||
|
||||
// 近 7 天报警趋势
|
||||
const days: { date: string; key: string }[] = [];
|
||||
const buckets = new Map<string, { total: number; pending: number }>();
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = dayjs().subtract(i, 'day');
|
||||
const key = d.format('MM-DD');
|
||||
days.push({ date: key, key: d.format('YYYY-MM-DD') });
|
||||
buckets.set(d.format('YYYY-MM-DD'), { total: 0, pending: 0 });
|
||||
}
|
||||
for (const e of allEvents.list) {
|
||||
const key = dayjs(e.createdAt).format('YYYY-MM-DD');
|
||||
const b = buckets.get(key);
|
||||
if (b) {
|
||||
b.total++;
|
||||
if (!e.resolved) b.pending++;
|
||||
}
|
||||
}
|
||||
const trend = days.map((d) => ({
|
||||
date: d.date,
|
||||
total: buckets.get(d.key)!.total,
|
||||
pending: buckets.get(d.key)!.pending,
|
||||
}));
|
||||
|
||||
const users = await this.userService.getUserList();
|
||||
const rules = await this.alarmRuleService.getList(false, false);
|
||||
|
||||
const latestAlarms = allEvents.list.slice(0, 8).map((e) => ({
|
||||
eventID: e.id,
|
||||
alarmTime: dayjs(e.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
cameraName: e.sourceCamera?.name ?? '',
|
||||
alarmRuleName: e.alarmRule?.name ?? '',
|
||||
resolved: e.resolved,
|
||||
}));
|
||||
|
||||
return {
|
||||
camera: { total: cameras.length, online, offline, alarm },
|
||||
alarmEvent: { total: allEvents.total, pending, resolved },
|
||||
userTotal: users.length,
|
||||
alarmRuleTotal: rules.length,
|
||||
onlineRate: cameras.length ? Number(((online / cameras.length) * 100).toFixed(1)) : 0,
|
||||
trend,
|
||||
cameraStatusDist: [
|
||||
{ name: '在线', value: online },
|
||||
{ name: '报警', value: alarm },
|
||||
{ name: '离线', value: offline },
|
||||
],
|
||||
latestAlarms,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/api/user/getCampusState')
|
||||
async getCampusState(): Promise<{
|
||||
cameraTotal: number;
|
||||
cameraOnline: number;
|
||||
cameraAlarm: number;
|
||||
alarmEventPending: number;
|
||||
cameraList: {
|
||||
cameraName: string;
|
||||
cameraID: number;
|
||||
cameraStatus: string;
|
||||
latlng: [number, number];
|
||||
}[];
|
||||
}> {
|
||||
const cameraList = await this.cameraService.getList(false, true);
|
||||
let cameraOnline = 0,
|
||||
cameraAlarm = 0;
|
||||
|
||||
const cameraListRes = cameraList.map((camera) => {
|
||||
if (camera.online) cameraOnline++;
|
||||
if (this.cameraService.getCameraStatus(camera) === 'alarm') cameraAlarm++;
|
||||
return {
|
||||
cameraName: camera.name,
|
||||
cameraID: camera.id,
|
||||
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||
latlng: [Number(camera.latitude), Number(camera.longitude)] as [
|
||||
number,
|
||||
number,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
cameraTotal: cameraList.length,
|
||||
cameraOnline,
|
||||
cameraAlarm,
|
||||
alarmEventPending: await this.alarmEventService.getPendingCount(),
|
||||
cameraList: cameraListRes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/api/user/getCameraInfo')
|
||||
async getCameraInfo(
|
||||
@Query('cameraID')
|
||||
cameraID: FetchTypes['GET /api/user/getCameraInfo']['req']['cameraID'],
|
||||
): Promise<FetchTypes['GET /api/user/getCameraInfo']['res']['data']> {
|
||||
const camera = await this.cameraService.getById(cameraID, true, true);
|
||||
if (!camera) throw new NotFoundException('Camera not found');
|
||||
|
||||
return {
|
||||
cameraName: camera.name,
|
||||
cameraID: camera.id,
|
||||
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||
hlsUrl: this.cameraService.getHlsUrl(camera.id),
|
||||
latlng: [Number(camera.latitude), Number(camera.longitude)] as [
|
||||
number,
|
||||
number,
|
||||
],
|
||||
cameraModel: camera.model,
|
||||
alarmRules:
|
||||
camera.alarmRules?.map((rule) => ({
|
||||
alarmRuleID: rule.id,
|
||||
alarmRuleName: rule.name,
|
||||
})) ?? [],
|
||||
alarmEvents:
|
||||
camera.alarmEvents?.map((event) => ({
|
||||
eventID: event.id,
|
||||
alarmTime: dayjs(event.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
alarmRule: {
|
||||
alarmRuleID: event?.alarmRule?.id ?? 0,
|
||||
alarmRuleName: event?.alarmRule?.name ?? '',
|
||||
},
|
||||
resolved: event.resolved,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/api/user/resolveAlarm')
|
||||
async resolveAlarm(
|
||||
@Body('eventID')
|
||||
eventID: FetchTypes['POST /api/user/resolveAlarm']['req']['eventID'],
|
||||
): Promise<FetchTypes['POST /api/user/resolveAlarm']['res']['data']> {
|
||||
await this.alarmEventService.resolve(eventID);
|
||||
return {};
|
||||
}
|
||||
|
||||
@Get('/api/user/getAlarmEvents')
|
||||
async getAlarmEvents(
|
||||
@Query()
|
||||
query: FetchTypes['GET /api/user/getAlarmEvents']['req'],
|
||||
): Promise<FetchTypes['GET /api/user/getAlarmEvents']['res']['data']> {
|
||||
const res = await this.alarmEventService.getList(
|
||||
true,
|
||||
true,
|
||||
query.current,
|
||||
query.pageSize,
|
||||
{
|
||||
resolved:
|
||||
query.resolved === 'true'
|
||||
? true
|
||||
: query.resolved === 'false'
|
||||
? false
|
||||
: undefined,
|
||||
cameraID: query.cameraID,
|
||||
alarmRuleName: query.alarmType,
|
||||
cameraName: query.cameraName,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
list:
|
||||
res.list.map((event) => ({
|
||||
eventID: event.id,
|
||||
alarmTime: dayjs(event.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
alarmRule: {
|
||||
alarmRuleID: event?.alarmRule?.id ?? 0,
|
||||
alarmRuleName: event?.alarmRule?.name ?? '',
|
||||
},
|
||||
resolved: event.resolved,
|
||||
cameraID: event?.sourceCamera?.id ?? 0,
|
||||
cameraName: event?.sourceCamera?.name ?? '',
|
||||
cameraLatlng: [
|
||||
Number(event?.sourceCamera?.latitude),
|
||||
Number(event?.sourceCamera?.longitude),
|
||||
] as [number, number],
|
||||
cameraModel: event?.sourceCamera?.model ?? '',
|
||||
alarmPicUrl: this.utilsService.filePathToURL(event.picFilePath),
|
||||
})) ?? [],
|
||||
total: res.total,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/api/user/getMonitList')
|
||||
async getMonitList(): Promise<
|
||||
FetchTypes['GET /api/user/getMonitList']['res']['data']
|
||||
> {
|
||||
return (await this.cameraService.getList(false, true)).map((camera) => ({
|
||||
cameraID: camera.id,
|
||||
cameraName: camera.name,
|
||||
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||
hlsUrl: this.cameraService.getHlsUrl(camera.id),
|
||||
}));
|
||||
}
|
||||
|
||||
@Get('/api/user/getUserInfo')
|
||||
async getUserInfo(
|
||||
@UserInfo('username') username: string,
|
||||
): Promise<FetchTypes['GET /api/user/getUserInfo']['res']['data']> {
|
||||
const userInfo = await this.userService.getByUsername(username);
|
||||
if (!userInfo) throw new NotFoundException('User not found');
|
||||
return {
|
||||
username: userInfo.username,
|
||||
nickname: userInfo.nickname,
|
||||
email: userInfo.email ?? '',
|
||||
tel: userInfo.tel ?? '',
|
||||
role: userInfo.role,
|
||||
avatarURL: userInfo.avatarFilePath
|
||||
? this.utilsService.filePathToURL(userInfo.avatarFilePath)
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/api/user/updateUserInfo')
|
||||
async updateUserInfo(
|
||||
@Body() body: FetchTypes['POST /api/user/updateUserInfo']['req'],
|
||||
@UserInfo('username') username: string,
|
||||
): Promise<FetchTypes['POST /api/user/updateUserInfo']['res']['data']> {
|
||||
if (username !== body.username || (body as any).password) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
}
|
||||
|
||||
if (body.avatarURL.startsWith('data:image')) {
|
||||
const avatarFilePath = await this.utilsService.writeBase64ImageToFile(
|
||||
body.avatarURL,
|
||||
);
|
||||
await this.userService.updateUser({
|
||||
username: username,
|
||||
nickname: body.nickname,
|
||||
email: body.email,
|
||||
tel: body.tel,
|
||||
avatarFilePath,
|
||||
});
|
||||
} else {
|
||||
await this.userService.updateUser({
|
||||
username: username,
|
||||
nickname: body.nickname,
|
||||
email: body.email,
|
||||
tel: body.tel,
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('/api/user/updatePassword')
|
||||
async updatePassword(
|
||||
@Body() body: FetchTypes['POST /api/user/updatePassword']['req'],
|
||||
@UserInfo('username') username: string,
|
||||
): Promise<FetchTypes['POST /api/user/updatePassword']['res']['data']> {
|
||||
const user = await this.userService.getByUsername(username);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
if (body.oldPassword !== user.password) {
|
||||
throw new PreconditionFailedException('旧密码不正确');
|
||||
}
|
||||
|
||||
await this.userService.updateUser({
|
||||
username: username,
|
||||
password: body.newPassword,
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@Get('/api/user/getMapConfig')
|
||||
async getMapConfig(): Promise<
|
||||
FetchTypes['GET /api/user/getMapConfig']['res']['data']
|
||||
> {
|
||||
const config = await this.mapConfigService.getLatestConfig();
|
||||
if (!config) throw new NotFoundException('Map config not found');
|
||||
|
||||
return {
|
||||
layer:
|
||||
config.layerType === 'imageOverlay'
|
||||
? {
|
||||
type: 'imageOverlay',
|
||||
url: this.utilsService.filePathToURL(config.layerUrlOrPath),
|
||||
bounds: config.imageLayerBounds as [
|
||||
[number, number],
|
||||
[number, number],
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: 'tileLayer',
|
||||
url: config.layerUrlOrPath,
|
||||
},
|
||||
mapOptions: {
|
||||
center: config.mapCenter,
|
||||
zoom: config.mapZoom,
|
||||
minZoom: config.minZoom,
|
||||
maxZoom: config.maxZoom,
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AlarmRule } from './services/alarm-rule/alarm-rule.entity';
|
||||
import { User } from './services/user/user.entity';
|
||||
import { Camera } from './services/camera/camera.entity';
|
||||
import { AlarmEvent } from './services/alarm-event/alarm-event.entity';
|
||||
import { MapConfig } from './services/map-config/map-config.entity';
|
||||
|
||||
const SHARED_SECRET = 'smart-parking-platform';
|
||||
|
||||
const hashPassword = (plain: string): string =>
|
||||
crypto
|
||||
.createHmac('sha256', SHARED_SECRET)
|
||||
.update(plain)
|
||||
.digest('base64');
|
||||
|
||||
interface CameraSeed {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
model: string;
|
||||
rtspUrl: string;
|
||||
ruleNames: string[];
|
||||
}
|
||||
|
||||
// 12 路摄像头:覆盖园区出入口、地下车库、车位区、通道、坡道与屋顶
|
||||
const CAMERA_SEEDS: CameraSeed[] = [
|
||||
{
|
||||
name: '东门入口摄像机',
|
||||
latitude: 36.6625,
|
||||
longitude: 117.0368,
|
||||
model: 'HIKVISION DS-CD2T46',
|
||||
rtspUrl: 'rtsp://demo-camera/east-gate',
|
||||
ruleNames: ['人员异常聚集', '车辆违停检测', '高峰期车流监测'],
|
||||
},
|
||||
{
|
||||
name: '地下车库A区摄像机',
|
||||
latitude: 36.6608,
|
||||
longitude: 117.0342,
|
||||
model: 'DAHUA IPC-HFW5442',
|
||||
rtspUrl: 'rtsp://demo-camera/garage-a',
|
||||
ruleNames: ['车辆违停检测'],
|
||||
},
|
||||
{
|
||||
name: '中央车位区摄像机',
|
||||
latitude: 36.6614,
|
||||
longitude: 117.0351,
|
||||
model: 'HIKVISION DS-2CD2386',
|
||||
rtspUrl: 'rtsp://demo-camera/center-lot',
|
||||
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||
},
|
||||
{
|
||||
name: '西门出口摄像机',
|
||||
latitude: 36.6619,
|
||||
longitude: 117.0329,
|
||||
model: 'UNV IPC2124SR3',
|
||||
rtspUrl: 'rtsp://demo-camera/west-exit',
|
||||
ruleNames: ['人员异常聚集', '高峰期车流监测'],
|
||||
},
|
||||
{
|
||||
name: '非机动车通道摄像机',
|
||||
latitude: 36.6631,
|
||||
longitude: 117.0357,
|
||||
model: 'DAHUA IPC-HDW3541',
|
||||
rtspUrl: 'rtsp://demo-camera/non-motor-lane',
|
||||
ruleNames: ['人员异常聚集'],
|
||||
},
|
||||
{
|
||||
name: '南门访客入口摄像机',
|
||||
latitude: 36.6602,
|
||||
longitude: 117.0361,
|
||||
model: 'HIKVISION DS-2CD2T86',
|
||||
rtspUrl: 'rtsp://demo-camera/south-gate',
|
||||
ruleNames: ['人员异常聚集', '车辆违停检测'],
|
||||
},
|
||||
{
|
||||
name: '地下车库B区摄像机',
|
||||
latitude: 36.6605,
|
||||
longitude: 117.0335,
|
||||
model: 'DAHUA IPC-HFW3441',
|
||||
rtspUrl: 'rtsp://demo-camera/garage-b',
|
||||
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||
},
|
||||
{
|
||||
name: '车库电梯厅摄像机',
|
||||
latitude: 36.661,
|
||||
longitude: 117.033,
|
||||
model: 'HIKVISION DS-2CD2346',
|
||||
rtspUrl: 'rtsp://demo-camera/garage-lobby',
|
||||
ruleNames: ['人员异常聚集'],
|
||||
},
|
||||
{
|
||||
name: '北侧员工通道摄像机',
|
||||
latitude: 36.6636,
|
||||
longitude: 117.0346,
|
||||
model: 'UNV IPC3228SR3',
|
||||
rtspUrl: 'rtsp://demo-camera/north-staff',
|
||||
ruleNames: ['人员异常聚集'],
|
||||
},
|
||||
{
|
||||
name: '东侧坡道入口摄像机',
|
||||
latitude: 36.662,
|
||||
longitude: 117.0375,
|
||||
model: 'HIKVISION DS-2CD3T46',
|
||||
rtspUrl: 'rtsp://demo-camera/east-ramp',
|
||||
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||
},
|
||||
{
|
||||
name: '中央收费岗亭摄像机',
|
||||
latitude: 36.6616,
|
||||
longitude: 117.0348,
|
||||
model: 'DAHUA IPC-HFW5442',
|
||||
rtspUrl: 'rtsp://demo-camera/toll-booth',
|
||||
ruleNames: ['人员异常聚集', '车辆违停检测'],
|
||||
},
|
||||
{
|
||||
name: '屋顶停车场摄像机',
|
||||
latitude: 36.6628,
|
||||
longitude: 117.034,
|
||||
model: 'HIKVISION DS-2CD2T87',
|
||||
rtspUrl: 'rtsp://demo-camera/roof-lot',
|
||||
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||
},
|
||||
];
|
||||
|
||||
// 报警抓拍种子图(源文件 → public 下固定名)
|
||||
const ALARM_PIC_SEEDS: Record<string, string> = {
|
||||
'alarm-crowd.jpg': 'seed-alarm-crowd.jpg',
|
||||
'alarm-parking.jpg': 'seed-alarm-parking.jpg',
|
||||
'alarm-peak.jpg': 'seed-alarm-peak.jpg',
|
||||
'alarm-bicycle.jpg': 'seed-alarm-bicycle.jpg',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseSeeder implements OnModuleInit {
|
||||
private readonly logger = new Logger(DatabaseSeeder.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const publicDir = this.configService.getOrThrow<string>(
|
||||
'PUBLIC_DIR_ABSOLUTE_PATH',
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const userRepo = manager.getRepository(User);
|
||||
const ruleRepo = manager.getRepository(AlarmRule);
|
||||
const cameraRepo = manager.getRepository(Camera);
|
||||
const eventRepo = manager.getRepository(AlarmEvent);
|
||||
const mapRepo = manager.getRepository(MapConfig);
|
||||
|
||||
if ((await userRepo.count()) === 0) {
|
||||
await userRepo.save([
|
||||
userRepo.create({
|
||||
username: 'admin',
|
||||
nickname: '系统管理员',
|
||||
role: 'admin',
|
||||
password: hashPassword('admin123'),
|
||||
email: 'admin@parking.local',
|
||||
tel: '13800000001',
|
||||
}),
|
||||
userRepo.create({
|
||||
username: 'operator',
|
||||
nickname: '停车运营员',
|
||||
role: 'user',
|
||||
password: hashPassword('123456'),
|
||||
email: 'operator@parking.local',
|
||||
tel: '13800000002',
|
||||
}),
|
||||
]);
|
||||
this.logger.log('已创建默认用户');
|
||||
}
|
||||
|
||||
// 规则幂等补齐(按名称)
|
||||
const allDays = [1, 2, 3, 4, 5, 6, 7];
|
||||
const ruleDefs = [
|
||||
{
|
||||
name: '人员异常聚集',
|
||||
enabled: true,
|
||||
algorithmType: 'body' as const,
|
||||
triggerDayOfWeek: allDays,
|
||||
triggerTimeStart: '00:00:00',
|
||||
triggerTimeEnd: '23:59:59',
|
||||
triggerCountMin: 3,
|
||||
triggerCountMax: -1,
|
||||
},
|
||||
{
|
||||
name: '车辆违停检测',
|
||||
enabled: true,
|
||||
algorithmType: 'vehicle' as const,
|
||||
triggerDayOfWeek: allDays,
|
||||
triggerTimeStart: '00:00:00',
|
||||
triggerTimeEnd: '23:59:59',
|
||||
triggerCountMin: 1,
|
||||
triggerCountMax: -1,
|
||||
},
|
||||
{
|
||||
name: '高峰期车流监测',
|
||||
enabled: true,
|
||||
algorithmType: 'vehicle' as const,
|
||||
triggerDayOfWeek: [1, 2, 3, 4, 5],
|
||||
triggerTimeStart: '07:30:00',
|
||||
triggerTimeEnd: '09:30:00',
|
||||
triggerCountMin: 5,
|
||||
triggerCountMax: -1,
|
||||
},
|
||||
];
|
||||
|
||||
const ruleByName = new Map<string, AlarmRule>();
|
||||
for (const def of ruleDefs) {
|
||||
let rule = await ruleRepo.findOne({ where: { name: def.name } });
|
||||
if (!rule) {
|
||||
rule = await ruleRepo.save(ruleRepo.create(def));
|
||||
this.logger.log(`已创建报警规则:${def.name}`);
|
||||
}
|
||||
ruleByName.set(def.name, rule);
|
||||
}
|
||||
|
||||
// 摄像头幂等补齐到 12 路
|
||||
for (const seed of CAMERA_SEEDS) {
|
||||
let camera = await cameraRepo.findOne({
|
||||
where: { name: seed.name },
|
||||
relations: { alarmRules: true },
|
||||
});
|
||||
const alarmRules = seed.ruleNames
|
||||
.map((n) => ruleByName.get(n))
|
||||
.filter((r): r is AlarmRule => Boolean(r));
|
||||
|
||||
if (!camera) {
|
||||
await cameraRepo.save(
|
||||
cameraRepo.create({
|
||||
name: seed.name,
|
||||
latitude: seed.latitude,
|
||||
longitude: seed.longitude,
|
||||
model: seed.model,
|
||||
rtspUrl: seed.rtspUrl,
|
||||
online: false,
|
||||
alarmRules,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// 已存在则补齐规则关联(不覆盖在线状态)
|
||||
camera.alarmRules = alarmRules;
|
||||
await cameraRepo.save(camera);
|
||||
}
|
||||
}
|
||||
this.logger.log('摄像头清单已同步(12 路)');
|
||||
|
||||
// 复制报警抓拍图到 public 目录
|
||||
const seedAssetDir = path.join(__dirname, '..', 'seed-assets');
|
||||
await fs.ensureDir(publicDir);
|
||||
const publicPicPaths: string[] = [];
|
||||
for (const [srcName, destName] of Object.entries(ALARM_PIC_SEEDS)) {
|
||||
const dest = path.join(publicDir, destName);
|
||||
try {
|
||||
await fs.copy(path.join(seedAssetDir, srcName), dest, {
|
||||
overwrite: true,
|
||||
});
|
||||
} catch {
|
||||
this.logger.warn(`种子抓拍图缺失:${srcName}`);
|
||||
}
|
||||
if (await fs.pathExists(dest)) publicPicPaths.push(dest);
|
||||
}
|
||||
|
||||
// 历史报警事件:仅在无任何事件时播种,避免重复
|
||||
if ((await eventRepo.count()) === 0 && publicPicPaths.length > 0) {
|
||||
const cameras = await cameraRepo.find();
|
||||
const rules = [...ruleByName.values()];
|
||||
const events: AlarmEvent[] = [];
|
||||
const now = Date.now();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// 过去 7 天约 42 条事件,时间升序
|
||||
for (let i = 0; i < 42; i += 1) {
|
||||
const offsetMin = Math.floor((42 - i) * (7 * 24 * 60) / 42);
|
||||
const createdAt = new Date(now - offsetMin * 60 * 1000);
|
||||
const camera = cameras[i % cameras.length];
|
||||
const rule = rules[i % rules.length];
|
||||
const picFilePath =
|
||||
publicPicPaths[
|
||||
rule.algorithmType === 'body'
|
||||
? i % 2 === 0
|
||||
? 0
|
||||
: 3
|
||||
: i % 2 === 0
|
||||
? 1
|
||||
: 2
|
||||
] ?? publicPicPaths[i % publicPicPaths.length];
|
||||
|
||||
events.push(
|
||||
eventRepo.create({
|
||||
resolved: i % 10 < 7, // 约 70% 已处理,保留部分未处理
|
||||
picFilePath,
|
||||
createdAt,
|
||||
sourceCamera: camera,
|
||||
alarmRule: rule,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await eventRepo.save(events);
|
||||
this.logger.log('已播种过去 7 天历史报警事件 42 条');
|
||||
}
|
||||
|
||||
if ((await mapRepo.count()) === 0) {
|
||||
await mapRepo.save(
|
||||
mapRepo.create({
|
||||
layerType: 'tileLayer',
|
||||
layerUrlOrPath: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
mapCenter: [36.6615, 117.035],
|
||||
mapZoom: 17,
|
||||
minZoom: 3,
|
||||
maxZoom: 19,
|
||||
}),
|
||||
);
|
||||
this.logger.log('已创建默认地图配置');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
|
||||
export const UserInfo = createParamDecorator(
|
||||
(data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import assert from 'assert';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { Request } from 'express';
|
||||
import { User } from 'src/services/user/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) private cache: Cache,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<User>(token);
|
||||
assert(token === (await this.cache.get(payload.username)));
|
||||
|
||||
request['user'] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class RoleGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
const requiredRole = this.reflector.get('role', context.getClass());
|
||||
const userRole = context.switchToHttp().getRequest()?.user?.role;
|
||||
return userRole === 'admin' || userRole === requiredRole;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
|
||||
@Catch()
|
||||
export class ExceptionTransformerFilter implements ExceptionFilter {
|
||||
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const { httpAdapter } = this.httpAdapterHost;
|
||||
|
||||
const ctx = host.switchToHttp();
|
||||
|
||||
const httpStatus =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
Logger.error(exception);
|
||||
console.error(exception);
|
||||
const exceptionMessage =
|
||||
exception instanceof HttpException
|
||||
? exception.message
|
||||
: String(exception);
|
||||
|
||||
const responseBody = {
|
||||
success: false,
|
||||
message: exceptionMessage,
|
||||
data: {},
|
||||
};
|
||||
|
||||
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ResponseTransformerInterceptor } from './response-transformer.interceptor';
|
||||
|
||||
describe('ResponseTransformerInterceptor', () => {
|
||||
it('should be defined', () => {
|
||||
expect(new ResponseTransformerInterceptor()).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseTransformerInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next
|
||||
.handle()
|
||||
.pipe(map((data) => ({ success: true, message: '', data })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
cors: true,
|
||||
});
|
||||
app.useBodyParser('json', { limit: '50mb' });
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Camera } from '../camera/camera.entity';
|
||||
import { AlarmRule } from '../alarm-rule/alarm-rule.entity';
|
||||
|
||||
@Entity()
|
||||
export class AlarmEvent {
|
||||
@PrimaryGeneratedColumn({ type: 'int' })
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'bool', default: false })
|
||||
resolved: boolean;
|
||||
|
||||
@Column()
|
||||
picFilePath: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
|
||||
@ManyToOne(() => Camera, (camera) => camera.alarmEvents)
|
||||
sourceCamera?: Camera;
|
||||
|
||||
@ManyToOne(() => AlarmRule, (alarmRule) => alarmRule.alarmEvents)
|
||||
@JoinColumn()
|
||||
alarmRule?: AlarmRule;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AlarmEventService } from './alarm-event.service';
|
||||
|
||||
describe('AlarmEventService', () => {
|
||||
let service: AlarmEventService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AlarmEventService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AlarmEventService>(AlarmEventService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AlarmEvent } from './alarm-event.entity';
|
||||
import { FindManyOptions, Like, Repository } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class AlarmEventService {
|
||||
constructor(
|
||||
@InjectRepository(AlarmEvent)
|
||||
private alarmEventRepo: Repository<AlarmEvent>,
|
||||
) {}
|
||||
|
||||
async getList(
|
||||
withSourceCamera = false,
|
||||
withAlarmRule = false,
|
||||
current?: number,
|
||||
pageSize?: number,
|
||||
search?: {
|
||||
resolved?: boolean;
|
||||
cameraName?: string;
|
||||
alarmRuleName?: string;
|
||||
cameraID?: number;
|
||||
},
|
||||
): Promise<{ total: number; list: AlarmEvent[] }> {
|
||||
const findOptions: FindManyOptions<AlarmEvent> = {
|
||||
relations: {
|
||||
sourceCamera: withSourceCamera,
|
||||
alarmRule: withAlarmRule,
|
||||
},
|
||||
skip: current && pageSize ? (current - 1) * pageSize : undefined,
|
||||
take: current && pageSize ? pageSize : undefined,
|
||||
where: {
|
||||
resolved: search?.resolved,
|
||||
sourceCamera: {
|
||||
name: search?.cameraName ? Like(`%${search.cameraName}%`) : undefined,
|
||||
id: search?.cameraID,
|
||||
},
|
||||
alarmRule: {
|
||||
name: search?.alarmRuleName
|
||||
? Like(`%${search.alarmRuleName}%`)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const total = await this.alarmEventRepo.count(findOptions);
|
||||
const list = await this.alarmEventRepo.find({
|
||||
...findOptions,
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
return { total, list };
|
||||
}
|
||||
|
||||
async getResolvedList() {
|
||||
return await this.alarmEventRepo.find({ where: { resolved: true } });
|
||||
}
|
||||
|
||||
async getPenddingList() {
|
||||
return await this.alarmEventRepo.find({ where: { resolved: false } });
|
||||
}
|
||||
|
||||
async getPendingCount() {
|
||||
return await this.alarmEventRepo.count({ where: { resolved: false } });
|
||||
}
|
||||
|
||||
async resolve(id: number) {
|
||||
await this.alarmEventRepo.update({ id }, { resolved: true });
|
||||
}
|
||||
|
||||
async addEvent(event: Partial<AlarmEvent>) {
|
||||
return await this.alarmEventRepo.save(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Camera } from '../camera/camera.entity';
|
||||
import { AlarmEvent } from '../alarm-event/alarm-event.entity';
|
||||
|
||||
@Entity()
|
||||
export class AlarmRule {
|
||||
@PrimaryGeneratedColumn({ type: 'int' })
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
enabled: boolean;
|
||||
|
||||
@Column()
|
||||
algorithmType: 'body' | 'vehicle';
|
||||
|
||||
@Column({ type: 'json' })
|
||||
triggerDayOfWeek: number[];
|
||||
|
||||
@Column()
|
||||
triggerTimeStart: string;
|
||||
|
||||
@Column()
|
||||
triggerTimeEnd: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
triggerCountMin: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
triggerCountMax: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
|
||||
@ManyToMany(() => Camera, (camera) => camera.alarmRules)
|
||||
@JoinTable()
|
||||
relatedCameras?: Camera[];
|
||||
|
||||
@OneToMany(() => AlarmEvent, (alarmEvent) => alarmEvent.alarmRule)
|
||||
alarmEvents?: AlarmEvent[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AlarmRuleService } from './alarm-rule.service';
|
||||
|
||||
describe('AlarmRuleService', () => {
|
||||
let service: AlarmRuleService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AlarmRuleService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AlarmRuleService>(AlarmRuleService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Inject, Injectable, forwardRef } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AlarmRule } from './alarm-rule.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AiEndGateway } from 'src/ws-gateways/ai-end/ai-end.gateway';
|
||||
|
||||
@Injectable()
|
||||
export class AlarmRuleService {
|
||||
constructor(
|
||||
@InjectRepository(AlarmRule) private alarmRuleRepo: Repository<AlarmRule>,
|
||||
@Inject(forwardRef(() => AiEndGateway))
|
||||
private aiEndGateway: AiEndGateway,
|
||||
) {}
|
||||
|
||||
async getList(
|
||||
withRelatedCameras = false,
|
||||
withAlarmEvents = false,
|
||||
): Promise<AlarmRule[]> {
|
||||
return await this.alarmRuleRepo.find({
|
||||
relations: {
|
||||
relatedCameras: withRelatedCameras,
|
||||
alarmEvents: withAlarmEvents,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getById(
|
||||
ruleID: number,
|
||||
withRelatedCameras = false,
|
||||
withAlarmEvents = false,
|
||||
): Promise<AlarmRule | null> {
|
||||
return await this.alarmRuleRepo.findOne({
|
||||
where: { id: ruleID },
|
||||
relations: {
|
||||
relatedCameras: withRelatedCameras,
|
||||
alarmEvents: withAlarmEvents,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addRule(rule: Partial<AlarmRule>) {
|
||||
const savedRule = await this.alarmRuleRepo.save(rule);
|
||||
|
||||
await Promise.all(
|
||||
savedRule.relatedCameras?.map((camera) =>
|
||||
this.aiEndGateway.notifyCameraConfigChange(camera.id),
|
||||
) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
async updateRule(rule: Partial<AlarmRule>) {
|
||||
if (!rule.id) return;
|
||||
const originalRule = await this.getById(rule.id, true);
|
||||
const savedRule = await this.alarmRuleRepo.save(rule);
|
||||
|
||||
const relatedCameraIds = [
|
||||
...new Set(
|
||||
[
|
||||
...(originalRule?.relatedCameras ?? []),
|
||||
...(savedRule?.relatedCameras ?? []),
|
||||
].map((camera) => camera.id),
|
||||
),
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
relatedCameraIds.map((cameraId) =>
|
||||
this.aiEndGateway.notifyCameraConfigChange(cameraId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRule(ruleID: number) {
|
||||
const rule = await this.getById(ruleID, true);
|
||||
if (!rule) return;
|
||||
await this.alarmRuleRepo.softDelete({ id: ruleID });
|
||||
|
||||
await Promise.all(
|
||||
rule.relatedCameras?.map((camera) =>
|
||||
this.aiEndGateway.notifyCameraConfigChange(camera.id),
|
||||
) ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToMany,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AlarmEvent } from '../alarm-event/alarm-event.entity';
|
||||
import { AlarmRule } from '../alarm-rule/alarm-rule.entity';
|
||||
|
||||
@Entity()
|
||||
export class Camera {
|
||||
@PrimaryGeneratedColumn({ type: 'int' })
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'bool', default: false })
|
||||
online: boolean;
|
||||
|
||||
@Column({ default: '' })
|
||||
rtspUrl: string;
|
||||
|
||||
@Column({ type: 'double' })
|
||||
latitude: number;
|
||||
|
||||
@Column({ type: 'double' })
|
||||
longitude: number;
|
||||
|
||||
@Column()
|
||||
model: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
|
||||
@OneToMany(() => AlarmEvent, (alarmEvent) => alarmEvent.sourceCamera)
|
||||
@JoinColumn()
|
||||
alarmEvents?: AlarmEvent[];
|
||||
|
||||
@ManyToMany(() => AlarmRule, (alarmRule) => alarmRule.relatedCameras)
|
||||
alarmRules?: AlarmRule[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CameraService } from './camera.service';
|
||||
|
||||
describe('CameraService', () => {
|
||||
let service: CameraService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [CameraService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CameraService>(CameraService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Inject, Injectable, forwardRef } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Camera } from './camera.entity';
|
||||
import { AiEndGateway } from 'src/ws-gateways/ai-end/ai-end.gateway';
|
||||
|
||||
@Injectable()
|
||||
export class CameraService {
|
||||
constructor(
|
||||
@InjectRepository(Camera) private cameraRepo: Repository<Camera>,
|
||||
@Inject(forwardRef(() => AiEndGateway))
|
||||
private aiEndGateway: AiEndGateway,
|
||||
) {}
|
||||
|
||||
async getList(
|
||||
withAlarmRules = false,
|
||||
withAlarmEvents = false,
|
||||
): Promise<Camera[]> {
|
||||
return await this.cameraRepo.find({
|
||||
relations: { alarmRules: withAlarmRules, alarmEvents: withAlarmEvents },
|
||||
});
|
||||
}
|
||||
|
||||
async getOfflineList(): Promise<Camera[]> {
|
||||
return await this.cameraRepo.find({
|
||||
where: { online: false },
|
||||
});
|
||||
}
|
||||
|
||||
async getById(
|
||||
cameraID: number,
|
||||
withAlarmRules = false,
|
||||
withAlarmEvents = false,
|
||||
): Promise<Camera | null> {
|
||||
return await this.cameraRepo.findOne({
|
||||
where: { id: cameraID },
|
||||
relations: {
|
||||
alarmRules: withAlarmRules,
|
||||
alarmEvents: withAlarmEvents ? { alarmRule: true } : false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getCameraStatus(camera: Camera): 'normal' | 'offline' | 'alarm' {
|
||||
if (camera.online === false) return 'offline';
|
||||
if (
|
||||
camera.alarmEvents?.filter((event) => event.resolved === false)?.length ??
|
||||
0 > 0
|
||||
)
|
||||
return 'alarm';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
async addCamera(camera: Partial<Camera>) {
|
||||
const savedCamera = await this.cameraRepo.save(camera);
|
||||
|
||||
await this.aiEndGateway.notifyCameraConfigChange(savedCamera.id);
|
||||
}
|
||||
|
||||
async updateCamera(camera: Partial<Camera>) {
|
||||
if (!camera.id) return;
|
||||
const savedCamera = await this.cameraRepo.save(camera);
|
||||
|
||||
await this.aiEndGateway.notifyCameraConfigChange(savedCamera.id);
|
||||
}
|
||||
|
||||
async deleteCamera(cameraID: number) {
|
||||
await this.cameraRepo.softDelete({ id: cameraID });
|
||||
|
||||
await this.aiEndGateway.notifyCameraConfigChange(cameraID);
|
||||
}
|
||||
|
||||
getHlsUrl(cameraID: number): string {
|
||||
return '/hls/' + cameraID + '.m3u8';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class MapConfig {
|
||||
@PrimaryGeneratedColumn({ type: 'int' })
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
layerType: 'imageOverlay' | 'tileLayer';
|
||||
|
||||
@Column()
|
||||
layerUrlOrPath: string;
|
||||
|
||||
@Column({
|
||||
type: 'json',
|
||||
nullable: true,
|
||||
})
|
||||
imageLayerBounds?: [[number, number], [number, number]];
|
||||
|
||||
@Column({ type: 'json' })
|
||||
mapCenter: [number, number];
|
||||
|
||||
@Column({ type: 'int' })
|
||||
mapZoom: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
minZoom: number;
|
||||
|
||||
@Column({ type: 'int', default: 18 })
|
||||
maxZoom: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { MapConfigService } from './map-config.service';
|
||||
|
||||
describe('MapConfigService', () => {
|
||||
let service: MapConfigService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [MapConfigService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MapConfigService>(MapConfigService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { MapConfig } from './map-config.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class MapConfigService {
|
||||
constructor(
|
||||
@InjectRepository(MapConfig) private mapConfigRepo: Repository<MapConfig>,
|
||||
) {}
|
||||
|
||||
async getLatestConfig(): Promise<MapConfig | null> {
|
||||
return (
|
||||
await this.mapConfigRepo.find({
|
||||
order: { id: 'DESC' },
|
||||
take: 1,
|
||||
})
|
||||
)?.[0];
|
||||
}
|
||||
|
||||
async updateConfig(config: Partial<MapConfig>) {
|
||||
await this.mapConfigRepo.save(config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Module, Provider } from '@nestjs/common';
|
||||
import { UserService } from './user/user.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './user/user.entity';
|
||||
import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
|
||||
import { Camera } from './camera/camera.entity';
|
||||
import { AlarmEvent } from './alarm-event/alarm-event.entity';
|
||||
import { AlarmRule } from './alarm-rule/alarm-rule.entity';
|
||||
import { CameraService } from './camera/camera.service';
|
||||
import { AlarmEventService } from './alarm-event/alarm-event.service';
|
||||
import { AlarmRuleService } from './alarm-rule/alarm-rule.service';
|
||||
import { UtilsService } from './utils/utils.service';
|
||||
import { MapConfigService } from './map-config/map-config.service';
|
||||
import { MapConfig } from './map-config/map-config.entity';
|
||||
|
||||
const entities: EntityClassOrSchema[] = [
|
||||
User,
|
||||
Camera,
|
||||
AlarmEvent,
|
||||
AlarmRule,
|
||||
MapConfig,
|
||||
];
|
||||
|
||||
const services: Provider[] = [
|
||||
UserService,
|
||||
CameraService,
|
||||
AlarmEventService,
|
||||
AlarmRuleService,
|
||||
UtilsService,
|
||||
MapConfigService,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature(entities)],
|
||||
providers: services,
|
||||
exports: services,
|
||||
})
|
||||
export class ServicesModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn({ type: 'int' })
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
nickname: string;
|
||||
|
||||
@Column()
|
||||
role: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
avatarFilePath?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
tel?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { User } from './user.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User) private userRepo: Repository<User>,
|
||||
private jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) private cache: Cache,
|
||||
) {}
|
||||
|
||||
async getUserList(): Promise<User[]> {
|
||||
return await this.userRepo.find({
|
||||
select: { password: false },
|
||||
});
|
||||
}
|
||||
|
||||
async authLogin(username: string, password: string): Promise<User | null> {
|
||||
return await this.userRepo.findOne({ where: { username, password } });
|
||||
}
|
||||
|
||||
async genToken(user: User): Promise<string> {
|
||||
const token = await this.jwtService.signAsync({ ...user });
|
||||
await this.cache.set(user.username, token);
|
||||
return 'Bearer ' + token;
|
||||
}
|
||||
|
||||
async getByUsername(username: string): Promise<User | null> {
|
||||
return await this.userRepo.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
async updateUser(user: Partial<User>) {
|
||||
if (!user.username) return;
|
||||
await this.userRepo.update({ username: user.username }, user);
|
||||
}
|
||||
|
||||
async addUser(user: Partial<User>) {
|
||||
return await this.userRepo.save(user);
|
||||
}
|
||||
|
||||
async deleteUser(username: string) {
|
||||
await this.userRepo.softDelete({ username });
|
||||
await this.cache.del(username);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UtilsService } from './utils.service';
|
||||
|
||||
describe('UtilsService', () => {
|
||||
let service: UtilsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UtilsService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UtilsService>(UtilsService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class UtilsService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private publicDirPath = this.configService.getOrThrow<string>(
|
||||
'PUBLIC_DIR_ABSOLUTE_PATH',
|
||||
);
|
||||
|
||||
private URLToPublicDir = '/public';
|
||||
|
||||
/**
|
||||
* Write image file to the public path from base64
|
||||
* @param base64Image base64 file data
|
||||
* @returns the written file path with the randomly generated file name
|
||||
*/
|
||||
async writeBase64ImageToFile(base64Image: string): Promise<string> {
|
||||
const fileExt = /data:image\/(.+?);base64,/.exec(base64Image)?.[1];
|
||||
if (!fileExt) throw new Error('Invalid file type');
|
||||
const base64Data = base64Image.replace(/data:image\/(.+?);base64,/, '');
|
||||
const dataBuffer = Buffer.from(base64Data, 'base64');
|
||||
const filePath = path.join(
|
||||
this.publicDirPath,
|
||||
`${randomBytes(10).toString('hex')}.${fileExt}`,
|
||||
);
|
||||
await fs.ensureDir(this.publicDirPath);
|
||||
await fs.writeFile(filePath, dataBuffer);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
filePathToURL(filePath: string): string {
|
||||
return filePath.replace(this.publicDirPath, this.URLToPublicDir);
|
||||
}
|
||||
}
|
||||
Vendored
+456
@@ -0,0 +1,456 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
|
||||
// Stay consistent with the frontend/src/services/serviceTypes.ts
|
||||
|
||||
export interface FetchTypes {
|
||||
'POST /api/user/login': {
|
||||
req: { username: string; password: string };
|
||||
res: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
token: string;
|
||||
userInfo: {
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
avatarURL: string;
|
||||
tel: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
'GET /api/user/getOverviewStats': {
|
||||
req: void;
|
||||
res: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
camera: { total: number; online: number; offline: number; alarm: number };
|
||||
alarmEvent: { total: number; pending: number; resolved: number };
|
||||
userTotal: number;
|
||||
alarmRuleTotal: number;
|
||||
onlineRate: number;
|
||||
trend: { date: string; total: number; pending: number }[];
|
||||
cameraStatusDist: { name: string; value: number }[];
|
||||
latestAlarms: {
|
||||
eventID: number;
|
||||
alarmTime: string;
|
||||
cameraName: string;
|
||||
alarmRuleName: string;
|
||||
resolved: boolean;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
'GET /api/user/getCampusState': {
|
||||
req: void;
|
||||
res: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
cameraTotal: number;
|
||||
cameraOnline: number;
|
||||
cameraAlarm: number;
|
||||
alarmEventPending: number;
|
||||
cameraList: {
|
||||
cameraName: string;
|
||||
cameraID: number;
|
||||
cameraStatus: string;
|
||||
latlng: [number, number];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
'GET /api/user/getCameraInfo': {
|
||||
req: { cameraID: number };
|
||||
res: {
|
||||
data: {
|
||||
cameraName: string;
|
||||
cameraID: number;
|
||||
cameraStatus: string;
|
||||
hlsUrl: string;
|
||||
latlng: [number, number];
|
||||
cameraModel: string;
|
||||
alarmRules: {
|
||||
alarmRuleID: number;
|
||||
alarmRuleName: string;
|
||||
}[];
|
||||
alarmEvents: {
|
||||
eventID: number;
|
||||
alarmTime: string;
|
||||
alarmRule: {
|
||||
alarmRuleID: number;
|
||||
alarmRuleName: string;
|
||||
};
|
||||
resolved: boolean;
|
||||
}[];
|
||||
};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/user/resolveAlarm': {
|
||||
req: { eventID: number };
|
||||
res: { success: boolean; message: string; data: {} };
|
||||
};
|
||||
'GET /api/user/getAlarmEvents': {
|
||||
req: {
|
||||
cameraID?: number;
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
alarmType?: string;
|
||||
cameraName?: string;
|
||||
resolved?: string;
|
||||
};
|
||||
res: {
|
||||
data: {
|
||||
total: number;
|
||||
list: {
|
||||
eventID: number;
|
||||
alarmTime: string;
|
||||
alarmRule: {
|
||||
alarmRuleID: number;
|
||||
alarmRuleName: string;
|
||||
};
|
||||
resolved: boolean;
|
||||
cameraID: number;
|
||||
cameraName: string;
|
||||
cameraLatlng: [number, number];
|
||||
cameraModel: string;
|
||||
alarmPicUrl: string;
|
||||
}[];
|
||||
};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'GET /api/user/getMonitList': {
|
||||
req: void;
|
||||
res: {
|
||||
data: {
|
||||
cameraName: string;
|
||||
cameraID: number;
|
||||
cameraStatus: string;
|
||||
hlsUrl: string;
|
||||
}[];
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'GET /api/user/getUserInfo': {
|
||||
req: void;
|
||||
res: {
|
||||
data: {
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
avatarURL: string;
|
||||
tel: string;
|
||||
email: string;
|
||||
};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/user/updateUserInfo': {
|
||||
req: {
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatarURL: string;
|
||||
tel: string;
|
||||
email: string;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/user/updatePassword': {
|
||||
req: {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'GET /api/user/getMapConfig': {
|
||||
req: void;
|
||||
res: {
|
||||
data: {
|
||||
mapOptions: {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
attributionControl: boolean;
|
||||
zoomControl: boolean;
|
||||
};
|
||||
layer:
|
||||
| {
|
||||
type: 'imageOverlay';
|
||||
url: string;
|
||||
bounds: [[number, number], [number, number]];
|
||||
}
|
||||
| {
|
||||
type: 'tileLayer';
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/updateMapConfig': {
|
||||
req: {
|
||||
mapOptions: {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
attributionControl: boolean;
|
||||
zoomControl: boolean;
|
||||
};
|
||||
layer:
|
||||
| {
|
||||
type: 'imageOverlay';
|
||||
url: string;
|
||||
bounds: [[number, number], [number, number]];
|
||||
}
|
||||
| {
|
||||
type: 'tileLayer';
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'GET /api/admin/getCameraList': {
|
||||
req: void;
|
||||
res: {
|
||||
data: {
|
||||
cameraName: string;
|
||||
cameraID: number;
|
||||
cameraStatus: string;
|
||||
hlsUrl: string;
|
||||
rtspUrl: string;
|
||||
latlng: [number, number];
|
||||
cameraModel: string;
|
||||
alarmRules: {
|
||||
alarmRuleID: number;
|
||||
alarmRuleName: string;
|
||||
}[];
|
||||
}[];
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/addCamera': {
|
||||
req: {
|
||||
cameraName: string;
|
||||
latlng: [number, number];
|
||||
cameraModel: string;
|
||||
alarmRuleIDs: number[];
|
||||
rtspUrl: string;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/updateCamera': {
|
||||
req: {
|
||||
cameraID: number;
|
||||
cameraName: string;
|
||||
latlng: [number, number];
|
||||
cameraModel: string;
|
||||
alarmRuleIDs: number[];
|
||||
rtspUrl: string;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/deleteCamera': {
|
||||
req: {
|
||||
cameraID: number;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'GET /api/admin/getAlarmRuleList': {
|
||||
req: void;
|
||||
res: {
|
||||
data: {
|
||||
alarmRuleID: number;
|
||||
alarmRuleName: string;
|
||||
relatedCameras: {
|
||||
cameraName: string;
|
||||
cameraID: number;
|
||||
}[];
|
||||
enabled: boolean;
|
||||
algorithmType: 'body' | 'vehicle';
|
||||
triggerCondition: {
|
||||
time: {
|
||||
dayOfWeek: number[];
|
||||
timeRange: [string, string];
|
||||
};
|
||||
count: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/addAlarmRule': {
|
||||
req: {
|
||||
relatedCameraIds: number[];
|
||||
alarmRuleName: string;
|
||||
enabled: boolean;
|
||||
algorithmType: 'body' | 'vehicle';
|
||||
triggerCondition: {
|
||||
time: {
|
||||
dayOfWeek: number[];
|
||||
timeRange: [string, string];
|
||||
};
|
||||
count: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/updateAlarmRule': {
|
||||
req: {
|
||||
alarmRuleID: number;
|
||||
alarmRuleName: string;
|
||||
relatedCameraIds: number[];
|
||||
enabled: boolean;
|
||||
algorithmType: 'body' | 'vehicle';
|
||||
triggerCondition: {
|
||||
time: {
|
||||
dayOfWeek: number[];
|
||||
timeRange: [string, string];
|
||||
};
|
||||
count: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/deleteAlarmRule': {
|
||||
req: {
|
||||
alarmRuleID: number;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'GET /api/admin/getUserList': {
|
||||
req: void;
|
||||
res: {
|
||||
data: {
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
avatarURL: string;
|
||||
tel: string;
|
||||
email: string;
|
||||
}[];
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/addUser': {
|
||||
req: {
|
||||
username: string;
|
||||
role: string;
|
||||
nickname: string;
|
||||
tel?: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/updateUser': {
|
||||
req: {
|
||||
username: string;
|
||||
role: string;
|
||||
nickname: string;
|
||||
tel?: string;
|
||||
email?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'POST /api/admin/deleteUser': {
|
||||
req: {
|
||||
username: string;
|
||||
};
|
||||
res: {
|
||||
data: {};
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
'GET /api/ai/getOfflineCameraList': {
|
||||
req: {
|
||||
adminUsername: string;
|
||||
password: string;
|
||||
};
|
||||
res: {
|
||||
data: {
|
||||
cameraID: number;
|
||||
}[];
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type fetchUrls = keyof FetchTypes extends `${string} ${infer Url}`
|
||||
? Url
|
||||
: never;
|
||||
|
||||
declare module '@nestjs/common' {
|
||||
export const Get: (path?: fetchUrls) => MethodDecorator;
|
||||
export const Post: (path?: fetchUrls) => MethodDecorator;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AiEndGateway } from './ai-end.gateway';
|
||||
|
||||
describe('AiEndGateway', () => {
|
||||
let gateway: AiEndGateway;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AiEndGateway],
|
||||
}).compile();
|
||||
|
||||
gateway = module.get<AiEndGateway>(AiEndGateway);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(gateway).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Inject, forwardRef } from '@nestjs/common';
|
||||
import {
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, ServerOptions, Socket } from 'socket.io';
|
||||
import { AlarmEventService } from 'src/services/alarm-event/alarm-event.service';
|
||||
import { AlarmRule } from 'src/services/alarm-rule/alarm-rule.entity';
|
||||
import { Camera } from 'src/services/camera/camera.entity';
|
||||
import { CameraService } from 'src/services/camera/camera.service';
|
||||
import { UserService } from 'src/services/user/user.service';
|
||||
import { UtilsService } from 'src/services/utils/utils.service';
|
||||
|
||||
interface ClientInfo {
|
||||
username: string;
|
||||
password: string;
|
||||
cameraID: string;
|
||||
}
|
||||
|
||||
interface AlarmData {
|
||||
picBase64: string;
|
||||
alarmRuleID: number;
|
||||
}
|
||||
|
||||
interface CameraConfig {
|
||||
rtspUrl: string;
|
||||
alarmRules: AlarmRule[];
|
||||
}
|
||||
|
||||
@WebSocketGateway<Partial<ServerOptions>>({
|
||||
path: '/ws/ai/',
|
||||
cors: {},
|
||||
serveClient: false,
|
||||
})
|
||||
export class AiEndGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
wsServer: Server;
|
||||
constructor(
|
||||
@Inject(forwardRef(() => CameraService))
|
||||
private cameraService: CameraService,
|
||||
@Inject(forwardRef(() => AlarmEventService))
|
||||
private alarmEventService: AlarmEventService,
|
||||
@Inject(forwardRef(() => UserService))
|
||||
private userService: UserService,
|
||||
@Inject(forwardRef(() => UtilsService))
|
||||
private utilsService: UtilsService,
|
||||
) {}
|
||||
|
||||
connecetedClients: Map<string, Socket> = new Map();
|
||||
|
||||
@SubscribeMessage('message')
|
||||
handleMessage(
|
||||
@MessageBody() body: string,
|
||||
@ConnectedSocket() client: Socket,
|
||||
) {
|
||||
console.log('message received: ', body);
|
||||
client.emit('message', 'hello from server');
|
||||
}
|
||||
|
||||
@SubscribeMessage('alarm')
|
||||
async handleAlarm(
|
||||
@MessageBody() body: AlarmData,
|
||||
@ConnectedSocket() client: Socket,
|
||||
) {
|
||||
console.log('alarm received: ');
|
||||
|
||||
const { cameraID } = client.data as ClientInfo;
|
||||
const sourceCamera = new Camera();
|
||||
sourceCamera.id = parseInt(cameraID);
|
||||
const alarmRule = new AlarmRule();
|
||||
alarmRule.id = body.alarmRuleID;
|
||||
|
||||
await this.alarmEventService.addEvent({
|
||||
sourceCamera,
|
||||
picFilePath: await this.utilsService.writeBase64ImageToFile(
|
||||
body.picBase64,
|
||||
),
|
||||
alarmRule,
|
||||
});
|
||||
}
|
||||
|
||||
async notifyCameraConfigChange(
|
||||
cameraID: number,
|
||||
cameraConfig?: CameraConfig,
|
||||
) {
|
||||
const client = this.connecetedClients.get(cameraID.toString());
|
||||
if (!cameraConfig) {
|
||||
const config = await this.cameraService.getById(cameraID, true);
|
||||
if (!config) return;
|
||||
cameraConfig = {
|
||||
alarmRules: config?.alarmRules ?? [],
|
||||
rtspUrl: config.rtspUrl,
|
||||
};
|
||||
}
|
||||
client?.emit('cameraConfigChange', cameraConfig);
|
||||
|
||||
console.log(`notify ${cameraID} CameraConfigChange`);
|
||||
}
|
||||
|
||||
async disconnectClient(cameraID: number) {
|
||||
this.connecetedClients.get(cameraID.toString())?.disconnect();
|
||||
}
|
||||
|
||||
async handleConnection(@ConnectedSocket() client: Socket) {
|
||||
try {
|
||||
if (typeof client.client.request.headers.data !== 'string') {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
const data: ClientInfo = JSON.parse(client.client.request.headers.data);
|
||||
|
||||
const user = await this.userService.authLogin(
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connecetedClients.has(data.cameraID)) {
|
||||
this.connecetedClients.get(data.cameraID)?.disconnect();
|
||||
this.connecetedClients.delete(data.cameraID);
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
client.data = data;
|
||||
|
||||
const camera = await this.cameraService.getById(
|
||||
parseInt(data.cameraID),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if (!camera) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cameraService.updateCamera({
|
||||
id: parseInt(data.cameraID),
|
||||
online: true,
|
||||
});
|
||||
|
||||
this.connecetedClients.set(data.cameraID, client);
|
||||
|
||||
console.log(`client ${data.cameraID} connected`);
|
||||
|
||||
await this.notifyCameraConfigChange(parseInt(data.cameraID));
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async handleDisconnect(@ConnectedSocket() client: Socket) {
|
||||
this.connecetedClients.delete(client.data.cameraID);
|
||||
client.removeAllListeners();
|
||||
|
||||
const data = client.data as ClientInfo;
|
||||
await this.cameraService.updateCamera({
|
||||
id: parseInt(data.cameraID),
|
||||
online: false,
|
||||
});
|
||||
|
||||
console.log(`client ${data.cameraID} disconnected`);
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = async (ms: number) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(null);
|
||||
}, ms);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ServicesModule } from 'src/services/services.module';
|
||||
import { AiEndGateway } from './ai-end/ai-end.gateway';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ServicesModule],
|
||||
providers: [AiEndGateway],
|
||||
exports: [AiEndGateway],
|
||||
})
|
||||
export class WsGatewaysModule {}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user