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);
|
}
|
}
|
}
|