feat: 完善智慧停车综合管理平台源码

This commit is contained in:
admin
2026-10-10 17:20:00 +08:00
commit 9c9079c998
170 changed files with 27985 additions and 0 deletions
+25
View File
@@ -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',
},
};
+35
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+86
View File
@@ -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;
}
}
}
+84
View File
@@ -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"
}
}
+5601
View File
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

+8
View File
@@ -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
+51
View File
@@ -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,
},
};
}
}
+327
View File
@@ -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;
},
);
+43
View File
@@ -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;
}
}
+16
View File
@@ -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 })));
}
}
+12
View File
@@ -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);
}
}
+38
View File
@@ -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 {}
+44
View File
@@ -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();
});
});
+50
View File
@@ -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);
}
}
+456
View File
@@ -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 {}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+22
View File
@@ -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
}
}