tuqin
3 天以前 3b8ba54ea299a9febdf3a989a72b4eba311be796
fix(map): SDK 改走同域反向代理,绕开客户端 IPv6 不通

诊断页实测拿到决定性证据:script 的 error 事件在同一秒触发(不是超时),
说明请求立即失败。进一步排查:

- 百度的 DNS 同时返回 A 和 AAAA,浏览器优先连 IPv6
api.map.baidu.com -> A: 36.144.42.203 / 36.144.42.82
AAAA: 2409:8c00:6c21:103c:0:ff:b058:29bf
- 服务器实测:curl -4 得到 HTTP 200 / 0.94s;curl -6 直接失败
=> 该网络环境 IPv6 不通,浏览器扑到 IPv6 死路后立即报错

修复(服务器侧 + 前端):
1. nginx 新增 /bmap-api/ 反向代理(/data/nginx-docker/conf/bmap-proxy.conf,
由 9023 的 server 块 include),proxy_pass 固定 IPv4 36.144.42.203,
Host 头仍为 api.map.baidu.com,客户端只访问本机 9023。
实测经代理加载 SDK:HTTP 200 / 879431 字节 / 0.27s(公网 1.66s)。
2. 前端 loadBaiduMapSdk 改为按顺序尝试:
/bmap-api/getscript(同域代理) -> https://api.map.baidu.com/getscript(直连回退)
3. 诊断页 bmap-test.html 增加「经本站代理加载」区块,
同时验证代理通道和地图瓦片是否可用。

已知限制:SDK 里地图瓦片域名(apimaponline0-3.bdimg.com 等)是硬编码的,
window.B_HOST_TILE_ONLINE_URLS 会被 SDK 自身覆盖,无法通过配置改为代理地址。
若瓦片仍因 IPv6 加载不出,需再做瓦片代理或由客户侧放开 IPv6。
已修改2个文件
126 ■■■■■ 文件已修改
public/bmap-test.html 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/map/src/utils.ts 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/bmap-test.html
@@ -105,5 +105,45 @@
      document.body.appendChild(script);
    </script>
    <hr style="margin:24px 0" />
    <h3>经本站代理加载(绕开 IPv6)</h3>
    <div id="log2" style="padding:12px;background:#f0f9ff;border-radius:6px;font-family:Consolas,monospace;font-size:13px;line-height:1.9;white-space:pre-wrap;word-break:break-all"></div>
    <div id="map2" style="width:100%;height:320px;margin-top:12px;border:1px solid #ddd"></div>
    <script>
      (function () {
        var el = document.getElementById('log2');
        function lg(msg, cls) {
          var t = new Date().toLocaleTimeString('zh-CN', { hour12: false });
          var d = document.createElement('div');
          d.className = cls || '';
          d.textContent = '[' + t + '] ' + msg;
          el.appendChild(d);
        }
        var url = '/bmap-api/getscript?type=webgl&v=1.0&ak=' + KEY + '&services=&t=' + Date.now();
        lg('开始加载(同域代理): ' + url);
        var start = Date.now();
        var s = document.createElement('script');
        s.src = url;
        s.addEventListener('load', function () {
          lg('load 事件触发(耗时 ' + ((Date.now() - start) / 1000).toFixed(2) + ' 秒)', 'ok');
          lg('window.BMapGL = ' + (window.BMapGL ? '存在' : '不存在'), window.BMapGL ? 'ok' : 'bad');
          if (window.BMapGL) {
            try {
              var m = new window.BMapGL.Map(document.getElementById('map2'));
              m.centerAndZoom(new window.BMapGL.Point(87.616848, 43.825592), 14);
              m.enableScrollWheelZoom(true);
              lg('地图创建成功!如果下方地图能显示,说明瓦片也能加载', 'ok');
            } catch (e) {
              lg('创建地图失败: ' + e.message, 'bad');
            }
          }
        });
        s.addEventListener('error', function () {
          lg('error 事件触发:代理也不通(说明 9023 端口或 nginx 配置问题)', 'bad');
        });
        document.body.appendChild(s);
      })();
    </script>
  </body>
