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