wangzhibo
2026-09-18 07b344e51076a0307c1ac1be52d59436abb32fbf
添加了 从芯跃智联 api获取定位数据和电压
7个文件已修改
2个文件已添加
890 ■■■■■ 已修改文件
src/modules/base/db.json 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/basicdata/entity/iot.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/entity/vehiclegps.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/controller/admin/dailysorter.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/entity/joblog.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/ExecutorXinYueVehicleGps.ts 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/taskservice.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/taskservicexinyuegps.ts 615 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/task/db.json 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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": "芯跃开放接口域名",
          "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": "芯跃开放平台KEY",
          "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": "门户登录账号,开放接口鉴权不使用"
      },
      {
          "keyName": "xinYue.password",
          "name": "芯跃平台密码",
          "data": "123456",
          "dataType": 0,
          "remark": "门户登录密码,开放接口鉴权不使用"
      },
      {
          "keyName": "xinYue.whitelistIp",
          "name": "芯跃接口白名单IP",
          "data": "115.159.85.154",
          "dataType": 0,
          "remark": "调用方公网IP,需在芯跃后台配置白名单"
      },
      {
          "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": [
src/modules/basicdata/entity/iot.ts
@@ -15,7 +15,7 @@
  iotName: string;
  @Index()
  @Column({ comment: '终端类型' })    // 电子秤 固定监控 车载监控 车载GPS 司机异常 监控手环
  @Column({ comment: '终端类型' })    // 电子秤 固定监控 车载监控 车载GPS 司机异常 监控手环 三轮车定位
  iotTypeCode: string;
  
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;
}
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[][] = [];
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()
src/modules/report/service/ExecutorXinYueVehicleGps.ts
New file
@@ -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);
    }
  }
}
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';
  }
  /**
   * 芯跃三轮车定位:定时拉取位置和电压
   * 后台任务管理配置:reportTaskService.syncXinYueVehicleGps()
   * 建议 cron 不大于系统参数 xinYue.syncIntervalSeconds(默认300秒),例如每分钟:0 * * * * *
   */
  async syncXinYueVehicleGps() {
    await this.ensureXinYueGpsJobInstance();
    await this.executorXinYueVehicleGps.recoverStuckJobs();
    await this.executorXinYueVehicleGps.execute();
    return '任务执行:芯跃三轮车定位同步';
  }
  /**
@@ -101,5 +117,39 @@
    }
  }
  /**
   * 初始化当天的芯跃定位同步任务,SUCCESS 洗回 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) {
      // 并发写入时可能触发唯一索引冲突,直接忽略即可
    }
  }
}
src/modules/report/service/taskservicexinyuegps.ts
New file
@@ -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';
/**
 * 芯跃开放接口配置
 */
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 并写入车辆GPS表
 */
@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} 缺少设备号iotCode`);
        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 || '查询设备信息失败'}(code: ${payload?.code})`
      );
    }
    return payload?.data || null;
  }
  /**
   * 带签名的 POST JSON 请求,QPS 超限时按文档退避重试
   */
  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;
    }
  }
  /**
   * 按芯跃文档生成 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,与萤石云车辆GPS坐标体系统一
   */
  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: '芯跃开放接口域名',
        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: '芯跃开放平台KEY',
        data: 'JM7JX2yCxcjmOkYt',
        remark: '请求头 X-Api-AppKey',
      },
      {
        keyName: 'xinYue.appSecret',
        name: '芯跃开放平台秘钥',
        data: '3CXm9ZD7JC1iBHaxnA1bBswL95dVI2Ip',
        remark: 'HMAC-SHA256 签名秘钥',
      },
      {
        keyName: 'xinYue.account',
        name: '芯跃平台账号',
        data: '京申科技',
        remark: '门户登录账号,开放接口鉴权不使用',
      },
      {
        keyName: 'xinYue.password',
        name: '芯跃平台密码',
        data: '123456',
        remark: '门户登录密码,开放接口鉴权不使用',
      },
      {
        keyName: 'xinYue.whitelistIp',
        name: '芯跃接口白名单IP',
        data: '115.159.85.154',
        remark: '调用方公网IP,需在芯跃后台配置白名单',
      },
      {
        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);
    }
  }
}
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
        }
    ]
}