hikvision-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * Hikvision Manager - Express API Server
3
+ *
4
+ * 提供 HTTP API 接口,前端通过 Ajax 调用
5
+ * 内部调用 services 层与海康平台通信
6
+ */
7
+
8
+ import express from 'express';
9
+ import cors from 'cors';
10
+ import dotenv from 'dotenv';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import { createHikvisionClient } from 'hikvision-core-node';
15
+ import { HikvisionConfig } from 'hikvision-api-lib';
16
+
17
+ dotenv.config();
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const app = express();
21
+
22
+ // 配置优先级:环境变量 > config.json > 默认值
23
+ function resolveValue(envKey: string, configValue?: string, defaultValue?: string): string {
24
+ // 1. 环境变量优先
25
+ if (process.env[envKey]) {
26
+ return process.env[envKey]!;
27
+ }
28
+ // 2. config.json 配置(支持环境变量引用)
29
+ if (configValue) {
30
+ // 支持 ${ENV_VAR} 语法引用环境变量
31
+ return configValue.replace(/\$\{([^}]+)\}/g, (_, envVar) => process.env[envVar] || '');
32
+ }
33
+ // 3. 默认值
34
+ return defaultValue || '';
35
+ }
36
+
37
+ // 加载 config.json
38
+ const configPath = path.resolve(__dirname, '../config.json');
39
+ let configData: any = {};
40
+ if (fs.existsSync(configPath)) {
41
+ try {
42
+ configData = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
43
+ } catch (e) {
44
+ console.warn('⚠️ 配置文件加载失败:', e);
45
+ }
46
+ }
47
+
48
+ // API Server 端口
49
+ const PORT = parseInt(
50
+ process.env.API_SERVER_PORT ||
51
+ configData?.apiServer?.port?.toString() ||
52
+ '3000'
53
+ );
54
+
55
+ // Manager 服务地址(用于重启时转发)
56
+ const MANAGER_URL = process.env.MANAGER_URL || 'http://localhost:3031';
57
+
58
+ // 中间件 - 添加安全配置
59
+ app.use(cors({
60
+ origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3030'],
61
+ credentials: true,
62
+ }));
63
+ app.use(express.json({ limit: '1mb' })); // 防止请求体过大攻击
64
+
65
+ // API Key 认证
66
+ const API_KEY = process.env.API_SERVER_API_KEY || configData?.auth?.apiKey || '';
67
+
68
+ // 时序安全的密钥比较
69
+ function safeCompare(a: string, b: string): boolean {
70
+ if (a.length !== b.length) return false;
71
+ try {
72
+ const crypto = require('crypto');
73
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
74
+ } catch {
75
+ let result = 0;
76
+ for (let i = 0; i < a.length; i++) {
77
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
78
+ }
79
+ return result === 0;
80
+ }
81
+ }
82
+
83
+ // 公开路由(不需要认证)
84
+ const PUBLIC_ROUTES = ['/api/health', '/api/config', '/api/config/test', '/api/service/status'];
85
+
86
+
87
+ // 认证中间件
88
+ function authMiddleware(req: any, res: any, next: any) {
89
+ if (PUBLIC_ROUTES.some(r => req.path.startsWith(r))) {
90
+ return next();
91
+ }
92
+
93
+ if (!API_KEY) {
94
+ if (process.env.NODE_ENV === 'production') {
95
+ return res.status(503).json({ success: false, message: '服务配置不完整:API_SERVER_API_KEY 未设置' });
96
+ }
97
+ return next();
98
+ }
99
+
100
+ const apiKey = req.headers['x-api-key'];
101
+ if (!apiKey) {
102
+ return res.status(401).json({ success: false, message: '未授权:缺少 API Key,请从系统设置获取' });
103
+ }
104
+ if (!safeCompare(apiKey, API_KEY)) {
105
+ return res.status(401).json({ success: false, message: '未授权:无效的 API Key' });
106
+ }
107
+ next();
108
+ }
109
+ app.use(authMiddleware);
110
+
111
+ // 海康平台配置
112
+ const config: HikvisionConfig = {
113
+ host: resolveValue('HIK_HOST', configData?.hikvision?.host, '127.0.0.1:18443'),
114
+ appKey: resolveValue('HIK_APP_KEY', configData?.hikvision?.appKey, ''),
115
+ appSecret: resolveValue('HIK_APP_SECRET', configData?.hikvision?.appSecret, ''),
116
+ };
117
+
118
+ // 创建客户端
119
+ const hikClient = createHikvisionClient(config);
120
+
121
+ // 错误响应脱敏:生产环境不返回内部错误信息
122
+ function safeErrorResponse(res: any, error: any, statusCode = 500) {
123
+ console.error('[API Error]', error.message);
124
+ if (process.env.NODE_ENV === 'production') {
125
+ res.status(statusCode).json({ success: false, message: '服务器内部错误' });
126
+ } else {
127
+ res.status(statusCode).json({ success: false, message: error.message });
128
+ }
129
+ }
130
+
131
+ // ========== 人员 API ==========
132
+
133
+ /**
134
+ * 查询人员列表
135
+ */
136
+ app.get('/api/persons', async (req, res) => {
137
+ try {
138
+ const pageNo = parseInt(req.query.pageNo as string) || 1;
139
+ const pageSize = parseInt(req.query.pageSize as string) || 20;
140
+ const orgIndexCode = req.query.orgIndexCode as string || undefined;
141
+ const personName = req.query.personName as string || undefined;
142
+
143
+ const result = await hikClient.personService.list({ pageNo, pageSize, orgIndexCode, personName });
144
+ res.json({ success: true, data: result });
145
+ } catch (error: any) {
146
+ safeErrorResponse(res, error);
147
+ }
148
+ });
149
+
150
+ /**
151
+ * 按姓名查询人员
152
+ */
153
+ app.get('/api/persons/search', async (req, res) => {
154
+ try {
155
+ const personName = req.query.name as string;
156
+ if (!personName) {
157
+ res.status(400).json({ success: false, message: '缺少 name 参数' });
158
+ return;
159
+ }
160
+
161
+ const result = await hikClient.personService.list({ personName, pageNo: 1, pageSize: 50 });
162
+ res.json({ success: true, data: result });
163
+ } catch (error: any) {
164
+ safeErrorResponse(res, error);
165
+ }
166
+ });
167
+
168
+ /**
169
+ * 获取人员关联信息(卡片+车辆)
170
+ */
171
+ app.get('/api/persons/:personId/bindings', async (req, res) => {
172
+ try {
173
+ const { personId } = req.params;
174
+ if (!personId) {
175
+ res.status(400).json({ success: false, message: '缺少 personId' });
176
+ return;
177
+ }
178
+
179
+ // Fetch all cards (pageSize 100 should cover most cases)
180
+ const cardResult = await hikClient.cardService.list({ pageNo: 1, pageSize: 100 });
181
+ const cards = (cardResult.list || [])
182
+ .filter((c: any) => c.personId === personId)
183
+ .map((c: any) => ({
184
+ cardNo: c.cardNo,
185
+ cardType: c.cardType,
186
+ useStatus: c.useStatus,
187
+ }));
188
+
189
+ // Fetch all vehicles for this person
190
+ const vehicleResult = await hikClient.vehicleService.list({ pageNo: 1, pageSize: 100, personId });
191
+ const vehicleList = vehicleResult.list || [];
192
+ // Fetch all groups once, then match
193
+ let groupMap: Record<string, string> = {};
194
+ try {
195
+ const groups = await hikClient.vehicleService.listGroups();
196
+ groupMap = Object.fromEntries(groups.map((g: any) => [g.groupId, g.groupName]));
197
+ } catch { /* ignore */ }
198
+ const vehicles = vehicleList.map((v: any) => ({
199
+ vehicleId: v.vehicleId,
200
+ plateNo: v.plateNo,
201
+ vehicleType: v.vehicleType,
202
+ groupId: v.vehicleGroup || v.categoryCode || v.groupId || '',
203
+ groupName: v.vehicleGroupName || v.categoryName || v.groupName || groupMap[v.vehicleGroup || v.groupId || ''] || '',
204
+ }));
205
+
206
+
207
+ res.json({ success: true, data: { cards, vehicles } });
208
+ } catch (error: any) {
209
+ safeErrorResponse(res, error);
210
+ }
211
+ });
212
+
213
+ /**
214
+ * 获取人员详情
215
+ */
216
+ app.get('/api/persons/:personId', async (req, res) => {
217
+ try {
218
+ const person = await hikClient.personService.getById(req.params.personId);
219
+ res.json({ success: true, data: person });
220
+ } catch (error: any) {
221
+ safeErrorResponse(res, error);
222
+ }
223
+ });
224
+
225
+ /**
226
+ * 添加人员
227
+ */
228
+ app.post('/api/persons', async (req, res) => {
229
+ try {
230
+ // 移除 DEBUG 日志,防止敏感信息泄露
231
+ const person = await hikClient.personService.create(req.body);
232
+ res.json({ success: true, data: person });
233
+ } catch (error: any) {
234
+ console.error('[API Error] POST /api/persons:', error.message);
235
+ res.status(500).json({ success: false, message: '服务器内部错误' });
236
+ }
237
+ });
238
+
239
+ /**
240
+ * 更新人员
241
+ */
242
+ app.put('/api/persons/:personId', async (req, res) => {
243
+ try {
244
+ await hikClient.personService.update(req.params.personId, req.body);
245
+ res.json({ success: true, message: '更新成功' });
246
+ } catch (error: any) {
247
+ safeErrorResponse(res, error);
248
+ }
249
+ });
250
+
251
+ /**
252
+ * 删除人员
253
+ */
254
+ app.delete('/api/persons/:personId', async (req, res) => {
255
+ try {
256
+ await hikClient.personService.delete(req.params.personId);
257
+ res.json({ success: true, message: '删除成功' });
258
+ } catch (error: any) {
259
+ safeErrorResponse(res, error);
260
+ }
261
+ });
262
+
263
+ /**
264
+ * 批量删除人员
265
+ */
266
+ app.post('/api/persons/batch-delete', async (req, res) => {
267
+ try {
268
+ const { personIds } = req.body;
269
+ if (!personIds || !Array.isArray(personIds)) {
270
+ res.status(400).json({ success: false, message: '缺少 personIds 参数' });
271
+ return;
272
+ }
273
+ await hikClient.personService.batchDelete(personIds);
274
+ res.json({ success: true, message: `成功删除 ${personIds.length} 名人员` });
275
+ } catch (error: any) {
276
+ safeErrorResponse(res, error);
277
+ }
278
+ });
279
+
280
+ // ========== 卡片 API ==========
281
+
282
+ /**
283
+ * 查询卡片列表
284
+ */
285
+ app.get('/api/cards', async (req, res) => {
286
+ try {
287
+ const pageNo = parseInt(req.query.pageNo as string) || 1;
288
+ const pageSize = parseInt(req.query.pageSize as string) || 20;
289
+
290
+ const result = await hikClient.cardService.list({ pageNo, pageSize });
291
+ res.json({ success: true, data: result });
292
+ } catch (error: any) {
293
+ safeErrorResponse(res, error);
294
+ }
295
+ });
296
+
297
+ /**
298
+ * 开卡
299
+ */
300
+ app.post('/api/cards/issue', async (req, res) => {
301
+ try {
302
+ const result = await hikClient.cardService.batchIssue(req.body);
303
+ res.json({ success: true, data: result });
304
+ } catch (error: any) {
305
+ safeErrorResponse(res, error);
306
+ }
307
+ });
308
+
309
+ /**
310
+ * 退卡
311
+ */
312
+ app.post('/api/cards/unbind', async (req, res) => {
313
+ try {
314
+ const { cardNo, personId } = req.body;
315
+ await hikClient.cardService.unbind(cardNo, personId);
316
+ res.json({ success: true, message: '退卡成功' });
317
+ } catch (error: any) {
318
+ safeErrorResponse(res, error);
319
+ }
320
+ });
321
+
322
+ // ========== 车辆 API ==========
323
+
324
+ /**
325
+ * 查询车辆列表
326
+ */
327
+ app.get('/api/vehicles', async (req, res) => {
328
+ try {
329
+ const pageNo = parseInt(req.query.pageNo as string) || 1;
330
+ const pageSize = parseInt(req.query.pageSize as string) || 20;
331
+ const plateNo = req.query.plateNo as string || '';
332
+ const personName = req.query.personName as string || '';
333
+
334
+ const result = await hikClient.vehicleService.list({
335
+ pageNo,
336
+ pageSize,
337
+ plateNo: plateNo || undefined,
338
+ personName: personName || undefined,
339
+ });
340
+
341
+ // Fetch all groups once, then match
342
+ let groupMap: Record<string, string> = {};
343
+ try {
344
+ const groups = await hikClient.vehicleService.listGroups();
345
+ groupMap = Object.fromEntries(groups.map((g: any) => [g.groupId, g.groupName]));
346
+ } catch { /* ignore */ }
347
+
348
+ // Enrich with groupName
349
+ if (result.list) {
350
+ result.list = result.list.map((v: any) => ({
351
+ ...v,
352
+ groupId: v.vehicleGroup || v.categoryCode || v.groupId || '',
353
+ groupName: v.vehicleGroupName || v.categoryName || v.groupName || groupMap[v.vehicleGroup || v.groupId || ''] || '',
354
+ }));
355
+ }
356
+
357
+ res.json({ success: true, data: result });
358
+ } catch (error: any) {
359
+ safeErrorResponse(res, error);
360
+ }
361
+ });
362
+
363
+ /**
364
+ * 获取车辆详情(含分组)
365
+ */
366
+ app.get('/api/vehicles/:vehicleId', async (req, res) => {
367
+ try {
368
+ // Use list API with vehicleId filter (more reliable than getById which may return wrong data)
369
+ const result = await hikClient.vehicleService.list({
370
+ pageNo: 1,
371
+ pageSize: 500,
372
+ plateNo: undefined,
373
+ personName: undefined,
374
+ });
375
+
376
+ const vehicle = (result.list || []).find((v: any) => v.vehicleId === req.params.vehicleId);
377
+ if (!vehicle) {
378
+ return res.status(404).json({ success: false, message: '车辆不存在' });
379
+ }
380
+
381
+ // Enrich with groupName
382
+ let groupName = '';
383
+ if (vehicle.vehicleGroup) {
384
+ try {
385
+ const groups = await hikClient.vehicleService.listGroups();
386
+ const groupMap = Object.fromEntries(groups.map((g: any) => [g.groupId, g.groupName]));
387
+ groupName = groupMap[vehicle.vehicleGroup] || '';
388
+ } catch { /* ignore */ }
389
+ }
390
+
391
+ res.json({ success: true, data: { ...vehicle, groupName } });
392
+ } catch (error: any) {
393
+ safeErrorResponse(res, error);
394
+ }
395
+ });
396
+
397
+ /**
398
+ * 添加车辆
399
+ */
400
+ app.post('/api/vehicles', async (req, res) => {
401
+ try {
402
+ const vehicle = await hikClient.vehicleService.saveOrUpdate(req.body);
403
+ res.json({ success: true, data: vehicle });
404
+ } catch (error: any) {
405
+ safeErrorResponse(res, error);
406
+ }
407
+ });
408
+
409
+ /**
410
+ * 更新车辆
411
+ */
412
+ app.put('/api/vehicles/:vehicleId', async (req, res) => {
413
+ try {
414
+ await hikClient.vehicleService.update(req.params.vehicleId, req.body);
415
+ res.json({ success: true, message: '更新成功' });
416
+ } catch (error: any) {
417
+ safeErrorResponse(res, error);
418
+ }
419
+ });
420
+
421
+ /**
422
+ * 删除车辆
423
+ */
424
+ app.delete('/api/vehicles/:vehicleId', async (req, res) => {
425
+ try {
426
+ await hikClient.vehicleService.delete(req.params.vehicleId);
427
+ res.json({ success: true, message: '删除成功' });
428
+ } catch (error: any) {
429
+ safeErrorResponse(res, error);
430
+ }
431
+ });
432
+
433
+ // ========== 组织 API ==========
434
+
435
+ /**
436
+ * 获取组织列表
437
+ */
438
+ app.get('/api/orgs', async (req, res) => {
439
+ try {
440
+ const result = await hikClient.personService.getOrganizations();
441
+ res.json({ success: true, data: result });
442
+ } catch (error: any) {
443
+ safeErrorResponse(res, error);
444
+ }
445
+ });
446
+
447
+ /**
448
+ * 获取组织详情
449
+ */
450
+ app.get('/api/orgs/:orgIndexCode', async (req, res) => {
451
+ try {
452
+ const org = await hikClient.personService.getOrganization(req.params.orgIndexCode);
453
+ res.json({ success: true, data: org });
454
+ } catch (error: any) {
455
+ safeErrorResponse(res, error);
456
+ }
457
+ });
458
+
459
+ /**
460
+ * 获取根组织
461
+ */
462
+ app.get('/api/orgs/root', async (req, res) => {
463
+ try {
464
+ const root = await hikClient.personService.getRootOrg();
465
+ res.json({ success: true, data: root });
466
+ } catch (error: any) {
467
+ safeErrorResponse(res, error);
468
+ }
469
+ });
470
+
471
+ /**
472
+ * 获取下级组织
473
+ */
474
+ app.get('/api/orgs/:orgIndexCode/children', async (req, res) => {
475
+ try {
476
+ const children = await hikClient.personService.getChildOrgs(req.params.orgIndexCode);
477
+ res.json({ success: true, data: children });
478
+ } catch (error: any) {
479
+ safeErrorResponse(res, error);
480
+ }
481
+ });
482
+
483
+ /**
484
+ * 添加组织(批量)
485
+ */
486
+ app.post('/api/orgs', async (req, res) => {
487
+ try {
488
+ const result = await hikClient.personService.batchAddOrgs(req.body);
489
+ res.json({ success: true, data: result });
490
+ } catch (error: any) {
491
+ safeErrorResponse(res, error);
492
+ }
493
+ });
494
+
495
+ /**
496
+ * 更新组织
497
+ */
498
+ app.put('/api/orgs/:orgIndexCode', async (req, res) => {
499
+ try {
500
+ await hikClient.personService.updateOrg(req.params.orgIndexCode, req.body);
501
+ res.json({ success: true, message: '更新成功' });
502
+ } catch (error: any) {
503
+ safeErrorResponse(res, error);
504
+ }
505
+ });
506
+
507
+ /**
508
+ * 删除组织(批量)
509
+ */
510
+ app.delete('/api/orgs', async (req, res) => {
511
+ try {
512
+ const orgIndexCodes = req.body.orgIndexCodes || [];
513
+ const result = await hikClient.personService.batchDeleteOrgs(orgIndexCodes);
514
+ res.json({ success: true, data: result });
515
+ } catch (error: any) {
516
+ safeErrorResponse(res, error);
517
+ }
518
+ });
519
+
520
+ /**
521
+ * 增量同步组织
522
+ */
523
+ app.post('/api/orgs/sync', async (req, res) => {
524
+ try {
525
+ const { startTime, endTime } = req.body;
526
+ const result = await hikClient.personService.syncOrgsIncrement(startTime, endTime);
527
+ res.json({ success: true, data: result });
528
+ } catch (error: any) {
529
+ safeErrorResponse(res, error);
530
+ }
531
+ });
532
+
533
+ // ========== 配置管理 API ==========
534
+
535
+ /**
536
+ * 获取当前配置
537
+ */
538
+ app.get('/api/config', (_req, res) => {
539
+ try {
540
+ const configPath = path.resolve(__dirname, '../config.json');
541
+ let configData: any = {};
542
+ if (fs.existsSync(configPath)) {
543
+ configData = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
544
+ }
545
+
546
+ // 脱敏显示:前2位 + **** + 后2位
547
+ function maskSecret(value: string): string {
548
+ if (!value) return '';
549
+ if (value.length <= 4) return '****';
550
+ return value.slice(0, 2) + '****' + value.slice(-2);
551
+ }
552
+
553
+ // 返回实际生效的配置(环境变量优先)
554
+ // 注意:前端显示的从 fileConfig 读取,敏感信息脱敏
555
+ res.json({
556
+ success: true,
557
+ data: {
558
+ configPath,
559
+ // 返回实际配置值(前端会正确处理只保存修改的字段)
560
+ fileConfig: configData,
561
+ // 脱敏信息单独提供,用于日志和调试
562
+ maskedConfig: {
563
+ ...configData,
564
+ hikvision: configData?.hikvision ? {
565
+ ...configData.hikvision,
566
+ appKey: configData.hikvision.appKey ? maskSecret(configData.hikvision.appKey) : '',
567
+ appSecret: configData.hikvision.appSecret ? maskSecret(configData.hikvision.appSecret) : '',
568
+ } : undefined,
569
+ },
570
+ effectiveConfig: {
571
+ apiServer: {
572
+ port: parseInt(
573
+ process.env.API_SERVER_PORT ||
574
+ configData?.apiServer?.port?.toString() ||
575
+ '3000'
576
+ )
577
+ },
578
+ web: {
579
+ port: parseInt(
580
+ process.env.WEB_PORT ||
581
+ configData?.web?.port?.toString() ||
582
+ '3030'
583
+ )
584
+ },
585
+ hikvision: {
586
+ host: resolveValue('HIK_HOST', configData?.hikvision?.host, '127.0.0.1:18443'),
587
+ appKey: resolveValue('HIK_APP_KEY', configData?.hikvision?.appKey, '') ? '***' : '',
588
+ appSecret: resolveValue('HIK_APP_SECRET', configData?.hikvision?.appSecret, '') ? '***' : '',
589
+ }
590
+ },
591
+ envOverrides: {
592
+ API_SERVER_PORT: process.env.API_SERVER_PORT || null,
593
+ WEB_PORT: process.env.WEB_PORT || null,
594
+ HIK_HOST: process.env.HIK_HOST || null,
595
+ HIK_APP_KEY: process.env.HIK_APP_KEY ? '***' : null,
596
+ HIK_APP_SECRET: process.env.HIK_APP_SECRET ? '***' : null,
597
+ }
598
+ }
599
+ });
600
+ } catch (error: any) {
601
+ safeErrorResponse(res, error);
602
+ }
603
+ });
604
+
605
+ /**
606
+ * 更新配置
607
+ */
608
+ app.post('/api/config', async (req, res) => {
609
+ try {
610
+ const configPath = path.resolve(__dirname, '../config.json');
611
+ const newConfig = req.body;
612
+
613
+ // 读取现有配置
614
+ let existingConfig: any = {};
615
+ try {
616
+ if (fs.existsSync(configPath)) {
617
+ existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
618
+ }
619
+ } catch {}
620
+
621
+ // 合并配置:保留 auth、manager.corsOrigins 等现有字段
622
+ // 如果新值为空,保留原有值
623
+ const mergedConfig = {
624
+ ...existingConfig,
625
+ ...newConfig,
626
+ // 强制保留 auth 字段不被覆盖
627
+ auth: existingConfig.auth,
628
+ // 合并 manager 配置,保留 corsOrigins
629
+ manager: {
630
+ ...(existingConfig.manager || {}),
631
+ ...(newConfig.manager || {}),
632
+ corsOrigins: existingConfig.manager?.corsOrigins,
633
+ },
634
+ // 保留 hikvision 敏感字段,如果新值为空则保留原有值
635
+ hikvision: {
636
+ ...(existingConfig.hikvision || {}),
637
+ ...(newConfig.hikvision || {}),
638
+ appKey: newConfig?.hikvision?.appKey || existingConfig?.hikvision?.appKey,
639
+ appSecret: newConfig?.hikvision?.appSecret || existingConfig?.hikvision?.appSecret,
640
+ },
641
+ };
642
+
643
+ // 验证必填项(host 必填,appKey/appSecret 只在有变更时才验证)
644
+ if (!mergedConfig.hikvision?.host) {
645
+ return res.status(400).json({
646
+ success: false,
647
+ message: 'hikvision.host 为必填项'
648
+ });
649
+ }
650
+
651
+ // 如果提交了 appKey 或 appSecret,则验证它们不能为空
652
+ if ((newConfig?.hikvision?.appKey || newConfig?.hikvision?.appSecret) &&
653
+ (!mergedConfig.hikvision?.appKey || !mergedConfig.hikvision?.appSecret)) {
654
+ return res.status(400).json({
655
+ success: false,
656
+ message: 'appKey 和 appSecret 必须同时提供'
657
+ });
658
+ }
659
+
660
+ // 写入配置文件
661
+ fs.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2), 'utf-8');
662
+
663
+ // 重新加载配置
664
+ configData = newConfig;
665
+ config.host = resolveValue('HIK_HOST', newConfig.hikvision?.host, '127.0.0.1:18443');
666
+ config.appKey = resolveValue('HIK_APP_KEY', newConfig.hikvision?.appKey, '');
667
+ config.appSecret = resolveValue('HIK_APP_SECRET', newConfig.hikvision?.appSecret, '');
668
+
669
+ // 重新创建客户端
670
+ // 注意:这里需要重新初始化客户端,但由于 Express 应用是单例,
671
+ // 实际生效需要重启服务
672
+
673
+ res.json({
674
+ success: true,
675
+ message: '配置已更新,重启服务后生效',
676
+ configPath,
677
+ effectiveConfig: {
678
+ hikvision: {
679
+ host: config.host,
680
+ appKey: config.appKey ? '***' : '',
681
+ appSecret: config.appSecret ? '***' : '',
682
+ }
683
+ }
684
+ });
685
+ } catch (error: any) {
686
+ safeErrorResponse(res, error);
687
+ }
688
+ });
689
+
690
+ // ========== 服务控制 API ==========
691
+
692
+ /**
693
+ * 获取服务状态
694
+ */
695
+ app.get('/api/service/status', (_req, res) => {
696
+ const pidPath = path.resolve(__dirname, '../.server.pid');
697
+ let pid: number | null = null;
698
+ let isRunning = false;
699
+
700
+ if (fs.existsSync(pidPath)) {
701
+ try {
702
+ pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim());
703
+ // 检查进程是否存在
704
+ try {
705
+ process.kill(pid, 0);
706
+ isRunning = true;
707
+ } catch {
708
+ isRunning = false;
709
+ pid = null;
710
+ }
711
+ } catch {
712
+ pid = null;
713
+ isRunning = false;
714
+ }
715
+ }
716
+
717
+ res.json({
718
+ success: true,
719
+ data: {
720
+ isRunning,
721
+ pid,
722
+ port: PORT,
723
+ host: `http://localhost:${PORT}`,
724
+ uptime: isRunning ? process.uptime() : 0
725
+ }
726
+ });
727
+ });
728
+
729
+ /**
730
+ * 停止服务
731
+ */
732
+ app.post('/api/service/stop', async (req, res) => {
733
+ try {
734
+ const pidPath = path.resolve(__dirname, '../.server.pid');
735
+
736
+ if (!fs.existsSync(pidPath)) {
737
+ return res.json({ success: true, message: '服务未运行(无 PID 文件)' });
738
+ }
739
+
740
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim());
741
+
742
+ try {
743
+ process.kill(pid, 'SIGTERM');
744
+ // 等待进程退出
745
+ await new Promise<void>((resolve) => {
746
+ const timeout = setTimeout(() => {
747
+ process.kill(pid, 'SIGKILL');
748
+ resolve();
749
+ }, 3000);
750
+
751
+ const checkInterval = setInterval(() => {
752
+ try {
753
+ process.kill(pid, 0);
754
+ } catch {
755
+ clearInterval(checkInterval);
756
+ clearTimeout(timeout);
757
+ resolve();
758
+ }
759
+ }, 100);
760
+ });
761
+
762
+ fs.unlinkSync(pidPath);
763
+ res.json({ success: true, message: `服务已停止 (PID: ${pid})` });
764
+ } catch (error: any) {
765
+ if (error.code === 'ESRCH') {
766
+ fs.unlinkSync(pidPath);
767
+ res.json({ success: true, message: '服务已停止(进程不存在)' });
768
+ } else {
769
+ res.status(500).json({ success: false, message: error.message });
770
+ }
771
+ }
772
+ } catch (error: any) {
773
+ safeErrorResponse(res, error);
774
+ }
775
+ });
776
+
777
+ /**
778
+ * 重启服务(通过 Manager 服务重启 API Server)
779
+ */
780
+ app.post('/api/service/restart', async (req, res) => {
781
+ try {
782
+ // 转发到 Manager 服务重启
783
+ const managerRes = await fetch(`${MANAGER_URL}/api/manager/restart`, { method: 'POST' });
784
+ const data = await managerRes.json();
785
+
786
+ if (data.success) {
787
+ res.json({ success: true, message: '服务已重启', pid: data.pid });
788
+ } else {
789
+ res.status(500).json({ success: false, message: data.message || '重启失败' });
790
+ }
791
+ } catch (error: any) {
792
+ res.status(500).json({ success: false, message: `无法连接 Manager 服务: ${error.message}` });
793
+ }
794
+ });
795
+
796
+ // ========== 配置测试 API ==========
797
+
798
+ /**
799
+ * 使用临时配置测试海康平台连接
800
+ */
801
+ app.post('/api/config/test', async (req, res) => {
802
+ const { host, appKey, appSecret } = req.body;
803
+
804
+ // 基础校验
805
+ if (!host || !appKey || !appSecret) {
806
+ return res.json({
807
+ status: 'error',
808
+ timestamp: new Date().toISOString(),
809
+ message: '配置不完整,请检查平台地址、App Key 和 App Secret'
810
+ });
811
+ }
812
+
813
+ // 创建临时客户端测试连接
814
+ try {
815
+ const testClient = createHikvisionClient({
816
+ host,
817
+ appKey,
818
+ appSecret,
819
+ } as HikvisionConfig);
820
+
821
+ // 调用人员列表 API 验证连接
822
+ await testClient.personService.list({ pageNo: 1, pageSize: 1 });
823
+
824
+ res.json({
825
+ status: 'ok',
826
+ timestamp: new Date().toISOString(),
827
+ message: '连接测试成功',
828
+ config: {
829
+ host,
830
+ appKeyConfigured: true,
831
+ appSecretConfigured: true,
832
+ }
833
+ });
834
+ } catch (error: any) {
835
+ res.json({
836
+ status: 'error',
837
+ timestamp: new Date().toISOString(),
838
+ message: error.message || '连接海康平台失败,请检查配置',
839
+ config: {
840
+ host,
841
+ appKeyConfigured: true,
842
+ appSecretConfigured: true,
843
+ }
844
+ });
845
+ }
846
+ });
847
+
848
+ // ========== 统计 API ==========
849
+
850
+ /**
851
+ * 获取系统统计信息
852
+ */
853
+ app.get('/api/stats', async (_req, res) => {
854
+ try {
855
+ const [personResult, orgResult, vehicleResult, cardResult] = await Promise.all([
856
+ hikClient.personService.list({ pageNo: 1, pageSize: 1 }),
857
+ hikClient.personService.getOrganizations(),
858
+ hikClient.vehicleService.list({ pageNo: 1, pageSize: 1 }),
859
+ hikClient.cardService.list({ pageNo: 1, pageSize: 1 }),
860
+ ]);
861
+
862
+ res.json({
863
+ success: true,
864
+ data: {
865
+ personCount: personResult.total,
866
+ orgCount: orgResult.length,
867
+ vehicleCount: vehicleResult.total,
868
+ cardCount: cardResult.total,
869
+ },
870
+ });
871
+ } catch (error: any) {
872
+ safeErrorResponse(res, error);
873
+ }
874
+ });
875
+
876
+ /**
877
+ * 获取各组织人员分布
878
+ */
879
+ app.get('/api/stats/persons-by-org', async (_req, res) => {
880
+ try {
881
+ const orgs = await hikClient.personService.getOrganizations();
882
+ const orgCountMap: Record<string, number> = {};
883
+ const orgPathMap: Record<string, string> = {};
884
+
885
+ // 批量获取每页 100 人,统计各组织人数并记录完整路径
886
+ let pageNo = 1;
887
+ const pageSize = 100;
888
+ let hasMore = true;
889
+
890
+ while (hasMore) {
891
+ const result = await hikClient.personService.list({ pageNo, pageSize });
892
+ result.list.forEach((p: any) => {
893
+ const orgCode = p.orgIndexCode || 'unknown';
894
+ orgCountMap[orgCode] = (orgCountMap[orgCode] || 0) + 1;
895
+ // 记录完整路径(如果有人的话,取第一个人的路径)
896
+ if (!orgPathMap[orgCode] && p.orgPath) {
897
+ orgPathMap[orgCode] = p.orgPath;
898
+ }
899
+ });
900
+ hasMore = result.list.length === pageSize;
901
+ pageNo++;
902
+ }
903
+
904
+ // 构建组织名称映射
905
+ const orgMap: Record<string, string> = {};
906
+ orgs.forEach((o: any) => {
907
+ orgMap[o.orgIndexCode] = o.orgName;
908
+ });
909
+
910
+ // 转换为数组并排序
911
+ const breakdown = Object.entries(orgCountMap).map(([orgIndexCode, count]) => ({
912
+ orgIndexCode,
913
+ orgName: orgMap[orgIndexCode] || orgIndexCode,
914
+ orgPath: orgPathMap[orgIndexCode] || orgMap[orgIndexCode] || orgIndexCode,
915
+ personCount: count,
916
+ })).sort((a, b) => b.personCount - a.personCount);
917
+
918
+ res.json({ success: true, data: breakdown });
919
+ } catch (error: any) {
920
+ safeErrorResponse(res, error);
921
+ }
922
+ });
923
+
924
+ /**
925
+ * 获取车辆绑定状态统计
926
+ */
927
+ app.get('/api/stats/vehicles-by-status', async (_req, res) => {
928
+ try {
929
+ const result = await hikClient.vehicleService.list({ pageNo: 1, pageSize: 1000 });
930
+ const vehicles = result.list || [];
931
+
932
+ const boundCount = vehicles.filter((v: any) => v.personId && v.personId.trim()).length;
933
+ const unboundCount = (result.total || 0) - boundCount;
934
+
935
+ res.json({
936
+ success: true,
937
+ data: {
938
+ total: result.total,
939
+ boundCount,
940
+ unboundCount,
941
+ boundPercent: result.total > 0 ? Math.round(boundCount / result.total * 100) : 0,
942
+ unboundPercent: result.total > 0 ? Math.round(unboundCount / result.total * 100) : 0,
943
+ },
944
+ });
945
+ } catch (error: any) {
946
+ safeErrorResponse(res, error);
947
+ }
948
+ });
949
+
950
+ /**
951
+ * 获取卡片状态统计
952
+ */
953
+ app.get('/api/stats/cards-by-status', async (_req, res) => {
954
+ try {
955
+ // 分页获取所有卡片,统计 useStatus
956
+ let pageNo = 1;
957
+ const pageSize = 100;
958
+ let totalActive = 0;
959
+ let totalInactive = 0;
960
+ let hasMore = true;
961
+
962
+ while (hasMore) {
963
+ const result = await hikClient.cardService.list({ pageNo, pageSize });
964
+ const cards = result.list || [];
965
+
966
+ totalActive += cards.filter((c: any) => c.useStatus === 1).length;
967
+ totalInactive += cards.filter((c: any) => c.useStatus !== 1).length;
968
+
969
+ hasMore = cards.length === pageSize && result.total > pageNo * pageSize;
970
+ pageNo++;
971
+ }
972
+
973
+ const total = totalActive + totalInactive;
974
+
975
+ res.json({
976
+ success: true,
977
+ data: {
978
+ total,
979
+ activeCount: totalActive,
980
+ inactiveCount: totalInactive,
981
+ activePercent: total > 0 ? Math.round(totalActive / total * 100) : 0,
982
+ inactivePercent: total > 0 ? Math.round(totalInactive / total * 100) : 0,
983
+ },
984
+ });
985
+ } catch (error: any) {
986
+ safeErrorResponse(res, error);
987
+ }
988
+ });
989
+
990
+ // ========== 健康检查 ==========
991
+
992
+ /**
993
+ * 健康检查 - 真正调用海康平台 API
994
+ */
995
+ app.get('/api/health', async (_req, res) => {
996
+ // 检查基础配置
997
+ if (!config.host || !config.appKey || !config.appSecret) {
998
+ return res.json({
999
+ status: 'error',
1000
+ timestamp: new Date().toISOString(),
1001
+ message: '配置不完整',
1002
+ config: {
1003
+ host: config.host || '(未配置)',
1004
+ appKeyConfigured: !!config.appKey,
1005
+ appSecretConfigured: !!config.appSecret,
1006
+ }
1007
+ });
1008
+ }
1009
+
1010
+ // 真正调用海康平台 API
1011
+ try {
1012
+ const result = await hikClient.personService.list({ pageNo: 1, pageSize: 1 });
1013
+ res.json({
1014
+ status: 'ok',
1015
+ timestamp: new Date().toISOString(),
1016
+ message: '连接成功',
1017
+ config: {
1018
+ host: config.host,
1019
+ appKeyConfigured: true,
1020
+ appSecretConfigured: true,
1021
+ }
1022
+ });
1023
+ } catch (error: any) {
1024
+ res.json({
1025
+ status: 'error',
1026
+ timestamp: new Date().toISOString(),
1027
+ message: error.message || '连接海康平台失败',
1028
+ config: {
1029
+ host: config.host,
1030
+ appKeyConfigured: true,
1031
+ appSecretConfigured: true,
1032
+ }
1033
+ });
1034
+ }
1035
+ });
1036
+
1037
+ // 启动服务器
1038
+ app.listen(PORT, () => {
1039
+ console.log(`🚀 Hikvision Manager API Server running on http://localhost:${PORT}`);
1040
+ console.log(` Config: host=${config.host}, appKey=${config.appKey ? '***' : '(empty)'}`);
1041
+
1042
+ // 保存 PID 文件
1043
+ const pidPath = path.resolve(__dirname, '../.server.pid');
1044
+ fs.writeFileSync(pidPath, String(process.pid), 'utf-8');
1045
+ });
1046
+
1047
+ // ========== 绑定关系 API ==========
1048
+
1049
+ /**
1050
+ * 查询人员-卡片绑定列表
1051
+ */
1052
+ app.get('/api/bindings/person-card', async (req, res) => {
1053
+ try {
1054
+ const pageNo = parseInt(req.query.pageNo as string) || 1;
1055
+ const pageSize = parseInt(req.query.pageSize as string) || 50;
1056
+
1057
+ const result: any = await hikClient.cardService.list({
1058
+ pageNo,
1059
+ pageSize,
1060
+ });
1061
+
1062
+ const cards = (result?.data?.list || result?.list || [])
1063
+ .filter((c: any) => c.personId);
1064
+
1065
+ res.json({ success: true, data: { list: cards, total: cards.length } });
1066
+ } catch (error: any) {
1067
+ safeErrorResponse(res, error);
1068
+ }
1069
+ });
1070
+
1071
+ /**
1072
+ * 查询人员-车辆绑定列表
1073
+ */
1074
+ app.get('/api/bindings/person-vehicle', async (req, res) => {
1075
+ try {
1076
+ const personId = req.query.personId as string;
1077
+ const pageNo = parseInt(req.query.pageNo as string) || 1;
1078
+ const pageSize = parseInt(req.query.pageSize as string) || 50;
1079
+
1080
+ const result: any = await hikClient.vehicleService.list({
1081
+ pageNo,
1082
+ pageSize,
1083
+ personId, // 传递 personId 过滤
1084
+ });
1085
+
1086
+ res.json({ success: true, data: result });
1087
+ } catch (error: any) {
1088
+ safeErrorResponse(res, error);
1089
+ }
1090
+ });
1091
+
1092
+ // ========== 车辆群组 ==========
1093
+ app.get('/api/vehicle-groups', async (_req, res) => {
1094
+ try {
1095
+ const groups = await hikClient.vehicleService.listGroups();
1096
+ res.json({ success: true, data: groups });
1097
+ } catch (error: any) {
1098
+ safeErrorResponse(res, error);
1099
+ }
1100
+ });