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