唯杰地图 logo唯杰地图
VJMAP
EN

vjmap.js

vjmap-service/sdk/nodejs/vjmap.js 44.8 KB1237 行
原文
/* eslint-disable */
/**
 * vjmap Node.js SDK(单文件版)。
 *
 * 要求:Node.js ≥ 18(使用内置 fetch / FormData / Blob)。零外部依赖。
 *
 *
 * 使用:
 *
 *   const { Vjmap } = require('./vjmap');
 *   const svc = new Vjmap('http://127.0.0.1:27660/api/v1', '<jwt-token>');
 *   console.log(await svc.heart());
 *   console.log(await svc.version());
 *
 * 设计:
 *   - 方法名 camelCase,与官方 JS SDK(vjmap 包的 Service)对齐;参数名也对齐。
 *   - HTTP 非 2xx → VjmapError { status, url, body }。
 *   - web服务 {code,msg,data} 响应自动 unwrap 成 data;图形服务 JSON 原样返回。
 */
'use strict';

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

// ---------------------------------------------------------------------------
// 工具函数
// ---------------------------------------------------------------------------

/** 32 位小写 MD5 hex。 */
function md5Hex(input) {
  return crypto.createHash('md5').update(input).digest('hex');
}

/**
 * 明文密码 → 16 位 secretKey。
 * 规则:连续 4 次 md5 后取前 16;空串 → 空串;`ak` 开头 18 位 → 原样返回。
 */
function computeSecretKey(pwd) {
  if (!pwd) return '';
  if (pwd.length === 18 && pwd.slice(0, 2) === 'ak') return pwd;
  let s = pwd;
  for (let i = 0; i < 4; i++) s = md5Hex(s);
  return s.slice(0, 16);
}

/** 流式计算文件 MD5。支持路径/Buffer/Readable。 */
async function fileMd5(src) {
  if (Buffer.isBuffer(src)) return md5Hex(src);
  if (typeof src === 'string') {
    return await new Promise((resolve, reject) => {
      const hash = crypto.createHash('md5');
      const stream = fs.createReadStream(src);
      stream.on('data', (chunk) => hash.update(chunk));
      stream.on('end', () => resolve(hash.digest('hex')));
      stream.on('error', reject);
    });
  }
  if (src && typeof src.on === 'function') {
    return await new Promise((resolve, reject) => {
      const hash = crypto.createHash('md5');
      src.on('data', (chunk) => hash.update(chunk));
      src.on('end', () => resolve(hash.digest('hex')));
      src.on('error', reject);
    });
  }
  throw new TypeError('fileMd5: unsupported source type');
}

function normalizeBase(base) {
  if (!base) throw new Error('base_url is required');
  return base.endsWith('/') ? base : base + '/';
}

