From 07b344e51076a0307c1ac1be52d59436abb32fbf Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期五, 18 九月 2026 14:14:36 +0800
Subject: [PATCH] 添加了 从芯跃智联 api获取定位数据和电压
---
src/modules/report/service/taskservice.ts | 52 +++
src/modules/task/db.json | 18 +
src/modules/push/entity/vehiclegps.ts | 16 +
src/modules/report/entity/joblog.ts | 2
src/modules/base/db.json | 70 ++++
src/modules/report/service/taskservicexinyuegps.ts | 615 +++++++++++++++++++++++++++++++++++++++++
src/modules/report/controller/admin/dailysorter.ts | 9
src/modules/report/service/ExecutorXinYueVehicleGps.ts | 106 +++++++
src/modules/basicdata/entity/iot.ts | 2
9 files changed, 886 insertions(+), 4 deletions(-)
diff --git a/src/modules/base/db.json b/src/modules/base/db.json
index 82262f5..dea4747 100644
--- a/src/modules/base/db.json
+++ b/src/modules/base/db.json
@@ -48,6 +48,76 @@
"data": "<h3 style=\"text-align: center;\"><strong>闅愮鏀跨瓥</strong></h3><p><br></p><p>xxxxxx</p>",
"dataType": 1,
"remark": null
+ },
+ {
+ "keyName": "xinYue.baseUrl",
+ "name": "鑺穬寮�鏀炬帴鍙e煙鍚�",
+ "data": "https://open-api.yznapp.com",
+ "dataType": 0,
+ "remark": "瀵瑰簲鏂囨。 api.example.com锛屾寮忕幆澧冧负 open-api.yznapp.com"
+ },
+ {
+ "keyName": "xinYue.portalUrl",
+ "name": "鑺穬骞冲彴闂ㄦ埛鍦板潃",
+ "data": "https://xy-gnss.com",
+ "dataType": 0,
+ "remark": "鑺穬 GNSS 绠$悊鍚庡彴鍦板潃"
+ },
+ {
+ "keyName": "xinYue.appKey",
+ "name": "鑺穬寮�鏀惧钩鍙癒EY",
+ "data": "JM7JX2yCxcjmOkYt",
+ "dataType": 0,
+ "remark": "璇锋眰澶� X-Api-AppKey"
+ },
+ {
+ "keyName": "xinYue.appSecret",
+ "name": "鑺穬寮�鏀惧钩鍙扮閽�",
+ "data": "3CXm9ZD7JC1iBHaxnA1bBswL95dVI2Ip",
+ "dataType": 0,
+ "remark": "HMAC-SHA256 绛惧悕绉橀挜"
+ },
+ {
+ "keyName": "xinYue.account",
+ "name": "鑺穬骞冲彴璐﹀彿",
+ "data": "浜敵绉戞妧",
+ "dataType": 0,
+ "remark": "闂ㄦ埛鐧诲綍璐﹀彿锛屽紑鏀炬帴鍙i壌鏉冧笉浣跨敤"
+ },
+ {
+ "keyName": "xinYue.password",
+ "name": "鑺穬骞冲彴瀵嗙爜",
+ "data": "123456",
+ "dataType": 0,
+ "remark": "闂ㄦ埛鐧诲綍瀵嗙爜锛屽紑鏀炬帴鍙i壌鏉冧笉浣跨敤"
+ },
+ {
+ "keyName": "xinYue.whitelistIp",
+ "name": "鑺穬鎺ュ彛鐧藉悕鍗旾P",
+ "data": "115.159.85.154",
+ "dataType": 0,
+ "remark": "璋冪敤鏂瑰叕缃慖P锛岄渶鍦ㄨ姱璺冨悗鍙伴厤缃櫧鍚嶅崟"
+ },
+ {
+ "keyName": "xinYue.iotTypeCode",
+ "name": "鑺穬鍚屾缁堢绫诲瀷",
+ "data": "涓夎疆杞﹀畾浣�",
+ "dataType": 0,
+ "remark": "浠� t_basicdata_iot.iotTypeCode 绛涢�夎澶囷紝iotCode 涓鸿澶囧彿/IMEI"
+ },
+ {
+ "keyName": "xinYue.syncIntervalSeconds",
+ "name": "鑺穬浣嶇疆鐢靛帇鍚屾鍛ㄦ湡(绉�)",
+ "data": "300",
+ "dataType": 0,
+ "remark": "瀹氭椂鎷夊彇浣嶇疆鍜岀數鍘嬬殑鏈�灏忛棿闅旓紝寤鸿鍚庡彴浠诲姟 cron 涓嶅ぇ浜庤鍊笺�傞粯璁�300绉�"
+ },
+ {
+ "keyName": "xinYue.requestDelayMs",
+ "name": "鑺穬鎺ュ彛璇锋眰闂撮殧(姣)",
+ "data": "400",
+ "dataType": 0,
+ "remark": "閫愬彴鏌ヨ璁惧鏃剁殑闂撮殧锛岄伩鍏嶈Е鍙� QPS 闄愭祦"
}
],
"base_sys_conf": [
diff --git a/src/modules/basicdata/entity/iot.ts b/src/modules/basicdata/entity/iot.ts
index 75c4f75..12dfe0a 100644
--- a/src/modules/basicdata/entity/iot.ts
+++ b/src/modules/basicdata/entity/iot.ts
@@ -15,7 +15,7 @@
iotName: string;
@Index()
- @Column({ comment: '缁堢绫诲瀷' }) // 鐢靛瓙绉� 鍥哄畾鐩戞帶 杞﹁浇鐩戞帶 杞﹁浇GPS 鍙告満寮傚父 鐩戞帶鎵嬬幆
+ @Column({ comment: '缁堢绫诲瀷' }) // 鐢靛瓙绉� 鍥哄畾鐩戞帶 杞﹁浇鐩戞帶 杞﹁浇GPS 鍙告満寮傚父 鐩戞帶鎵嬬幆 涓夎疆杞﹀畾浣�
iotTypeCode: string;
diff --git a/src/modules/push/entity/vehiclegps.ts b/src/modules/push/entity/vehiclegps.ts
index 3f21e07..f2fd90c 100644
--- a/src/modules/push/entity/vehiclegps.ts
+++ b/src/modules/push/entity/vehiclegps.ts
@@ -47,4 +47,20 @@
@Column({ comment: '瀹氫綅绮惧害' })
status: string;
+
+ @Column({
+ comment: '鐢靛帇(V)锛岃姱璺冨畾浣嶅櫒鍚屾锛涜悿鐭充簯鎺ㄩ�佸彲绌�',
+ type: 'decimal',
+ precision: 8,
+ scale: 2,
+ nullable: true,
+ })
+ voltage: number;
+
+ @Column({
+ comment: '鐢甸噺鐧惧垎姣旓紝鑺穬瀹氫綅鍣ㄥ悓姝ワ紱钀ょ煶浜戞帹閫佸彲绌�',
+ type: 'int',
+ nullable: true,
+ })
+ batteryPercent: number;
}
\ No newline at end of file
diff --git a/src/modules/report/controller/admin/dailysorter.ts b/src/modules/report/controller/admin/dailysorter.ts
index 7b28a35..bf25856 100644
--- a/src/modules/report/controller/admin/dailysorter.ts
+++ b/src/modules/report/controller/admin/dailysorter.ts
@@ -2,6 +2,7 @@
import { ReportDailySorterEntity } from '../../entity/dailysorter';
import { ReportDailySorterService } from '../../service/dailysorter';
import { BaseSysDepartmentEntity } from '../../../base/entity/sys/department';
+import { BasicdataSiteEntity } from '../../../basicdata/entity/site';
import { WithDeptFilterWhere } from '../../../base/middleware/with-dept-filter-where';
/**
@@ -14,7 +15,7 @@
pageQueryOp: {
keyWordLikeFields: ['a.businessName', 'a.businessId'],
fieldEq: ['a.date', 'a.businessId', 'a.attendanceStatus'],
- select: ['a.*', 'b.name AS departmentName'],
+ select: ['a.*', 'b.name AS departmentName', 'c.businessName AS workSiteName'],
join: [
{
entity: BaseSysDepartmentEntity,
@@ -22,6 +23,12 @@
condition: 'a.departmentId = b.id',
type: 'leftJoin',
},
+ {
+ entity: BasicdataSiteEntity,
+ alias: 'c',
+ condition: 'a.workSiteId = c.businessId',
+ type: 'leftJoin',
+ },
],
where: async ctx => {
const conditions: any[][] = [];
diff --git a/src/modules/report/entity/joblog.ts b/src/modules/report/entity/joblog.ts
index 9591bbb..b6dc83d 100644
--- a/src/modules/report/entity/joblog.ts
+++ b/src/modules/report/entity/joblog.ts
@@ -8,7 +8,7 @@
@Index(['jobName', 'dataDate'], { unique: true })
export class ReportJoblogEntity extends BaseEntity {
@Index()
- @Column({ comment: '浠诲姟绫诲瀷: SITE_DAILY(绔欑偣鏃ユ眹鎬�), DEPT_DAILY(閮ㄩ棬鏃ユ眹鎬�)' })
+ @Column({ comment: '浠诲姟绫诲瀷: SITE_DAILY/DEPT_DAILY/VEHICLE_DAILY/SORTER_DAILY/XINYUE_VEHICLE_GPS' })
jobName: string;
@Index()
diff --git a/src/modules/report/service/ExecutorXinYueVehicleGps.ts b/src/modules/report/service/ExecutorXinYueVehicleGps.ts
new file mode 100644
index 0000000..1a60e63
--- /dev/null
+++ b/src/modules/report/service/ExecutorXinYueVehicleGps.ts
@@ -0,0 +1,106 @@
+import { Provide, Inject } from '@midwayjs/core';
+import { InjectEntityModel } from '@midwayjs/typeorm';
+import { Repository, In, LessThan } from 'typeorm';
+import { ReportJoblogEntity } from '../entity/joblog';
+import { TaskServiceXinYueGps } from './taskservicexinyuegps';
+
+/**
+ * 鑺穬涓夎疆杞﹀畾浣嶅疄鏃跺悓姝ユ墽琛屽櫒
+ * 璐熻矗 joblog 鐘舵�佹満涓庡悓姝ュ懆鏈熻妭娴侊紝鍏蜂綋鎷夋暟鐢� TaskServiceXinYueGps 瀹屾垚
+ */
+@Provide()
+export class ExecutorXinYueVehicleGps {
+ @InjectEntityModel(ReportJoblogEntity)
+ reportJoblogEntity: Repository<ReportJoblogEntity>;
+
+ @Inject()
+ taskServiceXinYueGps: TaskServiceXinYueGps;
+
+ async execute() {
+ const pendingJobs = await this.reportJoblogEntity.find({
+ where: {
+ status: In(['INIT', 'FAILED']),
+ jobName: 'XINYUE_VEHICLE_GPS',
+ },
+ order: {
+ dataDate: 'ASC',
+ },
+ });
+
+ for (const job of pendingJobs) {
+ const intervalSeconds = await this.taskServiceXinYueGps.getSyncIntervalSeconds();
+ if (this.shouldSkipByInterval(job, intervalSeconds)) {
+ continue;
+ }
+
+ const lockResult = await this.reportJoblogEntity.update(
+ { id: job.id, status: job.status },
+ { status: 'RUNNING', startTime: new Date() }
+ );
+
+ if (lockResult.affected === 0) {
+ continue;
+ }
+
+ try {
+ const result = await this.taskServiceXinYueGps.syncRealtimeData();
+ await this.reportJoblogEntity.update(job.id, {
+ status: 'SUCCESS',
+ recordsProcessed: result.recordsProcessed,
+ endTime: new Date(),
+ errorMessage: result.errorMessage || '',
+ });
+ } catch (error) {
+ await this.reportJoblogEntity.update(job.id, {
+ status: 'FAILED',
+ endTime: new Date(),
+ errorMessage: this.stringifyError(error),
+ failedCount: () => 'failedCount + 1',
+ });
+ }
+ }
+ }
+
+ /**
+ * 灏嗗崱浣忕殑 RUNNING 浠诲姟娲楀洖 INIT锛岄伩鍏嶈繘绋嬩腑鏂悗姘歌繙涓嶈窇
+ */
+ async recoverStuckJobs() {
+ const expireAt = new Date(Date.now() - 10 * 60 * 1000);
+ await this.reportJoblogEntity.update(
+ {
+ jobName: 'XINYUE_VEHICLE_GPS',
+ status: 'RUNNING',
+ startTime: LessThan(expireAt),
+ },
+ { status: 'INIT' }
+ );
+ }
+
+ /**
+ * 璺濅笂娆$粨鏉熸湭鍒板悓姝ュ懆鏈熷垯璺宠繃锛岄伩鍏嶅悗鍙� cron 姣斿弬鏁版洿瀵嗘椂鍒锋帴鍙�
+ */
+ private shouldSkipByInterval(job: ReportJoblogEntity, intervalSeconds: number): boolean {
+ if (!intervalSeconds || intervalSeconds <= 0) {
+ return false;
+ }
+ if (!job.endTime) {
+ return false;
+ }
+ const elapsed = Date.now() - new Date(job.endTime).getTime();
+ return elapsed < intervalSeconds * 1000;
+ }
+
+ private stringifyError(error: any): string {
+ if (error instanceof Error) {
+ return error.stack ?? error.message;
+ }
+ if (typeof error === 'string') {
+ return error;
+ }
+ try {
+ return JSON.stringify(error);
+ } catch {
+ return String(error);
+ }
+ }
+}
diff --git a/src/modules/report/service/taskservice.ts b/src/modules/report/service/taskservice.ts
index 4498ccb..0b9b7be 100644
--- a/src/modules/report/service/taskservice.ts
+++ b/src/modules/report/service/taskservice.ts
@@ -1,10 +1,11 @@
import { Provide, Inject } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
-import { Repository, In } from 'typeorm';
+import { Repository } from 'typeorm';
import { ReportJoblogEntity } from '../entity/joblog';
import { ExecutorSiteDeptDaily } from './ExecutorSiteDeptDaily'
import { ExecutorVehicleDaily } from './ExecutorVehicleDaily'
+import { ExecutorXinYueVehicleGps } from './ExecutorXinYueVehicleGps'
/**
* 瀹氭椂浠诲姟-姹囨�荤珯鐐�/鍚勪釜鍗曚綅鐨勫悇绫诲瀮鍦炬眹鎬绘暟鎹�
@@ -22,6 +23,9 @@
@Inject()
executorVehicleDaily: ExecutorVehicleDaily;
+
+ @Inject()
+ executorXinYueVehicleGps: ExecutorXinYueVehicleGps;
@@ -44,6 +48,18 @@
return '浠诲姟鎵ц:ReportTaskService';
+ }
+
+ /**
+ * 鑺穬涓夎疆杞﹀畾浣嶏細瀹氭椂鎷夊彇浣嶇疆鍜岀數鍘�
+ * 鍚庡彴浠诲姟绠$悊閰嶇疆锛歳eportTaskService.syncXinYueVehicleGps()
+ * 寤鸿 cron 涓嶅ぇ浜庣郴缁熷弬鏁� xinYue.syncIntervalSeconds锛堥粯璁�300绉掞級锛屼緥濡傛瘡鍒嗛挓锛�0 * * * * *
+ */
+ async syncXinYueVehicleGps() {
+ await this.ensureXinYueGpsJobInstance();
+ await this.executorXinYueVehicleGps.recoverStuckJobs();
+ await this.executorXinYueVehicleGps.execute();
+ return '浠诲姟鎵ц:鑺穬涓夎疆杞﹀畾浣嶅悓姝�';
}
/**
@@ -101,5 +117,39 @@
}
}
+ /**
+ * 鍒濆鍖栧綋澶╃殑鑺穬瀹氫綅鍚屾浠诲姟锛孲UCCESS 娲楀洖 INIT 浠ヤ究鎸夊懆鏈熼噸鎷�
+ */
+ private async ensureXinYueGpsJobInstance() {
+ const now = new Date();
+ const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
+ try {
+ await this.reportJoblogEntity.update(
+ { jobName: 'XY_VEHICLE_GPS' },
+ { jobName: 'XINYUE_VEHICLE_GPS' }
+ );
+ } catch (err) {
+ // 鏂版棫浠诲姟鍚嶅苟瀛樻椂蹇界暐鍞竴绱㈠紩鍐茬獊
+ }
+ try {
+ const exist = await this.reportJoblogEntity.findOneBy({
+ jobName: 'XINYUE_VEHICLE_GPS',
+ dataDate: todayStr,
+ });
+ if (!exist) {
+ const job = new ReportJoblogEntity();
+ job.jobName = 'XINYUE_VEHICLE_GPS';
+ job.dataDate = todayStr;
+ job.status = 'INIT';
+ await this.reportJoblogEntity.save(job);
+ return;
+ }
+ if (exist.status === 'SUCCESS') {
+ await this.reportJoblogEntity.update(exist.id, { status: 'INIT' });
+ }
+ } catch (err) {
+ // 骞跺彂鍐欏叆鏃跺彲鑳借Е鍙戝敮涓�绱㈠紩鍐茬獊锛岀洿鎺ュ拷鐣ュ嵆鍙�
+ }
+ }
}
\ No newline at end of file
diff --git a/src/modules/report/service/taskservicexinyuegps.ts b/src/modules/report/service/taskservicexinyuegps.ts
new file mode 100644
index 0000000..bcf1442
--- /dev/null
+++ b/src/modules/report/service/taskservicexinyuegps.ts
@@ -0,0 +1,615 @@
+import { Provide } from '@midwayjs/core';
+import { BaseService } from '@cool-midway/core';
+import { InjectEntityModel } from '@midwayjs/typeorm';
+import { Repository } from 'typeorm';
+import axios, { AxiosError } from 'axios';
+import * as crypto from 'crypto';
+import { BaseSysParamEntity } from '../../base/entity/sys/param';
+import { BasicdataIotEntity } from '../../basicdata/entity/iot';
+import { PushVehicleGpsEntity } from '../../push/entity/vehiclegps';
+import { DictTypeEntity } from '../../dict/entity/type';
+import { DictInfoEntity } from '../../dict/entity/info';
+
+/**
+ * 鑺穬寮�鏀炬帴鍙i厤缃�
+ */
+interface XinYueApiConfig {
+ baseUrl: string;
+ appKey: string;
+ appSecret: string;
+ iotTypeCode: string;
+ requestDelayMs: number;
+}
+
+/**
+ * 鑺穬 getDeviceInfo 杩斿洖鐨勮澶囧疄鏃舵暟鎹�
+ */
+interface XinYueDeviceInfo {
+ imei?: string;
+ locationType?: number;
+ speed?: number;
+ gpsTime?: string;
+ latitude?: number;
+ longitude?: number;
+ batteryPercentage?: number;
+ altitude?: number;
+ direction?: number;
+ mainsVoltage?: number;
+}
+
+/**
+ * 鍗曞彴璁惧鍚屾缁撴灉
+ */
+interface XinYueDeviceSyncResult {
+ iotCode: string;
+ saved: boolean;
+ skipped: boolean;
+ error?: string;
+}
+
+const PARAM_KEY_MAP: Array<{ oldKey: string; newKey: string }> = [
+ { oldKey: 'xy.baseUrl', newKey: 'xinYue.baseUrl' },
+ { oldKey: 'xy.portalUrl', newKey: 'xinYue.portalUrl' },
+ { oldKey: 'xy.appKey', newKey: 'xinYue.appKey' },
+ { oldKey: 'xy.appSecret', newKey: 'xinYue.appSecret' },
+ { oldKey: 'xy.account', newKey: 'xinYue.account' },
+ { oldKey: 'xy.password', newKey: 'xinYue.password' },
+ { oldKey: 'xy.whitelistIp', newKey: 'xinYue.whitelistIp' },
+ { oldKey: 'xy.iotTypeCode', newKey: 'xinYue.iotTypeCode' },
+ { oldKey: 'xy.syncIntervalSeconds', newKey: 'xinYue.syncIntervalSeconds' },
+ { oldKey: 'xy.requestDelayMs', newKey: 'xinYue.requestDelayMs' },
+];
+
+/**
+ * 瀹氭椂浠诲姟-浠庤姱璺冨钩鍙版媺鍙栦笁杞溅瀹氫綅鍣ㄥ疄鏃朵綅缃拰鐢靛帇
+ * 涓嶆眹鎬荤粺璁★紝浠呮寜 iot 琛ㄤ腑銆屼笁杞溅瀹氫綅銆嶈澶囧彿璋冪敤 getDeviceInfo 骞跺啓鍏ヨ溅杈咷PS琛�
+ */
+@Provide()
+export class TaskServiceXinYueGps extends BaseService {
+ @InjectEntityModel(BaseSysParamEntity)
+ baseSysParamEntity: Repository<BaseSysParamEntity>;
+
+ @InjectEntityModel(BasicdataIotEntity)
+ basicdataIotEntity: Repository<BasicdataIotEntity>;
+
+ @InjectEntityModel(PushVehicleGpsEntity)
+ pushVehicleGpsEntity: Repository<PushVehicleGpsEntity>;
+
+ @InjectEntityModel(DictTypeEntity)
+ dictTypeEntity: Repository<DictTypeEntity>;
+
+ @InjectEntityModel(DictInfoEntity)
+ dictInfoEntity: Repository<DictInfoEntity>;
+
+ /**
+ * 鍚屾鍏ュ彛
+ * @returns 鍐欏叆鏉℃暟鍙婂け璐ユ槑缁嗭紙澶辫触鏄庣粏鐢辨墽琛屽櫒鍐欏叆 joblog锛�
+ */
+ async syncRealtimeData(): Promise<{ recordsProcessed: number; errorMessage: string }> {
+ await this.ensureSysParams();
+ await this.ensureIotTypeDict();
+
+ const config = await this.loadApiConfig();
+ const devices = await this.basicdataIotEntity.find({
+ where: { iotTypeCode: config.iotTypeCode, status: 1 },
+ select: ['id', 'iotCode', 'iotName', 'businessId', 'departmentId'],
+ });
+
+ if (!devices.length) {
+ return { recordsProcessed: 0, errorMessage: '' };
+ }
+
+ const errors: string[] = [];
+ let savedCount = 0;
+
+ for (let i = 0; i < devices.length; i++) {
+ const device = devices[i];
+ const iotCode = String(device.iotCode || '').trim();
+ if (!iotCode) {
+ errors.push(`iotId=${device.id} 缂哄皯璁惧鍙穒otCode`);
+ continue;
+ }
+
+ try {
+ const result = await this.syncOneDevice(device, config);
+ if (result.saved) {
+ savedCount += 1;
+ }
+ if (result.error) {
+ errors.push(result.error);
+ }
+ } catch (err) {
+ errors.push(`${iotCode}: ${this.stringifyError(err)}`);
+ }
+
+ if (i < devices.length - 1 && config.requestDelayMs > 0) {
+ await this.sleep(config.requestDelayMs);
+ }
+ }
+
+ if (errors.length > 0 && savedCount === 0) {
+ throw new Error(errors.join('\n'));
+ }
+ return {
+ recordsProcessed: savedCount,
+ errorMessage: errors.length
+ ? `鎴愬姛${savedCount}鍙帮紝澶辫触${errors.length}鍙帮細\n${errors.join('\n')}`
+ : '',
+ };
+ }
+
+ /**
+ * 璇诲彇鍚屾鍛ㄦ湡锛堢锛夛紝渚涙墽琛屽櫒鑺傛祦
+ */
+ async getSyncIntervalSeconds(): Promise<number> {
+ await this.ensureSysParams();
+ const value = await this.readParamNumber('xinYue.syncIntervalSeconds', 300);
+ return value > 0 ? value : 300;
+ }
+
+ /**
+ * 鍚屾鍗曞彴璁惧
+ */
+ private async syncOneDevice(
+ device: BasicdataIotEntity,
+ config: XinYueApiConfig
+ ): Promise<XinYueDeviceSyncResult> {
+ const iotCode = String(device.iotCode).trim();
+ const info = await this.getDeviceInfo(iotCode, config);
+ if (!info) {
+ return { iotCode, saved: false, skipped: true, error: `${iotCode}: 鎺ュ彛鏈繑鍥炶澶囨暟鎹甡 };
+ }
+
+ const voltage = this.toNumber(info.mainsVoltage);
+ let batteryPercent = this.toInt(info.batteryPercentage);
+ if (batteryPercent == null && voltage != null) {
+ batteryPercent = this.estimateBatteryPercent(voltage);
+ }
+
+ const gpsTime = this.parseDate(info.gpsTime);
+ const longitudeBd09 = this.toNumber(info.longitude);
+ const latitudeBd09 = this.toNumber(info.latitude);
+ let longitude: number | null = null;
+ let latitude: number | null = null;
+ if (longitudeBd09 != null && latitudeBd09 != null) {
+ const wgs = this.bd09ToWgs84(longitudeBd09, latitudeBd09);
+ longitude = wgs.longitude;
+ latitude = wgs.latitude;
+ }
+
+ const locationType = this.toInt(info.locationType);
+ const status = locationType === 1 ? 'A' : 'B';
+
+ const last = await this.pushVehicleGpsEntity.findOne({
+ where: { sn: iotCode },
+ order: { gpsTime: 'DESC', id: 'DESC' },
+ });
+
+ if (last && gpsTime && last.gpsTime && new Date(last.gpsTime).getTime() === gpsTime.getTime()) {
+ const patch: Partial<PushVehicleGpsEntity> = {};
+ if (voltage != null && Number(last.voltage) !== voltage) {
+ patch.voltage = voltage;
+ }
+ if (batteryPercent != null && last.batteryPercent !== batteryPercent) {
+ patch.batteryPercent = batteryPercent;
+ }
+ if (Object.keys(patch).length > 0) {
+ await this.pushVehicleGpsEntity.update(last.id, patch);
+ return { iotCode, saved: true, skipped: false };
+ }
+ return { iotCode, saved: false, skipped: true };
+ }
+
+ await this.pushVehicleGpsEntity.save({
+ sn: iotCode,
+ msgSeq: `xinYue_${iotCode}_${gpsTime ? gpsTime.getTime() : Date.now()}`,
+ createDate: new Date(),
+ vehicleNo: device.businessId || iotCode,
+ departmentId: device.departmentId || 0,
+ gpsTime: gpsTime || new Date(),
+ longitude,
+ latitude,
+ speed: this.toNumber(info.speed),
+ direction: this.toInt(info.direction),
+ elevation: this.toNumber(info.altitude),
+ status,
+ voltage,
+ batteryPercent,
+ });
+
+ return { iotCode, saved: true, skipped: false };
+ }
+
+ /**
+ * 璋冪敤鑺穬鏌ヨ璁惧淇℃伅鎺ュ彛
+ */
+ private async getDeviceInfo(
+ imei: string,
+ config: XinYueApiConfig
+ ): Promise<XinYueDeviceInfo | null> {
+ const path = '/open-api/device/getDeviceInfo';
+ const body = JSON.stringify({ imei });
+ const payload = await this.postJson(config, path, body);
+ if (payload?.code !== 200) {
+ throw new Error(
+ `${imei}: ${payload?.msg || '鏌ヨ璁惧淇℃伅澶辫触'}锛坈ode: ${payload?.code}锛塦
+ );
+ }
+ return payload?.data || null;
+ }
+
+ /**
+ * 甯︾鍚嶇殑 POST JSON 璇锋眰锛孮PS 瓒呴檺鏃舵寜鏂囨。閫�閬块噸璇�
+ */
+ private async postJson(
+ config: XinYueApiConfig,
+ path: string,
+ jsonBody: string,
+ retry = 0
+ ): Promise<any> {
+ const url = `${config.baseUrl.replace(/\/$/, '')}${path}`;
+ const headers = this.buildSignedHeaders(jsonBody, config.appKey, config.appSecret);
+
+ try {
+ const resp = await axios.post(url, jsonBody, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ timeout: 30000,
+ validateStatus: () => true,
+ });
+
+ const errorCode = resp.data?.errorCode;
+ if (resp.status === 429 && errorCode === 'OPEN_API_QPS_EXCEEDED' && retry < 3) {
+ const waitSec = Number(resp.headers?.['retry-after']) || 1;
+ await this.sleep((waitSec + retry) * 1000);
+ return this.postJson(config, path, jsonBody, retry + 1);
+ }
+ if (resp.status === 429 && errorCode === 'OPEN_API_DAILY_QUOTA_EXHAUSTED') {
+ throw new Error('浠婃棩鎺ュ彛璋冪敤棰濆害宸茬敤瀹�');
+ }
+ if (resp.status === 403 && errorCode === 'OPEN_API_QUOTA_NOT_CONFIGURED') {
+ throw new Error('鑺穬濂楅鏈厤缃垨宸茶繃鏈�');
+ }
+ if (resp.status >= 400) {
+ throw new Error(
+ `HTTP ${resp.status}: ${resp.data?.msg || JSON.stringify(resp.data)}`
+ );
+ }
+ return resp.data;
+ } catch (err) {
+ if (err instanceof AxiosError && !err.response) {
+ throw new Error(`鑺穬鎺ュ彛缃戠粶寮傚父: ${err.message}`);
+ }
+ throw err;
+ }
+ }
+
+ /**
+ * 鎸夎姱璺冩枃妗g敓鎴� HMAC-SHA256 绛惧悕璇锋眰澶�
+ */
+ private buildSignedHeaders(
+ jsonBody: string,
+ appKey: string,
+ appSecret: string
+ ): Record<string, string> {
+ const timestamp = String(Date.now());
+ const nonce = crypto.randomUUID().replace(/-/g, '');
+ const params: Record<string, string> = {
+ 'X-Api-Timestamp': timestamp,
+ 'X-Api-Nonce': nonce,
+ 'X-Api-AppKey': appKey,
+ };
+ if (jsonBody) {
+ params['X-Api-Body'] = jsonBody;
+ }
+
+ const signStr = Object.keys(params)
+ .sort()
+ .filter(key => params[key] != null && params[key] !== '')
+ .map(key => `${key}=${params[key]}`)
+ .join('&');
+
+ const signature = crypto
+ .createHmac('sha256', appSecret)
+ .update(signStr)
+ .digest('base64');
+
+ return {
+ 'X-Api-Timestamp': timestamp,
+ 'X-Api-Nonce': nonce,
+ 'X-Api-AppKey': appKey,
+ 'X-Api-Signature': signature,
+ };
+ }
+
+ /**
+ * 鏍规嵁鐢靛帇浼扮畻鐢甸噺鐧惧垎姣斻��
+ * 鏂囨。宸叉彁渚� batteryPercentage 鏃朵紭鍏堢敤鎺ュ彛鍊硷紱缂哄け鏃舵寜甯歌閾呴吀/閿傜數缁勭嚎鎬т及绠椼��
+ * 12V锛�10.5V~12.7V锛�48V锛�42V~54V锛�60V锛�52V~67V锛�72V锛�63V~84V銆�
+ */
+ estimateBatteryPercent(voltage: number): number | null {
+ if (!Number.isFinite(voltage) || voltage <= 0) {
+ return null;
+ }
+ let minV = 10.5;
+ let maxV = 12.7;
+ if (voltage > 16 && voltage <= 54) {
+ minV = 42;
+ maxV = 54;
+ } else if (voltage > 54 && voltage <= 70) {
+ minV = 52;
+ maxV = 67;
+ } else if (voltage > 70) {
+ minV = 63;
+ maxV = 84;
+ }
+ const percent = ((voltage - minV) / (maxV - minV)) * 100;
+ return Math.max(0, Math.min(100, Math.round(percent)));
+ }
+
+ /**
+ * BD09锛堣姱璺冭繑鍥烇級杞� WGS84锛屼笌钀ょ煶浜戣溅杈咷PS鍧愭爣浣撶郴缁熶竴
+ */
+ private bd09ToWgs84(bdLon: number, bdLat: number): { longitude: number; latitude: number } {
+ const xPi = (Math.PI * 3000.0) / 180.0;
+ const x = bdLon - 0.0065;
+ const y = bdLat - 0.006;
+ const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * xPi);
+ const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * xPi);
+ const gcjLon = z * Math.cos(theta);
+ const gcjLat = z * Math.sin(theta);
+
+ const d = this.gcj02Delta(gcjLon, gcjLat);
+ return {
+ longitude: Number((gcjLon - d.lon).toFixed(6)),
+ latitude: Number((gcjLat - d.lat).toFixed(6)),
+ };
+ }
+
+ private gcj02Delta(lon: number, lat: number): { lon: number; lat: number } {
+ const a = 6378245.0;
+ const ee = 0.00669342162296594323;
+ const dLat = this.transformLat(lon - 105.0, lat - 35.0);
+ const dLon = this.transformLon(lon - 105.0, lat - 35.0);
+ const radLat = (lat / 180.0) * Math.PI;
+ const magic = 1 - ee * Math.sin(radLat) * Math.sin(radLat);
+ const sqrtMagic = Math.sqrt(magic);
+ return {
+ lat: (dLat * 180.0) / (((a * (1 - ee)) / (magic * sqrtMagic)) * Math.PI),
+ lon: (dLon * 180.0) / ((a / sqrtMagic) * Math.cos(radLat) * Math.PI),
+ };
+ }
+
+ private transformLat(x: number, y: number): number {
+ let ret =
+ -100.0 +
+ 2.0 * x +
+ 3.0 * y +
+ 0.2 * y * y +
+ 0.1 * x * y +
+ 0.2 * Math.sqrt(Math.abs(x));
+ ret +=
+ ((20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0) / 3.0;
+ ret +=
+ ((20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin((y / 3.0) * Math.PI)) * 2.0) / 3.0;
+ ret +=
+ ((160.0 * Math.sin((y / 12.0) * Math.PI) + 320 * Math.sin((y * Math.PI) / 30.0)) * 2.0) /
+ 3.0;
+ return ret;
+ }
+
+ private transformLon(x: number, y: number): number {
+ let ret =
+ 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
+ ret +=
+ ((20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0) / 3.0;
+ ret +=
+ ((20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin((x / 3.0) * Math.PI)) * 2.0) / 3.0;
+ ret +=
+ ((150.0 * Math.sin((x / 12.0) * Math.PI) + 300.0 * Math.sin((x / 30.0) * Math.PI)) * 2.0) /
+ 3.0;
+ return ret;
+ }
+
+ private async loadApiConfig(): Promise<XinYueApiConfig> {
+ const baseUrl = await this.readParamString('xinYue.baseUrl', 'https://open-api.yznapp.com');
+ const appKey = await this.readParamString('xinYue.appKey', '');
+ const appSecret = await this.readParamString('xinYue.appSecret', '');
+ const iotTypeCode = await this.readParamString('xinYue.iotTypeCode', '涓夎疆杞﹀畾浣�');
+ const requestDelayMs = await this.readParamNumber('xinYue.requestDelayMs', 400);
+
+ if (!appKey || !appSecret) {
+ throw new Error('鑺穬 KEY/绉橀挜鏈厤缃紝璇峰湪銆愮郴缁熷弬鏁般�戜腑缁存姢 xinYue.appKey / xinYue.appSecret');
+ }
+ return { baseUrl, appKey, appSecret, iotTypeCode, requestDelayMs };
+ }
+
+ /**
+ * 棣栨杩愯鏃跺啓鍏ョ郴缁熷弬鏁帮紝宸插瓨鍦ㄥ垯涓嶈鐩�
+ */
+ private async ensureSysParams() {
+ await this.migrateOldXyParams();
+
+ const defaults: Array<{
+ keyName: string;
+ name: string;
+ data: string;
+ remark: string;
+ }> = [
+ {
+ keyName: 'xinYue.baseUrl',
+ name: '鑺穬寮�鏀炬帴鍙e煙鍚�',
+ data: 'https://open-api.yznapp.com',
+ remark: '瀵瑰簲鏂囨。 api.example.com锛屾寮忕幆澧冧负 open-api.yznapp.com',
+ },
+ {
+ keyName: 'xinYue.portalUrl',
+ name: '鑺穬骞冲彴闂ㄦ埛鍦板潃',
+ data: 'https://xy-gnss.com',
+ remark: '鑺穬 GNSS 绠$悊鍚庡彴鍦板潃',
+ },
+ {
+ keyName: 'xinYue.appKey',
+ name: '鑺穬寮�鏀惧钩鍙癒EY',
+ data: 'JM7JX2yCxcjmOkYt',
+ remark: '璇锋眰澶� X-Api-AppKey',
+ },
+ {
+ keyName: 'xinYue.appSecret',
+ name: '鑺穬寮�鏀惧钩鍙扮閽�',
+ data: '3CXm9ZD7JC1iBHaxnA1bBswL95dVI2Ip',
+ remark: 'HMAC-SHA256 绛惧悕绉橀挜',
+ },
+ {
+ keyName: 'xinYue.account',
+ name: '鑺穬骞冲彴璐﹀彿',
+ data: '浜敵绉戞妧',
+ remark: '闂ㄦ埛鐧诲綍璐﹀彿锛屽紑鏀炬帴鍙i壌鏉冧笉浣跨敤',
+ },
+ {
+ keyName: 'xinYue.password',
+ name: '鑺穬骞冲彴瀵嗙爜',
+ data: '123456',
+ remark: '闂ㄦ埛鐧诲綍瀵嗙爜锛屽紑鏀炬帴鍙i壌鏉冧笉浣跨敤',
+ },
+ {
+ keyName: 'xinYue.whitelistIp',
+ name: '鑺穬鎺ュ彛鐧藉悕鍗旾P',
+ data: '115.159.85.154',
+ remark: '璋冪敤鏂瑰叕缃慖P锛岄渶鍦ㄨ姱璺冨悗鍙伴厤缃櫧鍚嶅崟',
+ },
+ {
+ keyName: 'xinYue.iotTypeCode',
+ name: '鑺穬鍚屾缁堢绫诲瀷',
+ data: '涓夎疆杞﹀畾浣�',
+ remark: '浠� t_basicdata_iot.iotTypeCode 绛涢�夎澶囷紝iotCode 涓鸿澶囧彿/IMEI',
+ },
+ {
+ keyName: 'xinYue.syncIntervalSeconds',
+ name: '鑺穬浣嶇疆鐢靛帇鍚屾鍛ㄦ湡(绉�)',
+ data: '300',
+ remark: '瀹氭椂鎷夊彇浣嶇疆鍜岀數鍘嬬殑鏈�灏忛棿闅旓紝寤鸿鍚庡彴浠诲姟 cron 涓嶅ぇ浜庤鍊笺�傞粯璁�300绉�',
+ },
+ {
+ keyName: 'xinYue.requestDelayMs',
+ name: '鑺穬鎺ュ彛璇锋眰闂撮殧(姣)',
+ data: '400',
+ remark: '閫愬彴鏌ヨ璁惧鏃剁殑闂撮殧锛岄伩鍏嶈Е鍙� QPS 闄愭祦',
+ },
+ ];
+
+ for (const item of defaults) {
+ const exist = await this.baseSysParamEntity.findOneBy({ keyName: item.keyName });
+ if (!exist) {
+ await this.baseSysParamEntity.save({
+ keyName: item.keyName,
+ name: item.name,
+ data: item.data,
+ dataType: 0,
+ remark: item.remark,
+ });
+ }
+ }
+ }
+
+ /**
+ * 灏嗘棫鐗� xy.* 绯荤粺鍙傛暟閿縼绉讳负 xinYue.*
+ */
+ private async migrateOldXyParams() {
+ for (const item of PARAM_KEY_MAP) {
+ const oldRow = await this.baseSysParamEntity.findOneBy({ keyName: item.oldKey });
+ if (!oldRow) {
+ continue;
+ }
+ const newRow = await this.baseSysParamEntity.findOneBy({ keyName: item.newKey });
+ if (newRow) {
+ await this.baseSysParamEntity.delete(oldRow.id);
+ continue;
+ }
+ oldRow.keyName = item.newKey;
+ await this.baseSysParamEntity.save(oldRow);
+ }
+ }
+
+ /**
+ * 纭繚瀛楀吀銆岀粓绔被鍨嬨�嶄腑鏈夈�屼笁杞溅瀹氫綅銆嶏紝鏂逛究鍦� iot 鍩虹鏁版嵁閲岄�夋嫨
+ */
+ private async ensureIotTypeDict() {
+ const dictType = await this.dictTypeEntity.findOneBy({ key: 'iottype' });
+ if (!dictType) {
+ return;
+ }
+ const exist = await this.dictInfoEntity.findOneBy({
+ typeId: dictType.id,
+ value: '涓夎疆杞﹀畾浣�',
+ });
+ if (exist) {
+ return;
+ }
+ const maxOrder = await this.dictInfoEntity
+ .createQueryBuilder('a')
+ .select('MAX(a.orderNum)', 'maxOrder')
+ .where('a.typeId = :typeId', { typeId: dictType.id })
+ .getRawOne();
+ await this.dictInfoEntity.save({
+ typeId: dictType.id,
+ name: '涓夎疆杞﹀畾浣�',
+ value: '涓夎疆杞﹀畾浣�',
+ orderNum: (Number(maxOrder?.maxOrder) || 0) + 1,
+ remark: '鑺穬4G杞﹁締瀹氫綅鍣�',
+ });
+ }
+
+ private async readParamString(keyName: string, defaultValue: string): Promise<string> {
+ const row = await this.baseSysParamEntity.findOneBy({ keyName });
+ const value = row?.data == null ? '' : String(row.data).trim();
+ return value || defaultValue;
+ }
+
+ private async readParamNumber(keyName: string, defaultValue: number): Promise<number> {
+ const raw = await this.readParamString(keyName, String(defaultValue));
+ const num = Number(raw);
+ return Number.isFinite(num) ? num : defaultValue;
+ }
+
+ private toNumber(value: any): number | null {
+ if (value == null || value === '') {
+ return null;
+ }
+ const num = Number(value);
+ return Number.isFinite(num) ? num : null;
+ }
+
+ private toInt(value: any): number | null {
+ const num = this.toNumber(value);
+ return num == null ? null : Math.round(num);
+ }
+
+ private parseDate(value: string): Date | null {
+ if (!value) {
+ return null;
+ }
+ const date = new Date(String(value).replace(' ', 'T'));
+ return Number.isNaN(date.getTime()) ? null : date;
+ }
+
+ private sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+ }
+
+ private stringifyError(error: any): string {
+ if (error instanceof Error) {
+ return error.stack || error.message;
+ }
+ if (typeof error === 'string') {
+ return error;
+ }
+ try {
+ return JSON.stringify(error);
+ } catch {
+ return String(error);
+ }
+ }
+}
diff --git a/src/modules/task/db.json b/src/modules/task/db.json
index 43d95a0..66bda0c 100644
--- a/src/modules/task/db.json
+++ b/src/modules/task/db.json
@@ -35,6 +35,24 @@
"type": 1,
"nextRunTime": null,
"taskType": 0
+ },
+ {
+ "id": 3,
+ "jobId": null,
+ "repeatConf": null,
+ "name": "鑺穬涓夎疆杞﹀畾浣嶅悓姝�",
+ "cron": "0 * * * * *",
+ "limit": null,
+ "every": null,
+ "remark": "姣忓垎閽熷皾璇曞悓姝ワ紝瀹為檯闂撮殧鐢辩郴缁熷弬鏁� xinYue.syncIntervalSeconds 鎺у埗锛岄粯璁�300绉�",
+ "status": 0,
+ "startDate": null,
+ "endDate": null,
+ "data": null,
+ "service": "reportTaskService.syncXinYueVehicleGps()",
+ "type": 1,
+ "nextRunTime": null,
+ "taskType": 0
}
]
}
\ No newline at end of file
--
Gitblit v1.9.1