</html>
src/components/map/src/utils.ts
@@ -1,24 +1,23 @@
/**
 * 百度地图 SDK 加载工具
 *
 * 加载方式说明(踩过的坑):
 * 加载地址优先级(按顺序尝试,都失败才报错):
 *   1. /bmap-api/getscript  —— 本站 nginx 反向代理,强制回源百度 IPv4
 *   2. https://api.map.baidu.com/getscript —— 直连百度
 *
 * 1. 不能用 api?v=1.0&type=webgl&ak=xxx 这个地址。
 *    它返回的是一段 document.write('<script src=".../getscript?...">'),
 *    而通过 document.body.append(script) 动态插入的脚本里 document.write 会被浏览器拦截,
 *    导致真正的 SDK 从未执行、window.BMapGL 一直是空;但 api 的 callback 仍会被调用,
 *    调用方误判成功,接着 new BMapGL.Map() 就抛
 *    "Cannot read properties of null (reading 'Map')"。
 * 为什么要代理(实测结论):
 *   该部署环境(含客户端所在网络)IPv6 不通,而 api.map.baidu.com 的 DNS
 *   优先返回 AAAA(IPv6)记录,浏览器优先连 IPv6 时立即失败——
 *   script 的 error 事件在 1 秒内就触发,根本不是超时。
 *   服务器侧验证:curl -4 得到 HTTP 200 / 0.94s,curl -6 直接失败。
 *   走同域代理后,客户端只访问本机 9023,由服务器用固定 IPv4 回源,
 *   实测 HTTP 200 / 879KB / 0.28s。
 *
 * 2. 必须直连 getscript,它是 875KB 的真实 SDK。
 *
 * 3. 加载前要设置 window.BMAP_PROTOCOL = 'https'(百度官方要求),
 *    否则 SDK 内部按默认协议拼子资源地址,HTTPS 页面下会被当作混合内容拦截。
 *
 * 4. 不要给 script 加 async = true:SDK 内部用 document.currentScript 定位自身,
 *    async 脚本执行期间 currentScript 为 null,会让它走到 fallback 分支甚至拿不到自身地址。
 *
 * 5. 失败重试一次:偶发网络抖动(尤其是跨省/移动网络)重试往往就能过。
 * 其他踩过的坑:
 *   - 不能用 api?v=1.0&type=webgl(返回 document.write 包装,动态脚本里会被浏览器拦截)
 *   - 加载前要设 window.BMAP_PROTOCOL = 'https'(百度官方要求)
 *   - 不要给 script 加 async(SDK 依赖 document.currentScript 定位自身)
 *   - script 的事件名是 error,不是 onerror
 */
// 扩展 Window 接口以包含百度地图 GL API
@@ -32,27 +31,22 @@
// SDK 加载状态
let loadPromise: null | Promise<void> = null;
// 加载失败次数,用于决定是否重试
let failCount = 0;
/** 单次尝试加载 */
function loadOnce(accessKey: string, timeout: number): Promise<void> {
/** 用指定地址加载一次 */
function loadFrom(url: string, timeout: number): Promise<void> {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      reject(new Error('百度地图 SDK 加载超时,请检查网络'));
      reject(new Error(`百度地图 SDK 加载超时:${url}`));
    }, timeout);
    const script = document.createElement('script');
    script.src = `https://api.map.baidu.com/getscript?type=webgl&v=1.0&ak=${accessKey}&services=&t=${Date.now()}`;
    script.src = url;
    // 注意:不要设 async,SDK 依赖 document.currentScript 定位自身
    script.addEventListener('load', () => {
      clearTimeout(timeoutId);
      if (!window.BMapGL) {
        // ak 无效 / 域名未授权 / 被拦截时,脚本能加载但不会挂载 BMapGL
        reject(
          new Error('百度地图加载异常:未获取到地图对象,请检查 Key 或域名授权'),
        );
        reject(new Error('脚本已下载但未挂载地图对象'));
        return;
      }
      resolve();
@@ -61,11 +55,7 @@
    // 事件名是 error,不是 onerror(写 onerror 永远不会触发)
    script.addEventListener('error', () => {
      clearTimeout(timeoutId);
      reject(
        new Error(
          '百度地图 SDK 加载失败(无法访问 api.map.baidu.com,可能是网络限制或插件拦截)',
        ),
      );
      reject(new Error(`加载失败:${url}`));
    });
    document.body.append(script);
@@ -74,16 +64,13 @@
/**
 * 加载百度地图 GL SDK
 * @param timeout 超时时间(毫秒),默认 10000
 * @param timeout 单次尝试的超时时间(毫秒),默认 10000
 * @returns Promise<void>
 */
export const loadBaiduMapSdk = (timeout = 10_000): Promise<void> => {
  // 已加载完成
  if (window.BMapGL) {
    return Promise.resolve();
  }
  // 正在加载中,返回同一个 Promise
  if (loadPromise) {
    return loadPromise;
  }
@@ -94,25 +81,30 @@
  }
  // 百度官方要求:先声明协议,避免 HTTPS 页面下 SDK 拼出 http 子资源地址
  if (typeof window !== 'undefined') {
    window.BMAP_PROTOCOL = 'https';
    window.BMapGL_loadScriptTime = Date.now();
  }
  const query = `type=webgl&v=1.0&ak=${accessKey}&services=&t=${Date.now()}`;
  // 优先同域反代(绕过客户端 IPv6 不通),再回退直连
  const candidates = [
    `/bmap-api/getscript?${query}`,
    `https://api.map.baidu.com/getscript?${query}`,
  ];
  loadPromise = (async () => {
    let lastError: any;
    for (const url of candidates) {
    try {
      await loadOnce(accessKey, timeout);
        await loadFrom(url, timeout);
        return;
    } catch (error) {
      // 首次失败重试一次,应对偶发网络抖动
      if (failCount < 1) {
        failCount += 1;
        await loadOnce(accessKey, timeout);
      } else {
        failCount = 0;
        lastError = error;
      }
    }
        loadPromise = null;
        throw error;
      }
    }
    throw new Error(
      `百度地图 SDK 加载失败(已尝试代理与直连):${lastError?.message ?? ''}`,
    );
  })();
  return loadPromise;