/** 简化版 URL-encode query(数字/布尔转字符串,undefined/null 略过)。 */
function encodeQuery(params) {
  const parts = [];
  for (const [k, v] of Object.entries(params || {})) {
    if (v === undefined || v === null) continue;
    const val = typeof v === 'boolean' ? String(v) : String(v);
    parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(val)}`);
  }
  return parts.join('&');
}

// ---------------------------------------------------------------------------
// 异常
// ---------------------------------------------------------------------------

class VjmapError extends Error {
  constructor(message, { status = null, url = null, body = null } = {}) {
    super(message);
    this.name = 'VjmapError';
    this.status = status;
    this.url = url;
    this.body = body;
  }
}

// ---------------------------------------------------------------------------
// MapOpenWay 常量(仅客户端枚举;后端不读,openMap 换算成 body 布尔 geom = mapopenway !== 'Memory')
// ---------------------------------------------------------------------------

const MapOpenWay = Object.freeze({
  Memory: 'Memory',
  GeomRender: 'GeomRender',
});

// ---------------------------------------------------------------------------
// Vjmap 主类
// ---------------------------------------------------------------------------

class Vjmap {
  /**
   * @param {string} baseUrl 例如 'http://127.0.0.1:27660/api/v1'
   * @param {string} token JWT Token
   * @param {object} [opts]
   * @param {number} [opts.timeout=60000] 请求超时 (ms)
   */
  constructor(baseUrl, token = '', opts = {}) {
    this.baseUrl = normalizeBase(baseUrl);
    this.token = token || '';
    this.timeout = opts.timeout || 60000;
    this._curMap = { mapid: '', version: '', layer: '', fileid: '' };
    // 缓存的图密钥:发请求时按形态拆头——18 位 `ak` 开头 → `accessKey` 头,其余 → `secretKey` 头
    this._secretKeys = [];
    // 始终走 `accessKey` 头的密钥(如服务端 config `map_super_key`,后端只在 accessKey 列表里认它)
    this._accessKeys = [];
    this._workspace = '';
  }

  // --- 上下文 -----------------------------------------------------------

  get workspace() {
    return this._workspace;
  }

  switchWorkspace(name = '') {
    this._workspace = name || '';
  }

  currentMapParam() {
    return { ...this._curMap };
  }

  setCurrentMap({ mapid, version = 'v1', layer = '', fileid = '' }) {
    this._curMap = {
      mapid: mapid || '',
      version: version || 'v1',
      layer: layer || '',
      fileid: fileid || mapid || '',
    };
  }

  // --- URL 构造 ---------------------------------------------------------

  _mapPrefix() {
    if (this._workspace) return `workspace/${encodeURIComponent(this._workspace)}/`;
    return 'map/';
  }

  serviceUrl(subPath, params) {
    const q = { ...(params || {}), token: (params && params.token) || this.token };
    const qs = encodeQuery(q);
    return `${this.baseUrl}service/${String(subPath).replace(/^\//, '')}${qs ? '?' + qs : ''}`;
  }

  mapUrl(subPath, params) {
    const q = { ...(params || {}), token: (params && params.token) || this.token };
    const qs = encodeQuery(q);
    return `${this.baseUrl}${this._mapPrefix()}${String(subPath).replace(/^\//, '')}${qs ? '?' + qs : ''}`;
  }

  baseGateway() {
    const u = new URL(this.baseUrl);
    // 仅去掉末尾 /api/vN 段,保留部署前缀(公有云是 /server,私有化是 /)
    const p = u.pathname.replace(/\/api\/v\d+\/?$/i, "/");
    return `${u.protocol}//${u.host}${p.startsWith("/") ? p : "/" + p}`;
  }

  gatewayUrl(subPath, params) {
    const q = { ...(params || {}), token: (params && params.token) || this.token };
    const qs = encodeQuery(q);
    return `${this.baseGateway()}${String(subPath).replace(/^\//, '')}${qs ? '?' + qs : ''}`;
  }

  /** /api/v1/map/cmd/{cmd}/{mapid}/{version}?... */
  cmdUrl(cmd, mapid, version, params) {
    mapid = mapid || this._curMap.mapid || '_';
    version = version || this._curMap.version || '_';
    const sub = `cmd/${encodeURIComponent(cmd)}/${encodeURIComponent(mapid)}/${encodeURIComponent(version)}`;
    return this.mapUrl(sub, params);
  }

  // --- HTTP 核心 --------------------------------------------------------

  async _extractBody(resp) {
    const ctype = (resp.headers.get('content-type') || '').toLowerCase();
    if (ctype.includes('application/json')) {
      try {
        return await resp.json();
      } catch (_) {
        return await resp.text();
      }
    }
    if (ctype.startsWith('text/')) return await resp.text();
    // 图形引擎部分响应 content-type 不规范(octet-stream 里实为 JSON),按首字符嗅探
    const buf = Buffer.from(await resp.arrayBuffer());
    const head = buf.subarray(0, 64).toString('utf8').trimStart();
    if (head.startsWith('{') || head.startsWith('[')) {
      try { return JSON.parse(buf.toString('utf8')); } catch (_) { /* 真二进制 */ }
    }
    return buf;
  }

  _raiseIfError(resp, body) {
    if (resp.ok) return;
    throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`, {
      status: resp.status,
      url: resp.url,
      body,
    });
  }

  _unwrapGateway(body) {
    if (body === null || typeof body !== 'object' || Array.isArray(body)) return body;
    if ('code' in body && 'data' in body && 'msg' in body) {
      if (body.code === 0) return body.data;
      throw new VjmapError(`gateway code=${body.code}: ${body.msg}`, { status: 200, body });
    }
    return body;
  }

  /**
   * 由缓存密钥组装 `secretKey` / `accessKey` 请求头(多个用 `_;_` 连接)。
   * 后端 HavePermissionInMap:accessKey 列表含 map_super_key → admin;secretKey 列表含该图
   * secretKey → admin;accessKey 列表含该图 accessKey → 仅浏览。头部同名键会**覆盖** body 里的值。
   */
  _keyHeaders() {
    const isAk = (k) => typeof k === 'string' && k.length === 18 && k.startsWith('ak');
    const sk = this._secretKeys.filter((k) => k && !isAk(k));
    const ak = [...this._secretKeys.filter(isAk), ...this._accessKeys.filter(Boolean)];
    const h = {};
    if (sk.length) h.secretKey = sk.join('_;_');
    if (ak.length) h.accessKey = ak.join('_;_');
    return h;
  }

  async _request(method, url, opts = {}) {
    const { jsonBody, form, headers, timeout, stream } = opts;
    const hdrs = {};
    if (this.token) hdrs['token'] = this.token;
    Object.assign(hdrs, this._keyHeaders());
    let body;
    if (jsonBody !== undefined) {
      hdrs['Content-Type'] = 'application/json';
      body = JSON.stringify(jsonBody);
    } else if (form) {
      body = form; // FormData instance
    }
    if (headers) Object.assign(hdrs, headers);

    const ctrl = new AbortController();
    const to = setTimeout(() => ctrl.abort(), timeout || this.timeout);
    let resp;
    try {
      resp = await fetch(url, {
        method: method.toUpperCase(),
        headers: hdrs,
        body,
        signal: ctrl.signal,
      });
    } catch (err) {
      clearTimeout(to);
      throw new VjmapError(String(err && err.message || err), { url });
    }
    clearTimeout(to);

    if (stream) return resp; // 调用方自己处理流

    const parsed = await this._extractBody(resp);
    this._raiseIfError(resp, parsed);
    return this._unwrapGateway(parsed);
  }


  async heart() {
    return await this._request('GET', this.gatewayUrl('heart', {}));
  }

  async version() {
    return await this._request('GET', this.gatewayUrl('version', {}));
  }

  async runStatus(detail = true) {
    return await this._request(
      'GET',
      this.cmdUrl('runstatus', '_', 'v1', { detail }),
    );
  }

  async viewLogs(record = 100) {
    return await this._request(
      'GET',
      this.cmdUrl('viewlogs', '_', 'v1', { record }),
    );
  }

  // =========================================================================
  // 分类 1:地图管理
  // =========================================================================

  async checkMapFile(md5) {
    return await this._request('GET', this.mapUrl('mapfile', { md5 }));
  }

  /**
   * 上传地图源文件(multipart)。
   * @param {string|Buffer|{filename:string, content:Buffer}} fileArg
   * @param {object} [opts]
   * @param {string} [opts.filename]
   * @param {string} [opts.md5]
   * @param {object} [opts.extraFields]
   * @returns {Promise<{fileid:string, mapid:string, uploadname:string, version?:string, geom?:boolean, maptype?:string, error?:string}>}
   *   后端只取 multipart 里的文件部分(md5/extraFields 等表单字段不读),按内容自算 md5 命名。
   */
  async uploadMap(fileArg, opts = {}) {
    let name, content;
    if (typeof fileArg === 'string') {
      name = opts.filename || path.basename(fileArg);
      content = fs.readFileSync(fileArg);
    } else if (Buffer.isBuffer(fileArg)) {
      if (!opts.filename) throw new Error('uploadMap: Buffer 模式必须传 opts.filename');
      name = opts.filename;
      content = fileArg;
    } else if (fileArg && fileArg.content) {
      name = fileArg.filename || opts.filename || 'upload.bin';
      content = fileArg.content;
    } else {
      throw new TypeError('uploadMap: fileArg 必须是路径/Buffer/{filename,content}');
    }
    const md5 = opts.md5 || md5Hex(content);
    const fd = new FormData();
    fd.append('md5', md5);
    if (opts.extraFields) {
      for (const [k, v] of Object.entries(opts.extraFields)) {
        fd.append(k, String(v));
      }
    }
    fd.append('file', new Blob([content], { type: 'application/octet-stream' }), name);
    return await this._request('POST', this.mapUrl('uploads', {}), { form: fd });
  }

  /**
   * 打开地图。
   * 后端不读 `mapopenway`,只认布尔 `geom`(缺省 false=内存模式);SDK 按官方 JS SDK 的做法换算
   * `geom = mapopenway !== 'Memory'`。GeomRender 首次打开是异步的:响应 `{status:"working"}`,
   * 需 `waitMapOpenFinish()` 轮询到 finish 后再打开一次才有完整参数。
   * @param {object} args
   * @param {string} args.mapid
   * @param {string} [args.version='v1']
   * @param {'Memory'|'GeomRender'} [args.mapopenway='GeomRender']
   * @param {string} [args.fileid]
   * @param {object} [args.style]
   * @param {string} [args.secretKey] 显式给出时本次请求的 `secretKey` 头只带它(头会覆盖 body 同名键)
   * @param {string} [args.accessKey] 同上,`accessKey` 头只带它
   * @param {any} [args.mapbounds]
   * @param {any} [args.mapLayerIndex]
   * @param {any} [args.layers]
   * @param {string} [args.useMethod='POST']
   */
  async openMap(args) {
    const {
      mapid,
      version = 'v1',
      mapopenway = MapOpenWay.GeomRender,
      fileid,
      style,
      secretKey,
      accessKey,
      mapbounds,
      mapLayerIndex,
      layers,
      useMethod = 'POST',
      ...extra
    } = args || {};
    if (!mapid) throw new Error('openMap: mapid 必填');
    const body = { mapid, version, geom: mapopenway !== MapOpenWay.Memory };
    if (fileid) body.fileid = fileid;
    if (style !== undefined) body.style = style;
    if (secretKey) body.secretKey = secretKey;
    if (accessKey) body.accessKey = accessKey;
    if (mapbounds !== undefined) body.mapbounds = mapbounds;
    if (mapLayerIndex !== undefined) body.mapLayerIndex = mapLayerIndex;
    if (layers !== undefined) body.layers = layers;
    for (const [k, v] of Object.entries(extra)) if (v !== undefined) body[k] = v;
    const headers = {};
    if (secretKey) headers.secretKey = secretKey;
    if (accessKey) headers.accessKey = accessKey;
    const url = this.mapUrl(`openmap/${encodeURIComponent(mapid)}`, { version });
    const res = await this._request(useMethod, url, { jsonBody: body, headers });
    if (res && typeof res === 'object' && res.mapid) {
      this.setCurrentMap({
        mapid: res.mapid,
        version: res.version || version,
        layer: res.layer || '',
        fileid: res.fileid || fileid || mapid,
      });
    }
    return res;
  }

  /**
   * 更新地图:body 里给**新** fileid 时为已有 mapid 新建版本 vN+1(可带 deleteOldVersion:true
   * 删旧版本);fileid 为空或与当前版本相同则按原样重开。与 openMap 同样以 `geom` 决定打开方式
   * (body 未给 `geom` 时按 `mapopenway`,缺省 GeomRender → geom:true)。
   */
  async updateMap(mapid, version = 'v1', body = {}) {
    const { mapopenway, ...rest } = body || {};
    const jsonBody = { geom: mapopenway !== MapOpenWay.Memory, ...rest };
    const url = this.mapUrl(`updatemap/${encodeURIComponent(mapid)}`, { version });
    return await this._request('POST', url, { jsonBody });
  }

  /**
   * 轮询 `cmd/listmaps/{mapid}/{version}` 直到该版本 status 为 finish(或 error/runerror)。
   * 对应官方 JS SDK 的 waitMapOpenFinish;GeomRender 首次打开返回 status:"working" 后使用。
   * @returns {Promise<{status:'finish'|'error', version?:string, error?:string}>}
   */
  async waitMapOpenFinish(mapid, version, { interval = 2000, maxTries = 150 } = {}) {
    for (let i = 0; i < maxTries; i++) {
      const res = await this._request('GET', this.cmdUrl('listmaps', mapid, version || '_'));
      const vers = (res && res[mapid]) || [];
      const item = vers.find((v) => !version || version === '_' || v.version === version) || vers[0];
      if (item) {
        if (item.status === 'finish') return { status: 'finish', version: item.version };
        if (item.status === 'error' || item.status === 'runerror') {
          return { status: 'error', version: item.version, error: item.errmsg || item.status };
        }
      }
      await new Promise((r) => setTimeout(r, interval));
    }
    return { status: 'error', error: 'try max times' };
  }

  async closeMap(mapid, version = 'v1') {
    // 对无 body POST 会回 500 param error,SDK 统一走 GET
    return await this._request('GET', this.cmdUrl('closemap', mapid, version));
  }

  // 注意:后端实际读的是 deleteDwgFile(true=同时删除源文件);
  // 默认(false)时是否删源文件取决于服务端配置 noAutoDeleteDwgFile。
  // 旧版 SDK 的 keepSource 参数后端从未消费过,已移除。
  async deleteMap(mapid, version = 'v1', { deleteDwgFile = false } = {}) {
    const params = {};
    if (deleteDwgFile) params.deleteDwgFile = true;
    return await this._request(
      'GET',
      this.cmdUrl('deletemap', mapid, version, params),
    );
  }

  async renameMap(oldMapid, newMapid) {
    return await this._request(
      'GET',
      this.cmdUrl('renamemap', oldMapid, '_', { newMapId: newMapid }), // 后端读 newMapId(驼峰)
    );
  }

  async deleteMapFile(filenames) {
    const files = Array.isArray(filenames) ? filenames.join(';') : String(filenames);
    return await this._request(
      'GET',
      this.cmdUrl('deletemapfile', '_', 'v1', { files }),
    );
  }

  // 后端实际参数:curPage / pageCount / mapIds(数组,无关键字搜索)。
  // mapIds 由 toStringList() 读取,必须放 JSON body,故有参时走 POST。
  // 旧版 SDK 的 keyword/page/pageSize 后端从未消费过,已移除。
  async listMaps({ curPage, pageCount, mapIds } = {}) {
    const body = {};
    if (curPage !== undefined) body.curPage = curPage;
    if (pageCount !== undefined) body.pageCount = pageCount;
    if (mapIds !== undefined) {
      body.mapIds = Array.isArray(mapIds) ? mapIds : [String(mapIds)];
    }
    if (Object.keys(body).length === 0) {
      return await this._request('GET', this.cmdUrl('listmaps', '_', 'v1'));
    }
    return await this._request(
      'POST',
      this.cmdUrl('listmaps', '_', 'v1'),
      { jsonBody: body },
    );
  }

  // 注:cmdMetadata 只解析路径 mapid/version,不读任何业务参数;
  // 旧版的 enableEditMeta / fileid 均无消费点,已移除。
  async metadata(mapid, version) {
    return await this._request(
      'GET',
      this.cmdUrl('metadata', mapid || '', version || ''),
    );
  }

  async updateMetadata(mapid, version = 'v1', body = {}) {
    return await this._request(
      'POST',
      this.cmdUrl('updateMetadata', mapid, version),
      { jsonBody: body || {} },
    );
  }

  // 后端实际只消费 width/height/darkTheme(layers/backcolor 无消费点,已移除)。
  thumbnailUrl(mapid, version, { width = 200, height = 200, darkTheme } = {}) {
    const params = { width, height };
    if (darkTheme !== undefined) params.darkTheme = darkTheme;
    return this.cmdUrl('thumbnail', mapid || '', version || '', params);
  }

  async thumbnailBytes(mapid, version, opts = {}) {
    const url = this.thumbnailUrl(mapid, version, opts);
    const resp = await this._request('GET', url, { stream: true });
    if (!resp.ok) {
      throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`, {
        status: resp.status, url: resp.url, body: null,
      });
    }
    return Buffer.from(await resp.arrayBuffer());
  }

  async supportFormat() {
    return await this._request('GET', this.cmdUrl('supportFormat', '_', 'v1'));
  }

  async constData() {
    return await this._request('GET', this.cmdUrl('constData', '_', 'v1'));
  }

  async getDataBounds(mapid, version) {
    return await this._request(
      'GET',
      this.cmdUrl('getDataBounds', mapid || '', version || ''),
    );
  }

  // ==========================================================================
  // 分类 2:瓦片 / 样式(Tiles & Styles)
  // ==========================================================================

  rasterTileUrl({
    mapid, version, layer, fileid,
    z = '{z}', x = '{x}', y = '{y}',
  } = {}) {
    mapid = mapid || this._curMap.mapid || '_';
    version = version || this._curMap.version || 'v1';
    layer = layer || this._curMap.layer || '_';
    fileid = fileid || this._curMap.fileid || '';
    const base = `${this.baseUrl}${this._mapPrefix()}tile/${mapid}/${version}/${layer}/${z}/${x}/${y}`;
    const qs = encodeQuery({ tag: fileid, token: this.token });
    return `${base}?${qs}`;
  }

  vectorTileUrl({
    mapid, version, layer, fileid,
    z = '{z}', x = '{x}', y = '{y}',
  } = {}) {
    mapid = mapid || this._curMap.mapid || '_';
    version = version || this._curMap.version || 'v1';
    layer = layer || this._curMap.layer || 'main';
    fileid = fileid || this._curMap.fileid || '';
    const base = `${this.baseUrl}${this._mapPrefix()}tile/${mapid}/${version}/${layer}/${z}/${x}/${y}.mvt`;
    const qs = encodeQuery({ tag: fileid, token: this.token });
    return `${base}?${qs}`;
  }

  // 后端实际消费:layers(样式名,复数)、backgroundColor("r,g,b[,a]" 串,
  // 仅 transparent=false 时生效,且仅单图 cmd/wms 有效——Go 多图代理
  // 不转发它)、transparent、srs/crs、bbox/BBOX、
  // width/height(或 WIDTH/HEIGHT)、useImageRotate、fourParameter。
  // 旧版参数名 layer/backcolor 后端没有消费点;mvt 仅单图路径支持
  // ({version}.mvt 后缀),/service/wms 无此变体。
  wmsTileUrl({
    mapid, version = 'v1',
    layer, width = 256, height = 256,
    bbox = '{bbox-epsg-3857}', srs = 'EPSG:3857',
    backgroundColor, transparent, mvt = false,
    crs, fourParameter,
  } = {}) {
    let base;
    const qsParams = { srs, width, height, token: this.token };
    if (Array.isArray(mapid)) {
      // 多图:Go 端按 "_," 切分 mapid/version/layers/crs/fourParameter,
      // 且五者长度必须一致,否则返回 "paramter error"。
      if (mvt) {
        throw new VjmapError('/service/wms 不支持 mvt(仅单图 cmd/wms 支持 .mvt 后缀)', {});
      }
      const rep = (v) => (Array.isArray(v) ? v : new Array(mapid.length).fill(v ?? ''));
      for (const [k, v] of [['version', version], ['layer', layer], ['crs', crs], ['fourParameter', fourParameter]]) {
        if (Array.isArray(v) && v.length !== mapid.length) {
          throw new VjmapError(`wmsTileUrl: ${k} 数组长度须与 mapid 一致`, {});
        }
      }
      base = `${this.baseUrl}service/wms`;
      qsParams.mapid = rep(mapid).join('_,');
      qsParams.version = rep(version).join('_,');
      qsParams.layers = rep(layer).join('_,');
      qsParams.crs = rep(crs).join('_,');
      qsParams.fourParameter = rep(fourParameter).join('_,');
    } else {
      const ext = mvt ? '.mvt' : '';
      const m = encodeURIComponent(String(mapid));
      const v = encodeURIComponent(String(version));
      base = `${this.baseUrl}${this._mapPrefix()}cmd/wms/${m}/${v}${ext}`;
      if (layer !== undefined) qsParams.layers = layer;
      if (crs !== undefined) qsParams.crs = crs;
      if (fourParameter !== undefined) qsParams.fourParameter = fourParameter;
    }
    if (backgroundColor !== undefined) qsParams.backgroundColor = backgroundColor;
    if (transparent !== undefined) qsParams.transparent = transparent;
    const qs = encodeQuery(qsParams);
    return `${base}?bbox=${bbox}&${qs}`;
  }

  async createMapStyle({
    mapid, version,
    layeron, layeroff, backcolor, clipbounds,
    expression, lineweight, isGeomLayer = true, name,
  } = {}) {
    const body = { geom: !!isGeomLayer };
    if (name !== undefined) body.stylename = name; // 后端读 stylename,不读 name
    if (layeron !== undefined) {
      body.layeron = Array.isArray(layeron) ? `(${layeron.join(',')})` : layeron;
    }
    if (layeroff !== undefined) {
      body.layeroff = Array.isArray(layeroff) ? `(${layeroff.join(',')})` : layeroff;
    }
    if (backcolor !== undefined) body.backcolor = backcolor;
    if (clipbounds !== undefined) body.clipbounds = clipbounds;
    if (expression !== undefined) body.expression = expression;
    if (lineweight !== undefined) body.lineweight = JSON.stringify(lineweight);
    return await this._request(
      'POST',
      this.cmdUrl('createMapStyle', mapid || '', version || ''),
      { jsonBody: body },
    );
  }

  async cmdSwitchLayers(visibleLayers, {
    expression, darkMode, mapid, version,
  } = {}) {
    const body = { visibleLayer: visibleLayers };
    if (expression !== undefined) body.expression = expression;
    if (darkMode !== undefined) body.darkMode = !!darkMode;
    return await this._request(
      'POST',
      this.cmdUrl('switchlayer', mapid || '', version || ''),
      { jsonBody: body },
    );
  }

  async cmdUpdateStyle({
    name = '', layeron, layeroff, backcolor,
    clipbounds, expression, lineweight,
    mapid, version,
  } = {}) {
    const toLayerStr = (v) => {
      if (v === undefined || v === null) return '';
      if (Array.isArray(v)) return '(' + v.join(',') + ')';
      return String(v);
    };
    const body = {
      stylename: name,
      layeron: toLayerStr(layeron),
      layeroff: toLayerStr(layeroff),
      bkcolor: backcolor !== undefined ? backcolor : '',
      expression: expression || '',
      lineweight: lineweight ? JSON.stringify(lineweight) : '',
      clipbounds: clipbounds === undefined
        ? ''
        : (typeof clipbounds === 'string' ? clipbounds : JSON.stringify(clipbounds)),
    };
    return await this._request(
      'POST',
      this.cmdUrl('updateStyle', mapid || '', version || ''),
      { jsonBody: body },
    );
  }

  async sliceLayer({
    layer, zoom = 0, ismvt = false,
    iscancel = false, isAllCancel = false,
    batchNum, idleBatchSleepMs, busyBatchSleepMs,
    mapid, version,
  } = {}) {
    const body = {
      layer: layer || this._curMap.layer || '',
      zoom,
      ismvt: !!ismvt,
      iscancel: !!iscancel,
      isAllCancel: !!isAllCancel,
    };
    if (batchNum !== undefined) body.batchnum = batchNum;
    if (idleBatchSleepMs !== undefined) body.idle_batch_sleepms = idleBatchSleepMs;
    if (busyBatchSleepMs !== undefined) body.busy_batch_sleepms = busyBatchSleepMs;
    return await this._request(
      'POST',
      this.cmdUrl('slicelayer', mapid || '', version || ''),
      { jsonBody: body },
    );
  }

  async getSliceCacheZoom({ layer, ismvt = false, mapid, version } = {}) {
    const body = {
      layer: layer || this._curMap.layer || '',
      ismvt: !!ismvt,
    };
    return await this._request(
      'POST',
      this.cmdUrl('getSliceCacheZoom', mapid || '', version || ''),
      { jsonBody: body },
    );
  }

  async deleteStyle(stylename, { mapid, version } = {}) {
    return await this._request(
      'POST',
      this.cmdUrl('deletestyle', mapid || '', version || ''),
      { jsonBody: { styleid: stylename } }, // 后端读 styleid(可给 layername 或 style name,* / a* / m* / s* 为通配)
    );
  }

  // ==========================================================================
  // 分类 3:要素查询 / 几何(Query)
  // ==========================================================================

  async queryFeatures({
    querytype,
    mapid, version, layer,
    x, y, pixelsize,
    x1, y1, x2, y2,
    expr, condition, beginpos, bounds,
    zoom = 1, limit, fields, geom,
    simplifyTolerance, useCache, toMapCoordinate,
    maxGeomBytesSize, pixelToGeoLength,
    extra,
  } = {}) {
    if (!querytype) throw new VjmapError('queryFeatures: querytype is required');
    const body = {
      querytype,
      zoom,
      layername: layer || this._curMap.layer || '',
    };
    const put = (k, v) => { if (v !== undefined) body[k] = v; };
    put('x', x); put('y', y); put('pixelsize', pixelsize);
    put('x1', x1); put('y1', y1); put('x2', x2); put('y2', y2);
    put('expr', expr); put('condition', condition);
    put('beginpos', beginpos);
    put('maxReturnCount', limit); put('limit', limit);
    put('fields', fields);
    put('geom', geom); put('simplifyTolerance', simplifyTolerance);
    put('useCache', useCache); put('toMapCoordinate', toMapCoordinate);
    put('maxGeomBytesSize', maxGeomBytesSize);
    put('pixelToGeoLength', pixelToGeoLength);
    // 后端只把 cmdparam.bounds 当**字符串**读(数组会变成空串 → 空间过滤被静默丢弃),且不读 linePts:
    // 点数组 [[x,y],...] → "x,y;x,y"(含 ';' 即折线/多边形查询,首尾相同为闭合);
    // 4 数 [x1,y1,x2,y2] → "[x1,y1,x2,y2]"(后端剥掉方括号);字符串原样透传。
    if (bounds !== undefined && bounds !== null) {
      if (Array.isArray(bounds) && Array.isArray(bounds[0])) {
        body.bounds = bounds.map((p) => `${p[0]},${p[1]}`).join(';');
      } else if (Array.isArray(bounds)) {
        body.bounds = JSON.stringify(bounds);
      } else {
        body.bounds = String(bounds);
      }
    }
    if (extra) Object.assign(body, extra);
    return await this._request(
      'POST',
      this.cmdUrl('queryFeatures', mapid || '', version || ''),
      { jsonBody: body },
    );
  }

  async createEntityGeoData({
    mapdata, mapExtent, renderAccuracy,
    excludeAttribute = true, useZip = true,
  } = {}) {
    const body = {
      mapdata,
      excludeAttribute: !!excludeAttribute,
      useZip: !!useZip,
    };
    if (mapExtent !== undefined) {
      body.mapExtent = typeof mapExtent === 'string' ? mapExtent : JSON.stringify(mapExtent);
    }
    if (renderAccuracy !== undefined) body.renderAccuracy = renderAccuracy;
    return await this._request(
      'POST',
      this.cmdUrl('createEntityGeoData', '_null', 'v1'),
      { jsonBody: body },
    );
  }

  async coordTransform({
    srs, crs, points,
    fourParameter, isInverseFourParameter,
  } = {}) {
    const normOne = (p) => {
      if (p && typeof p === 'object' && !Array.isArray(p) && 'x' in p && 'y' in p) {
        return `${p.x},${p.y}`;
      }
      return `${p[0]},${p[1]}`;
    };
    let pts;
    if (points && !Array.isArray(points) && typeof points === 'object') {
      pts = [normOne(points)];
    } else if (Array.isArray(points) && typeof points[0] === 'number') {
      pts = [normOne(points)];
    } else {
      pts = points.map(normOne);
    }
    const body = { srs, crs, points: pts.join(';') };
    if (fourParameter !== undefined) {
      body.fourParameter = Array.isArray(fourParameter)
        ? fourParameter.join(',') : fourParameter;
    }
    if (isInverseFourParameter === true) body.isInverseFourParamter = true;
    return await this._request(
      'POST',
      this.cmdUrl('coordtransform', '_', '_'),
      { jsonBody: body },
    );
  }

  async prjWktToPrj4Str(wkt, { from = '' } = {}) {
    return await this._request(
      'POST',
      this.cmdUrl('prjWktToPrj4Str', '_', '_'),
      { jsonBody: { wkt, from } },
    );
  }

  // ==========================================================================
  // 分类 4:图形分析(Analysis),统一 pass-through 风格
  // ==========================================================================

  async _postCmdNull(cmd, params = {}) {
    return await this._request(
      'POST',
      this.cmdUrl(cmd, '_null', 'v1'),
      { jsonBody: { ...params } },
    );
  }

  async cmdMapDiff(params = {}) {
    return await this._postCmdNull('mapDiff', params);
  }

  async cmdMapCompare(params = {}) {
    return await this._postCmdNull('mapCompare', params);
  }

  async cmdExtractTable(params = {}) {
    return await this._postCmdNull('extractTable', params);
  }

  async cmdExtractCenterLines(params = {}) {
    return await this._postCmdNull('extractCenterLines', params);
  }

  async cmdMatchObject(params = {}) {
    return await this._postCmdNull('objectMatch', params);
  }

  async cmdImageSvgToDwg(params = {}) {
    return await this._postCmdNull('imageSvgToDwg', params);
  }

  // ==========================================================================
  // 分类 5:组合 / 拆分 / 导出
  // ==========================================================================

  async cmdComposeNewMap(params = {}) {
    return await this._postCmdNull('composeNewMap', params);
  }

  async cmdSplitMap(params = {}) {
    return await this._postCmdNull('cmdSplitMap', params);
  }

  async cmdExportLayout(params = {}) {
    return await this._postCmdNull('exportLayout', params);
  }

  /**
   * cmd 路由对**空 JSON body 的 POST** 直接回 500 "param error",而 GET 不校验。
   * 参数为空时改走 GET(无 query),否则 POST JSON。
   */
  async _cmdPostOrGet(cmd, mapid, version, params = {}) {
    const url = this.cmdUrl(cmd, mapid || '', version || '');
    if (Object.keys(params).length === 0) return await this._request('GET', url);
    return await this._request('POST', url, { jsonBody: { ...params } });
  }

  async cmdExportPdf(params = {}) {
    const { mapid, version, ...rest } = params;
    return await this._cmdPostOrGet('exportPdf', mapid, version, rest);
  }

  async cmdExportDxf(params = {}) {
    const { mapid, version, ...rest } = params;
    return await this._cmdPostOrGet('exportDxf', mapid, version, rest);
  }

  async cmdSaveOfflineTileDb(params = {}) {
    return await this._postCmdNull('saveOfflineTileDb', params);
  }

  // ==========================================================================
  // 分类 6:WebCAD(cmd 始终注册;/service/webcad 是前端 license 握手,不是启用探针)
  // ==========================================================================

  async listWebcadDraws() {
    return await this._request('GET', this.cmdUrl('listWebcadDraws', '_', 'v1'));
  }

  async getWebcadData(mapid, version = 'v1', params = {}) {
    return await this._request(
      'POST',
      this.cmdUrl('getWebcadData', mapid, version),
      { jsonBody: { ...params } },
    );
  }

  async saveWebcadPatch(mapid, version = 'v1', params = {}) {
    return await this._request(
      'POST',
      this.cmdUrl('saveWebcadPatch', mapid, version),
      { jsonBody: { ...params } },
    );
  }

  async deleteWebcadDraw(mapid, version = 'v1', params = {}) {
    return await this._cmdPostOrGet('deleteWebcadDraw', mapid, version, params);
  }

  async convertWebcad(mapid, version = 'v1', params = {}) {
    return await this._cmdPostOrGet('convertWebCAD', mapid, version, params);
  }

  async exportWebcad(params = {}) {
    return await this._postCmdNull('exportWebCAD', params);
  }

  // ==========================================================================
  // 分类 7:工作区
  // ==========================================================================

  async getWorkspaces() {
    return await this._request('GET', this.serviceUrl('getWorkspace'));
  }

  async workspaceCreate({
    name, alias = '', workDir = '',
    isPublic = false, disableFullSearch = false,
  } = {}) {
    return await this._request(
      'POST',
      this.serviceUrl('workspaceCreate'),
      { jsonBody: { name, alias, workDir, isPublic: !!isPublic, disableFullSearch: !!disableFullSearch } },
    );
  }

  async workspaceModify(workspace = {}) {
    return await this._request(
      'POST',
      this.serviceUrl('workspaceModify'),
      { jsonBody: { ...workspace } },
    );
  }

  async workspaceDelete(name) {
    const url = this.serviceUrl(`workspaceDelete/${encodeURIComponent(name)}`);
    return await this._request('DELETE', url);
  }

  async workspaceStatus() {
    return await this._request('GET', this.serviceUrl('workspaceStatus'));
  }

  // ==========================================================================
  // 分类 8:KV 键值存储
  // ==========================================================================

  async getCustomData(key, { retDataType, contentType } = {}) {
    const keySeg = Array.isArray(key)
      ? key.map(k => encodeURIComponent(String(k))).join(';;')
      : encodeURIComponent(String(key));
    const params = {};
    if (retDataType !== undefined) params.retDataType = retDataType;
    if (contentType !== undefined) params.contentType = contentType;
    return await this._request('GET', this.serviceUrl(`data/${keySeg}`, params));
  }

  /**
   * 存值:POST JSON `{value, prop}`(两者都必须是字符串,后端 ShouldBindJSON 绑定 string)。
   * 二进制请用 `binary`(Buffer/Uint8Array):SDK 转 base64 存进 value,prop 缺省记为
   * `base64;<mime>`(mime 缺省 application/octet-stream);回读 `Buffer.from(d.data, 'base64')`。
   * 后端 raw body 分支不可用(ShouldBindJSON 已先读走 body,小 body 存成 ""、大 body 被截断),SDK 不提供。
   */
  async saveCustomData(key, { value, prop, ttl, binary, mime } = {}) {
    const params = {};
    if (ttl !== undefined && ttl > 0) params.ttl = String(ttl);
    const url = this.serviceUrl(`data/${encodeURIComponent(key)}`, params);
    const body = {};
    if (binary !== undefined && binary !== null) {
      body.value = Buffer.from(binary).toString('base64');
      body.prop = prop !== undefined ? prop : `base64;${mime || 'application/octet-stream'}`;
    } else {
      if (value !== undefined) body.value = value;
      if (prop !== undefined) body.prop = prop;
    }
    return await this._request('POST', url, { jsonBody: body });
  }

  async deleteCustomData(key, { isPrefix = false } = {}) {
    const keySeg = Array.isArray(key)
      ? key.map(k => encodeURIComponent(String(k))).join(';;')
      : encodeURIComponent(String(key));
    const params = isPrefix ? { isPrefix: 'true' } : {};
    return await this._request('DELETE', this.serviceUrl(`data/${keySeg}`, params));
  }

  async saveMultipleCustomData(entries = []) {
    return await this._request(
      'POST',
      this.serviceUrl('datas'),
      { jsonBody: entries.map(e => ({ ...e })) },
    );
  }

  async getCustomDataKeys(prefix = '', { retDataType, isSys = false } = {}) {
    const params = { prefix };
    if (retDataType !== undefined) params.retDataType = retDataType;
    if (isSys) params.isSys = 'true';
    return await this._request('GET', this.serviceUrl('datakeys', params));
  }

  // ==========================================================================
  // 分类 9:全文搜索
  // ==========================================================================

  async fullSearch(query = '', {
    mapVer, type, workspace, limit = 10, offset = 0,
    fields, facet, boundingBox, timeRange, useGet = false,
  } = {}) {
    const body = { query, limit, offset };
    if (mapVer !== undefined) body.map_ver = mapVer;
    if (type !== undefined) body.type = type;
    if (workspace !== undefined) body.workspace = workspace;
    if (fields !== undefined) body.fields = fields;
    if (facet !== undefined) body.facet = !!facet;
    if (boundingBox !== undefined) body.bounding_box = boundingBox;
    if (timeRange !== undefined) body.time_range = timeRange;
    if (useGet) {
      return await this._request('GET', this.serviceUrl('fullsearch/search', body));
    }
    return await this._request(
      'POST',
      this.serviceUrl('fullsearch/search'),
      { jsonBody: body },
    );
  }

  async fullSearchAdd(document) {
    return await this._request(
      'POST',
      this.serviceUrl('fullsearch/add'),
      { jsonBody: { document: { ...document } } },
    );
  }

  async fullSearchDelete(id) {
    return await this._request(
      'DELETE',
      this.serviceUrl('fullsearch/delete'),
      { jsonBody: { id } },
    );
  }

  // ==========================================================================
  // 分类 10:符号库
  // ==========================================================================

  async symbolCategories() {
    return await this._request('GET', this.serviceUrl('symbol/categories'));
  }

  async symbolList(categoryId, { page = 1, pageSize = 20, keyword } = {}) {
    const params = { page, pageSize };
    if (keyword !== undefined && keyword !== '') params.keyword = keyword;
    return await this._request(
      'GET',
      this.serviceUrl(`symbol/list/${encodeURIComponent(categoryId)}`, params),
    );
  }

  async symbolGet(id) {
    return await this._request(
      'GET',
      this.serviceUrl(`symbol/${encodeURIComponent(id)}`),
    );
  }

  // ==========================================================================
  // 分类 12:/service 杂项
  // ==========================================================================

  qrcodeUrl(content, size = 256) {
    return this.serviceUrl('qrcode', { content, size });
  }

  async qrcodeBytes(content, size = 256) {
    const resp = await this._request('GET', this.qrcodeUrl(content, size), { stream: true });
    if (!resp.ok) {
      throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`,
        { status: resp.status, url: resp.url, body: null });
    }
    return Buffer.from(await resp.arrayBuffer());
  }

  glyphsUrl(fontstack, range = '0-255') {
    const fs = encodeURIComponent(fontstack).replace(/%2C/g, ',');
    let r = range;
    if (!r.endsWith('.pbf')) r = `${r}.pbf`;
    return this.serviceUrl(`fonts/${fs}/${r}`);
  }

  async glyphsBytes(fontstack, range = '0-255') {
    const resp = await this._request('GET', this.glyphsUrl(fontstack, range), { stream: true });
    if (!resp.ok) {
      throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`,
        { status: resp.status, url: resp.url, body: null });
    }
    return Buffer.from(await resp.arrayBuffer());
  }

  // ==========================================================================
  // 分类 13:AI(条件启用)
  // ==========================================================================

  async aiEnabled() {
    try {
      await this.chatFaq();
      return true;
    } catch (_) {
      return false;
    }
  }

  async vectorSearch(query = '', {
    numDocuments = 20, collection = 'vj',
    where, whereDocument, searchDescription,
    disableSplitSentence = false, disableSearchKnowledge = false,
    documents, useGet = false,
  } = {}) {
    const body = {
      query, numDocuments, collection,
      disableSplitSentence: !!disableSplitSentence,
      disableSearchKnowledge: !!disableSearchKnowledge,
    };
    if (where !== undefined) body.where = where;
    if (whereDocument !== undefined) body.whereDocument = whereDocument;
    if (searchDescription !== undefined) body.searchDescription = searchDescription;
    if (documents !== undefined) body.documents = documents.map(d => ({ ...d }));
    if (useGet) {
      const params = { query, numDocuments, collection };
      if (where !== undefined) params.where = where;
      if (whereDocument !== undefined) params.whereDocument = whereDocument;
      return await this._request('GET', this.serviceUrl('vectorSearch', params));
    }
    return await this._request('POST', this.serviceUrl('vectorSearch'), { jsonBody: body });
  }

  async chatFaq() {
    return await this._request('GET', this.serviceUrl('chatFaq'));
  }

  async vectorDocs() {
    return await this._request('GET', this.serviceUrl('vectorDocs'));
  }

  async uploadImg(file, { uri, filename, watermark = false, authorization } = {}) {
    if (uri === undefined) throw new VjmapError('uploadImg: uri is required');
    const form = new FormData();
    let blob; let fname;
    if (typeof file === 'string') {
      const fs = await import('node:fs/promises');
      const pathMod = await import('node:path');
      const buf = await fs.readFile(file);
      fname = filename || pathMod.basename(file);
      blob = new Blob([buf]);
    } else if (file && typeof file === 'object' && 'buffer' in file && 'name' in file) {
      fname = filename || file.name;
      blob = new Blob([file.buffer]);
    } else if (file instanceof Uint8Array || Buffer.isBuffer(file)) {
      fname = filename || 'upload.bin';
      blob = new Blob([file]);
    } else {
      throw new VjmapError('uploadImg: unsupported file type');
    }
    form.append('upload', blob, fname);
    const headers = {
      'Authorization': authorization || 'Basic X3ZqbWFwQF8=',
      'Uri': uri,
    };
    if (watermark) headers['watermark'] = '1';
    return await this._request(
      'POST',
      this.serviceUrl('uploadImg'),
      { form, headers },
    );
  }
}

// ---------------------------------------------------------------------------
module.exports = {
  Vjmap,
  VjmapError,
  MapOpenWay,
  computeSecretKey,
  fileMd5,
  md5Hex,
};