znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第十章:实战工作流组合

前九章逐层拆解了 opengis-skills 的设计理念、架构规范和六大领域共 67 个技能。你知道了每个技能能做什么、怎么触发、有哪些核心 API——但真实的项目从来不会只需要一个技能。一个 GIS 数据分析师早晨需要用 GDAL 做格式转换、上午要用 PostGIS 跑空间查询、下午要用 GeoServer 发布服务、晚上可能还要用 OpenLayers 调前端。这四种场景分别对应四个互相独立的技能文件,当你面对一个完整项目时,关键问题不是”单个技能怎么用”,而是”多个技能怎么组”。

本章展示的就是这个”组”的艺术。我们将通过七个完整的跨技能实战场景和八个可复用工作流模板,演示如何用 opengis-skills 覆盖从需求到交付的全过程。每一个场景都以”真实对话”的形式展开——这不是虚构的演示,而是你可以在自己项目中直接复现的操作流程。

本章定位:学完前九章,你知道每个技能”是什么”。读完本章,你知道多个技能”怎么串起来用”。这是一个从”知识”到”能力”的跃迁。


10.1 工作流组合的理念

在进入具体案例之前,先建立工作流组合的思维框架。这一节回答三个问题:为什么要组合?怎么组合?组合完之后能沉淀成什么?

10.1.1 单个技能 vs. 完整工作流

回顾 opengis-skills 的 67 个技能,每个技能本质上是一个”专业领域函数”:

技能 输入 输出 角色
gdal 原始矢量/栅格数据 转换、投影后的数据 数据搬运工
postgis 空间数据和 SQL 语句 查询结果、空间分析 计算引擎
geoserver 数据存储 + 图层配置 WMS/WFS 服务 服务发布者
openlayers 地图服务 URL + 样式 交互式 Web 地图 前端展示
cesiumjs 3D Tiles/glTF/地形 三维地球可视化 3D 展示
geopandas GeoDataFrame 空间分析图表 Python 分析
freecad 参数和几何描述 三维模型 CAD 建模
furion 业务逻辑代码 RESTful API 后端服务
dify 文档 + 配置 AI 应用 LLM 平台

任何一个技能单独使用时,解决的问题域都非常狭窄。但真实项目的需求往往是:

“把甲方给的 Shapefile 数据入库,做缓冲区分析,发布成 Web 地图,再加个点击查询功能。下周五上线。”

这个需求需要 gdalpostgisgeoserveropenlayers 四个技能串联。如果你只懂其中两三个技能,项目就推不动。而如果你用 opengis-skills,你可以让 AI 加载这四个技能文件,一次性生成全链路的代码和命令。

10.1.2 AI 的三级加载策略在工作流中的实践

第二章详细讲解了 opengis-skills 的三层索引架构,这里结合工作流场景重新诠释它的价值:

第一层(根 SKILL.md):AI 加载后看到"有 67 个技能可用"
         ↓
第二层(分类索引):AI 识别出"GIS 任务需要 gis/ 下的技能"
         ↓
第三层(项目技能):AI 按任务阶段依次加载 gdal → postgis → geoserver → openlayers

在工作流场景中,三级加载策略演变为动态按需加载

  1. 任务理解阶段:AI 加载根 SKILL.md,快速导航到相关领域
  2. 分步执行阶段:AI 按工作流阶段,逐阶段加载对应的项目技能。先加载 gdal 完成数据转换,确认无误后卸载;再加载 postgis 完成入库和分析;以此类推
  3. 交叉纠错阶段:当某个阶段出错时,AI 可以回溯到前面阶段已加载的技能内容进行排查(例如 GeoServer 无法识别字段类型,AI 回想 GDAL 导入时的 -lco 参数是否正确)

这种按需加载模式的核心优势是上下文效率:AI 的上下文中始终只保留当前阶段真正需要的技能知识,而不是一次性加载 67 个技能的全部内容。在实际使用中,这意味着:

  • 一个典型的四阶段 GIS 工作流,AI 上下文峰值不超过 30-40 KB 的技能知识(约等于一个中大型的 SKILL.md)
  • AI 不会因为上下文过长而”遗忘”关键 API 签名
  • Token 成本保持在可控范围(每次对话约消耗 10K-50K tokens 的技能注入)

10.1.3 工作流模板的概念

在反复实践中,某些技能组合会反复出现。把这种高频组合沉淀为工作流模板,你下次面对类似需求时不需要从零开始设计技能加载顺序,直接套用模板即可。

本章 10.8 节给出了八个可直接套用的工作流模板。每个模板包含:

  • 适用场景:一句话描述什么情况下用这个模板
  • 技能加载顺序:AI 应依次加载哪些技能
  • 关键衔接点:阶段之间传递什么数据
  • 典型时间估算:熟练使用者在 AI 辅助下大约需要多久完成

核心理念:opengis-skills 不替代你的专业知识——它替代的是”每次都要查文档”的低效重复劳动。工作流组合的目的是把你从机械的知识检索中解放出来,让你把精力放在真正需要创造力的设计决策上。


10.2 GIS 全链路:数据获取 → 处理 → 分析 → 发布

这是 GIS 领域最经典的全链路场景,也是 opengis-skills 覆盖最完整的领域。我们以一个虚构但典型的项目需求来串联整个过程:

需求:有一个 GeoJSON 格式的城市兴趣点(POI)数据集 poi.geojson,包含全市 50,000+ 个 POI 的位置和分类信息。需要导入 PostGIS 做空间分析(找出每个 POI 500 米范围内的其他 POI 数量,作为”商业密度”指标),然后用 GeoServer 发布 WMS 服务,最后用 OpenLayers 做一个带点击查询和密度热力图的前端展示页面。

这是一个标准的数据工程师 + 后端开发 + 前端开发的协作流程。在 AI 辅助 + opengis-skills 的场景下,一个全栈 GIS 开发者可以在半天内独立完成。

10.2.1 第一阶段:数据预处理(gdal)

AI 加载技能:首先让 AI 加载 @gis/gdal/SKILL.md,获得 GDAL/OGR 命令行工具的完整知识。

对话示例

用户:@gis/gdal/SKILL.md 帮我看一下 poi.geojson 的基本信息,包括坐标系、字段列表和数据量

AI:[已加载 gdal 技能]
执行以下命令查看数据概况:
  ogrinfo -so -json poi.geojson

该命令会输出:
- 图层名称和要素数量
- 坐标系(CRS)信息
- 所有字段的名称和类型
- 几何类型

如果数据集包含多个图层或需要筛选,后续可以用 ogr2ogr 做进一步处理。

实际操作

# 第一步:查看数据概览
ogrinfo -so -json poi.geojson
// 预期输出示例
{
  "layers": [{
    "name": "poi",
    "featureCount": 52341,
    "geometryType": "Point",
    "crs": { "type": "name", "properties": { "name": "EPSG:4326" } },
    "fields": [
      { "name": "id", "type": "Integer" },
      { "name": "name", "type": "String" },
      { "name": "category", "type": "String" },
      { "name": "address", "type": "String" }
    ]
  }]
}
用户:数据确认无误。帮我检查一下有没有无效几何,如果有就修复

AI:[基于 gdal 技能]
步骤1:检查无效几何
  ogrinfo -sql "SELECT id FROM poi WHERE ST_IsValid(geometry)=0" poi.geojson
  (注意:OGR SQL 对 GeoJSON 的支持有限,对于复杂验证建议先导入 PostGIS)

步骤2:如果数据量不大,可以用 ogr2ogr 做清洗:
  ogr2ogr -f GeoJSON poi_clean.geojson poi.geojson -sql "SELECT * FROM poi WHERE ST_IsValid(geometry)=1"

如果源文件确实存在无效几何,建议直接导入 PostGIS 后用 ST_MakeValid() 修复,PostGIS 的几何修复能力比 OGR 更强。

关键技巧:在这个阶段,不要试图用 GDAL 完成所有数据清洗。GDAL 的强项是格式转换、投影变换和简单过滤;复杂的几何修复和空间分析应该交给 PostGIS。这个分工认知本身,就是 opengis-skills 帮你建立的——AI 加载了多个技能后,会自动判断”什么事情该交给哪个工具”。

10.2.2 第二阶段:数据库入库与分析(postgis)

AI 加载技能:加载 @gis/postgis/SKILL.md,获得 PostGIS 空间函数、索引优化和 SQL 模板的知识。

对话示例

用户:@gis/postgis/SKILL.md 帮我把 poi_clean.geojson 导入 PostGIS 并创建空间索引

AI:[已加载 postgis 技能]
方案一(命令行,推荐):
  ogr2ogr -f PostgreSQL PG:"host=localhost dbname=gis user=postgres password=xxx" \
    poi_clean.geojson -nln poi -lco GEOMETRY_NAME=geom -lco FID=id

方案二(SQL,需要先创建表):
  -- 暂不推荐,ogr2ogr 会自动推断字段类型

导入后验证:
  SELECT COUNT(*) FROM poi;
  SELECT ST_SRID(geom) FROM poi LIMIT 1;

然后创建空间索引:
  CREATE INDEX poi_geom_idx ON poi USING GIST(geom);
  CREATE INDEX poi_category_idx ON poi(category);  -- 按分类查询的辅助索引

实际操作

# 导入 PostGIS(注意:使用 ogr2ogr 时需要它支持 PostgreSQL 驱动)
ogr2ogr -f "PostgreSQL" PG:"host=localhost dbname=gis user=postgres password=yourpass" \
  poi_clean.geojson \
  -nln poi \
  -lco GEOMETRY_NAME=geom \
  -lco FID=id \
  -lco GEOMETRY_TYPE=geometry \
  -overwrite
-- 验证导入
SELECT COUNT(*) AS total, ST_SRID(geom) AS srid FROM poi GROUP BY srid;
-- 预期:total=52341, srid=4326

-- 创建空间索引(这是后续所有空间查询的性能前提)
CREATE INDEX poi_geom_idx ON poi USING GIST(geom);

-- 查看空间索引大小
SELECT pg_size_pretty(pg_relation_size('poi_geom_idx'));
用户:现在做缓冲区分析:找出每个 POI 500 米范围内有多少其他 POI,
      结果存为新列 nearby_count

AI:[基于 postgis 技能]
对于 5 万级别的数据,直接用自连接 + ST_DWithin 会比较慢。
推荐方案:使用地理坐标系计算,分批执行。

第一步:如果 geom 是 EPSG:4326(WGS84 经纬度),先转换为地理坐标做距离计算:
  ALTER TABLE poi ADD COLUMN geom_geog geography(Point,4326);
  UPDATE poi SET geom_geog = geom::geography;
  CREATE INDEX poi_geog_idx ON poi USING GIST(geom_geog);

第二步:添加字段并计算(使用 geography 类型自动按米计算距离):
  ALTER TABLE poi ADD COLUMN nearby_count INTEGER DEFAULT 0;

  UPDATE poi a SET nearby_count = sub.cnt
  FROM (
    SELECT a.id, COUNT(b.id)::INTEGER AS cnt
    FROM poi a
    JOIN poi b ON ST_DWithin(a.geom_geog, b.geom_geog, 500)
    WHERE a.id != b.id
    GROUP BY a.id
  ) sub
  WHERE a.id = sub.id;

第三步:验证结果
  SELECT category, AVG(nearby_count) AS avg_density, MAX(nearby_count) AS max_density
  FROM poi GROUP BY category ORDER BY avg_density DESC;

性能提示:500 米缓冲区的自连接在 5 万点数据集上,如果使用 geography 类型和 GiST 索引,通常可以在 30 秒到 2 分钟之间完成。如果不建索引、用 geometry 类型直接计算平面距离(经纬度当平面坐标),不仅结果错误(高纬度地区变形严重),而且执行时间可能长达数十分钟。

进阶分析示例

-- 按分类统计 POI 分布热力
SELECT category, COUNT(*) AS total,
       AVG(nearby_count) AS avg_density,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY nearby_count) AS median_density,
       PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY nearby_count) AS p90_density
FROM poi GROUP BY category
ORDER BY avg_density DESC;

-- 找出最"孤独"的 POI(500 米内没有其他 POI 的点)
SELECT id, name, category, address, ST_AsGeoJSON(geom) AS geojson
FROM poi WHERE nearby_count = 0
ORDER BY id LIMIT 10;

-- 计算每个 POI 到最近同类 POI 的距离
SELECT a.id, a.name, a.category,
       MIN(ST_Distance(a.geom_geog, b.geom_geog)) AS nearest_same_category_dist
FROM poi a
JOIN poi b ON a.category = b.category AND a.id != b.id
GROUP BY a.id, a.name, a.category
ORDER BY nearest_same_category_dist
LIMIT 20;

10.2.3 第三阶段:地图服务发布(geoserver + geoserver-rest-api)

AI 加载技能:加载 @gis/geoserver/SKILL.md@gis/geoserver-rest-api/SKILL.md

GeoServer 的管理有两种方式:Web 管理界面(手动操作)和 REST API(可脚本化、可自动化)。在工作流场景中,REST API 是首选——你可以把所有配置写成脚本,在新环境一键复现。

对话示例

用户:@gis/geoserver-rest-api/SKILL.md 用 REST API 创建工作空间"city"、
      连接刚导入的 PostGIS POI 表,然后发布为 WMS 图层

AI:[已加载 geoserver-rest-api 技能]
以下命令按顺序执行:

1. 创建工作空间:
curl -u admin:geoserver -X POST \
  http://localhost:8080/geoserver/rest/workspaces \
  -H "Content-Type: application/json" \
  -d '{"workspace":{"name":"city"}}'

2. 创建 PostGIS 数据存储:
curl -u admin:geoserver -X POST \
  http://localhost:8080/geoserver/rest/workspaces/city/datastores \
  -H "Content-Type: application/json" \
  -d '{
    "dataStore":{
      "name":"postgis",
      "connectionParameters":{
        "entry":[
          {"@key":"host","$":"localhost"},
          {"@key":"port","$":"5432"},
          {"@key":"database","$":"gis"},
          {"@key":"user","$":"postgres"},
          {"@key":"passwd","$":"yourpass"},
          {"@key":"dbtype","$":"postgis"}
        ]
      }
    }
  }'

3. 发布 poi 图层:
curl -u admin:geoserver -X POST \
  http://localhost:8080/geoserver/rest/workspaces/city/datastores/postgis/featuretypes \
  -H "Content-Type: application/json" \
  -d '{
    "featureType":{
      "name":"poi",
      "nativeName":"poi",
      "title":"城市POI",
      "srs":"EPSG:4326",
      "enabled":true
    }
  }'

实际操作

# 1. 创建工作空间
curl -u admin:geoserver -X POST \
  "http://localhost:8080/geoserver/rest/workspaces" \
  -H "Content-Type: application/json" \
  -d '{"workspace":{"name":"city"}}'

# 2. 创建 PostGIS 存储(如果密码含特殊字符需 URL 编码)
curl -u admin:geoserver -X POST \
  "http://localhost:8080/geoserver/rest/workspaces/city/datastores" \
  -H "Content-Type: application/json" \
  -d '{
    "dataStore":{
      "name":"city_postgis",
      "type":"PostGIS",
      "connectionParameters":{
        "entry":[
          {"@key":"host","$":"localhost"},
          {"@key":"port","$":"5432"},
          {"@key":"database","$":"gis"},
          {"@key":"schema","$":"public"},
          {"@key":"user","$":"postgres"},
          {"@key":"passwd","$":"yourpass"},
          {"@key":"dbtype","$":"postgis"}
        ]
      }
    }
  }'

# 3. 检查数据存储是否创建成功
curl -u admin:geoserver -X GET \
  "http://localhost:8080/geoserver/rest/workspaces/city/datastores/city_postgis.json"

# 4. 发布要素类型(图层)
curl -u admin:geoserver -X POST \
  "http://localhost:8080/geoserver/rest/workspaces/city/datastores/city_postgis/featuretypes" \
  -H "Content-Type: application/json" \
  -d '{
    "featureType":{
      "name":"poi",
      "nativeName":"poi",
      "title":"城市POI数据",
      "abstract":"包含全市50,000+个POI的位置、分类和密度指标",
      "srs":"EPSG:4326",
      "nativeBoundingBox":{
        "minx":113.0,"maxx":114.5,
        "miny":22.0,"maxy":23.5,
        "crs":"EPSG:4326"
      },
      "latLonBoundingBox":{
        "minx":113.0,"maxx":114.5,
        "miny":22.0,"maxy":23.5,
        "crs":"EPSG:4326"
      },
      "enabled":true
    }
  }'

SLD 样式配置:发布图层后,POI 默认以 GeoServer 的灰色圆点显示。为了按”商业密度”(nearby_count)做分级渲染,需要配置 SLD 样式:

# 5. 发布分级渲染样式
curl -u admin:geoserver -X POST \
  "http://localhost:8080/geoserver/rest/workspaces/city/styles" \
  -H "Content-Type: application/vnd.ogc.sld+xml" \
  -d '<?xml version="1.0" encoding="UTF-8"?>
<StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld">
  <NamedLayer>
    <Name>poi_density</Name>
    <UserStyle>
      <Title>POI密度分级</Title>
      <FeatureTypeStyle>
        <Rule>
          <Name>低密度</Name>
          <Filter>
            <PropertyIsLessThan>
              <PropertyName>nearby_count</PropertyName>
              <Literal>10</Literal>
            </PropertyIsLessThan>
          </Filter>
          <PointSymbolizer>
            <Graphic>
              <Mark><WellKnownName>circle</WellKnownName>
                <Fill><CssParameter name="fill">#00FF00</CssParameter></Fill>
              </Mark>
              <Size>6</Size>
            </Graphic>
          </PointSymbolizer>
        </Rule>
        <Rule>
          <Name>中密度</Name>
          <Filter>
            <PropertyIsBetween>
              <PropertyName>nearby_count</PropertyName>
              <LowerBoundary><Literal>10</Literal></LowerBoundary>
              <UpperBoundary><Literal>50</Literal></UpperBoundary>
            </PropertyIsBetween>
          </Filter>
          <PointSymbolizer>
            <Graphic>
              <Mark><WellKnownName>circle</WellKnownName>
                <Fill><CssParameter name="fill">#FFFF00</CssParameter></Fill>
              </Mark>
              <Size>8</Size>
            </Graphic>
          </PointSymbolizer>
        </Rule>
        <Rule>
          <Name>高密度</Name>
          <Filter>
            <PropertyIsGreaterThan>
              <PropertyName>nearby_count</PropertyName>
              <Literal>50</Literal>
            </PropertyIsGreaterThan>
          </Filter>
          <PointSymbolizer>
            <Graphic>
              <Mark><WellKnownName>circle</WellKnownName>
                <Fill><CssParameter name="fill">#FF0000</CssParameter></Fill>
              </Mark>
              <Size>10</Size>
            </Graphic>
          </PointSymbolizer>
        </Rule>
      </FeatureTypeStyle>
    </UserStyle>
  </NamedLayer>
</StyledLayerDescriptor>'

# 6. 将样式应用到图层
curl -u admin:geoserver -X PUT \
  "http://localhost:8080/geoserver/rest/layers/city:poi" \
  -H "Content-Type: application/json" \
  -d '{"layer":{"defaultStyle":{"name":"poi_density","workspace":"city"}}}'

# 7. 验证 WMS 服务
curl "http://localhost:8080/geoserver/city/wms?service=WMS&version=1.3.0&request=GetCapabilities"

REST API vs. Web 管理界面:对于一次性配置,点几下鼠标在 Web UI 里完成也行。但如果你需要在新服务器上重建环境、在 CI/CD 流程中自动部署、或者团队多人共享配置,REST API 脚本是唯一可靠的方式。opengis-skills 的 geoserver-rest-api 技能覆盖了工作空间、数据存储、图层、样式、图层组、缓存配置的完整 API 端点——你不需要去翻 GeoServer 几百页的 REST API 文档,直接让 AI 查技能文件即可。

权限和安全提醒

# 生产环境建议:
# 1. 修改默认 admin 密码
curl -u admin:geoserver -X PUT \
  "http://localhost:8080/geoserver/rest/security/self/password" \
  -H "Content-Type: application/json" \
  -d '{"newPassword":"your_strong_password"}'

# 2. 为前端应用创建只读用户
curl -u admin:newpass -X POST \
  "http://localhost:8080/geoserver/rest/security/roles/role/READ_ONLY" \
  -H "Content-Type: application/json" \
  -d '{"role":{"name":"READ_ONLY"}}'

10.2.4 第四阶段:前端可视化(openlayers)

AI 加载技能:加载 @gis/openlayers/SKILL.md

现在数据已入库、服务已发布,最后一步是前端展示。目标是一个包含以下功能的页面:地图底图、POI 密度 WMS 图层叠加、点击查询 POI 详情、图例。

对话示例

用户:@gis/openlayers/SKILL.md 帮我用 OpenLayers 加载刚发布的 city:poi WMS 图层,
      需要 OSM 底图、点击查询功能,坐标中心设为 [113.5, 22.7] zoom 12

AI:[已加载 openlayers 技能]
以下是完整的 HTML+JS 代码...

完整代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>城市 POI 密度地图</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@10.4.0/ol.css">
  <style>
    #map { width: 100%; height: 100vh; }
    .ol-popup {
      position: absolute;
      background: white;
      box-shadow: 0 1px 4px rgba(0,0,0,0.2);
      padding: 15px;
      border-radius: 10px;
      border: 1px solid #ccc;
      bottom: 12px; left: -50px;
      min-width: 280px;
    }
    .ol-popup:after {
      top: 100%; border: solid transparent;
      content: " "; height: 0; width: 0;
      position: absolute; border-top-color: white;
      border-width: 10px; left: 48px; margin-left: -10px;
    }
    .legend {
      position: absolute; bottom: 20px; right: 20px;
      background: rgba(255,255,255,0.9);
      padding: 10px 15px; border-radius: 5px;
      font-size: 13px; box-shadow: 0 1px 4px rgba(0,0,0,0.2);
    }
    .legend-item { display: flex; align-items: center; margin: 3px 0; }
    .legend-color { width: 16px; height: 16px; border-radius: 50%; margin-right: 8px; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div class="legend">
    <strong>商业密度</strong>
    <div class="legend-item">
      <span class="legend-color" style="background:#00FF00;"></span> 低 (&lt;10)
    </div>
    <div class="legend-item">
      <span class="legend-color" style="background:#FFFF00;"></span> 中 (10-50)
    </div>
    <div class="legend-item">
      <span class="legend-color" style="background:#FF0000;"></span> 高 (&gt;50)
    </div>
  </div>

  <script type="module">
    import Map from 'https://cdn.jsdelivr.net/npm/ol@10.4.0/Map.js';
    import View from 'https://cdn.jsdelivr.net/npm/ol@10.4.0/View.js';
    import TileLayer from 'https://cdn.jsdelivr.net/npm/ol@10.4.0/layer/Tile.js';
    import OSM from 'https://cdn.jsdelivr.net/npm/ol@10.4.0/source/OSM.js';
    import TileWMS from 'https://cdn.jsdelivr.net/npm/ol@10.4.0/source/TileWMS.js';
    import Overlay from 'https://cdn.jsdelivr.net/npm/ol@10.4.0/Overlay.js';
    import { toLonLat } from 'https://cdn.jsdelivr.net/npm/ol@10.4.0/proj.js';

    // 弹出窗口
    const popupElement = document.createElement('div');
    popupElement.className = 'ol-popup';
    popupElement.id = 'popup';
    const closer = document.createElement('a');
    closer.href = '#';
    closer.className = 'ol-popup-closer';
    closer.textContent = '×';
    closer.style.cssText = 'position:absolute;top:5px;right:8px;text-decoration:none;font-size:18px;color:#999;';
    popupElement.appendChild(closer);

    const contentElement = document.createElement('div');
    contentElement.id = 'popup-content';
    popupElement.appendChild(contentElement);

    const popup = new Overlay({
      element: popupElement,
      autoPan: { animation: { duration: 250 } }
    });

    closer.onclick = function() { popup.setPosition(undefined); closer.blur(); return false; };

    // 地图初始化
    const map = new Map({
      target: 'map',
      layers: [
        new TileLayer({
          source: new OSM(),
          title: 'OpenStreetMap 底图'
        }),
        new TileLayer({
          title: 'POI密度图层',
          visible: true,
          opacity: 0.8,
          source: new TileWMS({
            url: 'http://localhost:8080/geoserver/city/wms',
            params: {
              LAYERS: 'city:poi',
              TILED: true,
              STYLES: 'poi_density',
              FORMAT: 'image/png'
            },
            serverType: 'geoserver',
            transition: 0
          })
        })
      ],
      overlays: [popup],
      view: new View({
        center: [12635200, 2590000], // EPSG:3857 坐标,对应 [113.5, 22.7] 附近
        zoom: 12
      })
    });

    // 点击查询(WMS GetFeatureInfo)
    map.on('singleclick', function(evt) {
      const viewResolution = map.getView().getResolution();
      const wmsSource = map.getLayers().item(1).getSource();
      const url = wmsSource.getFeatureInfoUrl(
        evt.coordinate,
        viewResolution,
        'EPSG:3857',
        { INFO_FORMAT: 'application/json', FEATURE_COUNT: 5 }
      );

      if (url) {
        fetch(url)
          .then(response => response.json())
          .then(data => {
            const features = data.features;
            if (features && features.length > 0) {
              const props = features[0].properties;
              contentElement.innerHTML = `
                <strong>${props.name || '未命名'}</strong><br>
                分类: ${props.category || '-'}<br>
                地址: ${props.address || '-'}<br>
                500m内POI: <strong>${props.nearby_count || 0}</strong> 个
              `;
              popup.setPosition(evt.coordinate);
            } else {
              popup.setPosition(undefined);
            }
          })
          .catch(() => { popup.setPosition(undefined); });
      }
    });

    // 鼠标悬停样式
    map.on('pointermove', function(evt) {
      if (evt.dragging) return;
      const pixel = map.getPixelFromCoordinate(evt.coordinate);
      const hit = map.hasFeatureAtPixel(pixel);
      map.getTargetElement().style.cursor = hit ? 'pointer' : '';
    });
  </script>
</body>
</html>

要点解析

  1. 坐标投影:OpenLayers 默认使用 EPSG:3857(Web Mercator)。center: [12635200, 2590000] 需要预先将 WGS84 经纬度转换为 EPSG:3857 米制坐标。可以用 ol/proj.fromLonLat([113.5, 22.7]) 在浏览器控制台计算。
  2. GetFeatureInfo:WMS 的点击查询不是前端行为,而是后端服务——每次点击触发一个 HTTP 请求到 GeoServer,由 GeoServer 查询数据库后返回 JSON。这就是为什么 PostGIS 查询和 GeoServer 发布两个阶段缺一不可。
  3. TILED: true:启用瓦片化 WMS,大幅提升加载速度。每个瓦片独立请求和缓存,而不是每次平移都重新请求整张地图。
  4. 样式分离:SLD 样式在 GeoServer 端定义,OpenLayers 只需在 params 中指定 STYLES 参数。这种”后端渲染 + 前端叠加”的模式是 WMS 的经典用法。

完整工作流的价值:四个阶段走完后,你手上有了:一个 PostGIS 数据库(可复用于后续分析)、一个 GeoServer 图层(支持标准 OGC 接口,可被 QGIS、ArcGIS、其他 Web 应用调用)、一个前端页面(最终交付物)。每一个阶段的产出都可以被下一个阶段消费,也可以独立对外提供服务。


10.3 CAD 全链路:建模 → 分析 → 出图 → 翻译

CAD 领域的工作流与 GIS 有本质不同:GIS 以”数据”为中心流转(文件→数据库→服务→前端),CAD 以”几何”为中心流转(模型→分析→图纸→翻译)。opengis-skills 在 CAD 领域的 11 个技能覆盖了这一完整流程。

10.3.1 场景设定

需求:设计一个机械支架(带孔的矩形块)→ 导出 STEP 格式供协作 → 用 OCCT 做布尔运算验证 → 导出 DXF 图纸 → 用 TongWen 翻译图纸标注

10.3.2 第一阶段:参数化建模(freecad)

AI 加载技能@cad/freecad/SKILL.md

FreeCAD 支持两种建模方式:GUI 交互建模(Workbench 操作)和 Python 脚本参数化建模。在工作流场景中,Python 脚本是首选——可复现、可参数化、可版本管理。

对话示例

用户:@cad/freecad/SKILL.md 用 FreeCAD Python 脚本创建一个带四个安装孔的矩形支架,
      长 100mm、宽 50mm、高 20mm,孔直径 8mm,孔中心距边缘 10mm

AI:[已加载 freecad 技能]
基于 Part 模块(CSG 构造几何)的实现方式如下...

完整建模代码

# bracket.py — 机械支架参数化建模
# 在 FreeCAD Python 控制台中运行,或通过 freecadcmd bracket.py 执行

import FreeCAD as App
import Part
import math

doc = App.newDocument("Bracket")

# ========== 参数定义 ==========
LENGTH  = 100.0   # 支架总长 (mm)
WIDTH   = 50.0    # 支架总宽 (mm)
HEIGHT  = 20.0    # 支架厚度 (mm)
HOLE_D  = 8.0     # 安装孔直径 (mm)
HOLE_DEPTH = HEIGHT + 1.0  # 孔深(穿透 + 余量)
MARGIN  = 10.0    # 孔中心距边缘距离 (mm)

# ========== 主体:矩形块 ==========
body = Part.makeBox(LENGTH, WIDTH, HEIGHT)
# makeBox 生成的块左下角在原点 (0,0,0)

# ========== 切减:四个安装孔 ==========
holes = []

# 四个孔的中心坐标 (x, y)
hole_centers = [
    (MARGIN, MARGIN),                     # 左下
    (LENGTH - MARGIN, MARGIN),            # 右下
    (MARGIN, WIDTH - MARGIN),             # 左上
    (LENGTH - MARGIN, WIDTH - MARGIN),    # 右上
]

for cx, cy in hole_centers:
    # 创建圆柱体(孔),位置在块表面以下 1mm(确保穿透)
    cylinder = Part.makeCylinder(HOLE_D / 2.0, HOLE_DEPTH,
                                 App.Vector(cx, cy, -0.5))
    holes.append(cylinder)

# 布尔减运算:主体减去所有孔
bracket = body
for hole in holes:
    bracket = bracket.cut(hole)

# ========== 可选:倒角处理 ==========
# 对四条竖直棱边做倒角(半径 2mm)
# bracket = bracket.makeFillet(2.0, bracket.Edges)  # 复杂几何可能需要逐个指定边

# ========== 显示结果 ==========
Part.show(bracket, "Bracket")

# ========== 验证体积和质心 ==========
print(f"体积: {bracket.Volume:.2f} mm³")
print(f"质心: ({bracket.CenterOfMass.x:.2f}, {bracket.CenterOfMass.y:.2f}, {bracket.CenterOfMass.z:.2f})")

# ========== 导出 STEP ==========
output_dir = App.getUserAppDataDir() + "/Bracket/"
import os
os.makedirs(output_dir, exist_ok=True)

Part.export([bracket], output_dir + "bracket.step")
print(f"STEP 文件已导出: {output_dir}bracket.step")

# ========== 保存 FreeCAD 项目 ==========
doc.saveAs(output_dir + "bracket.FCStd")
print(f"FreeCAD 项目已保存: {output_dir}bracket.FCStd")

运行方式

# 方式一:FreeCAD GUI 中的 Python 控制台粘贴执行
# 方式二:命令行批量执行
freecadcmd bracket.py

# 方式三:作为 FreeCAD 宏(Macro)执行
# 将 bracket.py 放入 FreeCAD 的 Macro 目录,通过菜单 Macro → Macros 加载

10.3.3 第二阶段:几何分析(clipper2 + occt)

AI 加载技能@cad/clipper2/SKILL.md@cad/occt/SKILL.md

导出 STEP 后,可以用 OCCT(Open Cascade Technology)加载模型做碰撞检测、体积计算、布尔运算验证等分析。

Clipper2 示例——截面对比

# clipper2_analysis.py
# 注意:Clipper2 处理的是二维多边形(裁剪、偏移),不是三维实体
# 适合用于 DXF 图纸中的截面分析

from clipper2 import Clipper2Lib as c2

# 假设我们有两个截面多边形需要比较
# 截面 A:支架底视图矩形
rect_a = c2.PathD([
    c2.PointD(0, 0), c2.PointD(100, 0),
    c2.PointD(100, 50), c2.PointD(0, 50)
])

# 截面 B:另一个零件的底视图
rect_b = c2.PathD([
    c2.PointD(20, -5), c2.PointD(80, -5),
    c2.PointD(80, 55), c2.PointD(20, 55)
])

# 计算两个截面的交集
intersection = c2.Intersect(rect_a, rect_b, c2.FillRule.EvenOdd)
print(f"交集面积: {c2.Area(intersection):.2f} mm²")

# 截面 A 偏移(等效于增加材料余量)
offset_a = c2.InflatePaths([rect_a], 2.0, c2.JoinType.Miter, c2.EndType.Polygon)
print(f"偏移后顶点数: {len(offset_a[0])}")

# 布尔运算:A 减去 B
diff = c2.Difference(rect_a, rect_b, c2.FillRule.EvenOdd)
print(f"差集区域数: {len(diff)}")

OCCT 示例——布尔运算验证

# occt_analysis.py
# 需要安装 pythonocc-core: pip install pythonocc-core
# 用于加载 STEP 文件并做三维布尔运算验证

from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse, BRepAlgoAPI_Cut, BRepAlgoAPI_Common
from OCC.Core.BRepGProp import brepgprop_VolumeProperties
from OCC.Core.GProp import GProp_GProps

# 加载 STEP 文件
reader = STEPControl_Reader()
status = reader.ReadFile("bracket.step")
if status != 1:
    raise RuntimeError("STEP 读取失败")

reader.TransferRoots()
shape = reader.OneShape()

# 计算体积
props = GProp_GProps()
brepgprop_VolumeProperties(shape, props)
print(f"STEP 模型体积: {props.Mass():.2f} mm³")

# 验证:该体积应接近 FreeCAD 中计算的体积(由于公差差异可能有微小误差)
# FreeCAD 中 body.Volume 约 = 100*50*20 - 4 * π*(4²)*20 ≈ 100000 - 4021 ≈ 95979 mm³

# 如果有另一个零件需要做装配分析:
# fusion = BRepAlgoAPI_Fuse(shape1, shape2).Shape()  # 合并
# cut = BRepAlgoAPI_Cut(shape1, shape2).Shape()       # 差集
# common = BRepAlgoAPI_Common(shape1, shape2).Shape()   # 交集

10.3.4 第三阶段:图纸翻译(tongwen)

AI 加载技能@cad/tongwen/SKILL.md

TongWen(同文翻译)是一个专门处理 CAD 图纸标注翻译的 C# 库,支持 AutoCAD DWG/DXF 格式。当支架图纸需要从中文标注翻译为英文时,TongWen 能保留原始位置和格式。

使用场景:设计团队在国内用中文标注出图,但客户是海外团队,需要英文版本。

// tongwen_bracket_translate.cs
// 需要引用 TongWen 库和 AutoCAD .NET API(或 Teigha/ODA)
// 本示例展示核心调用逻辑

using TongWen.Translation;
using TongWen.CadIntegration;

public class BracketTranslator
{
    public void TranslateDrawing(string dwgPath, string outputPath)
    {
        // 1. 加载 AutoCAD 图纸
        using var cadDoc = CadDocument.Open(dwgPath);

        // 2. 配置翻译引擎(中文 → 英文)
        var engine = new TranslationEngine(
            sourceLang: "zh-CN",
            targetLang: "en-US",
            glossaryPath: "cad_glossary.json"  // CAD 术语词典
        );

        // 3. 遍历所有文本实体
        foreach (var text in cadDoc.GetAllTextEntities())
        {
            // 过滤掉纯数字和符号(无需翻译)
            if (string.IsNullOrWhiteSpace(text.Content) ||
                text.Content.All(c => char.IsDigit(c) || char.IsPunctuation(c)))
                continue;

            // 翻译并替换
            var translated = engine.Translate(text.Content);
            text.Content = translated;

            // 保留原始文字样式、对齐方式、图层
            // TongWen 自动处理多行文本(MText)的格式保留
        }

        // 4. 翻译属性块(Block Attributes)中的文字
        foreach (var block in cadDoc.GetAllBlockReferences())
        {
            foreach (var attr in block.GetAttributes())
            {
                if (!string.IsNullOrWhiteSpace(attr.TextString))
                {
                    attr.TextString = engine.Translate(attr.TextString);
                }
            }
        }

        // 5. 翻译尺寸标注文字(Dimensions)
        foreach (var dim in cadDoc.GetAllDimensions())
        {
            if (!string.IsNullOrWhiteSpace(dim.DimensionText))
            {
                dim.DimensionText = engine.Translate(dim.DimensionText);
            }
        }

        // 6. 保存翻译后的图纸
        cadDoc.SaveAs(outputPath);

        // 7. 生成翻译报告
        engine.GenerateReport("translation_report.html");
    }
}

// 调用
var translator = new BracketTranslator();
translator.TranslateDrawing("bracket_drawing.dwg", "bracket_drawing_en.dwg");

CAD 术语词典(cad_glossary.json)示例

{
  "支架": "Bracket",
  "安装孔": "Mounting Hole",
  "倒角": "Chamfer",
  "沉头孔": "Countersunk Hole",
  "剖视图": "Section View",
  "俯视图": "Top View",
  "技术要求": "Technical Requirements",
  "未注公差": "Unspecified Tolerance",
  "表面粗糙度": "Surface Roughness",
  "材料": "Material",
  "热处理": "Heat Treatment",
  "表面处理": "Surface Treatment",
  "图纸编号": "Drawing Number",
  "审核": "Reviewed By",
  "批准": "Approved By"
}

CAD 全链路总结:FreeCAD 建模 → OCCT 分析 → TongWen 翻译,这三个阶段分别由三个独立的技能覆盖。在实际项目中,你可能还会插入 AutoCAD 的二次开发(ifoxcad/lightningcad)用于图纸的批量处理和标注管理。第六章(CAD 技能详解)涵盖了 11 个 CAD 相关技能的完整 API 参考,可作为工作流各阶段的速查手册。


10.4 AI 开发全链路:Dify → OpenCode → Superpowers

opengis-skills 不只是让 AI 帮你写 GIS/CAD 的代码,它还提供了一系列 AI 工具本身的技能——帮你构建 AI 应用、管理 AI 编码代理、以及用地道的工程方法论提升 AI 辅助编程的质量。本节展示这三个维度如何组合使用。

10.4.1 场景设定

需求:构建一个 GIS 知识问答机器人(用 Dify)→ 开发配套的后端 API(用 OpenCode + Furion)→ 整个开发过程遵循 TDD 和 Code Review 方法论(用 Superpowers)

10.4.2 第一阶段:构建知识库(dify)

AI 加载技能@ai/dify/SKILL.md

第一步是让问答机器人”懂”GIS。做法的核心是 RAG(检索增强生成):将 GDAL、GeoServer、PostGIS 等工具的官方文档切成若干个语义片段,存入 Dify 的知识库。当用户提问时,Dify 会从知识库中检索最相关的片段,与用户问题一起拼成 Prompt 发给 LLM,从而让 LLM 能够基于真实文档作答,而不是”胡编”。

操作流程

1. 在 Dify 中创建"GIS 知识库"
   - 索引模式:高质量模式(向量 + 关键词混合检索)
   - 分段模式:按分隔符(## 标题)分段
   - 向量化模型:BGE-M3(对中文 GIS 文档效果最好)

2. 上传文档
   - GDAL 官方文档的矢量/栅格部分
   - GeoServer 用户手册中关于 WMS/WFS 配置的章节
   - PostGIS 官方手册的空间函数参考
   - OpenLayers API 文档的关键部分

3. 配置检索参数
   - TopK: 4(返回最相关的 4 个分段)
   - Score 阈值: 0.6(低于此分数的分段不返回)
   - 重排序: 启用(用 BGE-Reranker 二次排序)

4. 创建 Chatbot 应用
   - 类型:Chatbot(多轮对话)
   - 关联知识库:GIS 知识库
   - 系统提示词(System Prompt):

系统提示词示例

你是 GIS 助手,基于知识库中的官方文档回答问题。
规则:
1. 优先引用知识库中的内容,如果知识库中没有相关信息,明确告知用户
2. 回答中包含代码或命令时,使用代码块并注明语言
3. 涉及坐标系时,同时给出 EPSG 代码和名称
4. 涉及 GDAL 命令时,给出具体可执行的命令,不要只描述概念
5. 如果用户问的问题知识库中有多个版本(如 GDAL 3.6 vs 3.9 的区别),明确指出版本差异
6. 用中文回答,但 API 名称、命令参数保留英文原文

测试问答示例

用户:ogrinfo 命令怎么查看 GeoJSON 文件的字段信息?

机器人(基于知识库检索结果生成):
使用 `ogrinfo -so input.geojson` 查看字段信息摘要。

`-so` 参数表示"summary only"(仅摘要),会显示:
- 图层名称和要素数量
- 几何类型
- 每个字段的名称、类型、宽度
- 坐标系信息

如果需要 JSON 格式输出(便于程序解析),使用:
  ogrinfo -so -json input.geojson

如果需要查看所有图层的详细信息,使用:
  ogrinfo -al input.gpkg

参考:GDAL 文档 ogrinfo 章节。

10.4.3 第二阶段:API 开发(opencode + furion)

AI 加载技能@ai/opencode/SKILL.md + @csharp/furion/SKILL.md

有了知识库问答机器人作为”外部用户入口”,现在需要一个后端 API 来提供数据服务——例如接收 Shapefile 上传、调用 GDAL 转换为 GeoJSON 并返回。

OpenCode 是一个终端 AI 编码代理,它可以被配置为加载项目中的 opengis-skills 技能文件,然后在编码过程中自动参考这些专业知识。结合 Furion(一个 .NET 后端框架),可以快速搭建一个功能完整的 GIS 数据服务。

OpenCode 配置:在项目的 .opencode/AGENTS.md 中引用相关技能:

# AGENTS.md — OpenCode 配置

本项目是一个 GIS 数据处理 API,使用以下技能:
- @gis/gdal/SKILL.md — GDAL 命令行和编程接口
- @gis/postgis/SKILL.md — PostGIS 空间数据库
- @csharp/furion/SKILL.md — Furion .NET 后端框架
- @ai/superpowers-zh/SKILL.md — AI 编程方法论

对话示例

用户:@csharp/furion/SKILL.md 帮我创建一个 GIS 数据转换 API 控制器,
      接收 Shapefile 上传,用 GDAL 转换为 GeoJSON 并返回

AI:[已加载 furion 技能]
基于 Furion 的控制器模式,以下是完整实现...
// GisConversionController.cs
// Furion 框架 + GDAL 集成

using Furion;
using Furion.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using System.IO.Compression;

namespace GisApi.Controllers;

[ApiController]
[Route("api/gis")]
public class GisConversionController : ControllerBase
{
    private readonly ILogger<GisConversionController> _logger;

    public GisConversionController(ILogger<GisConversionController> logger)
    {
        _logger = logger;
    }

    /// <summary>
    /// 上传 Shapefile 压缩包,转换为 GeoJSON 返回
    /// </summary>
    [HttpPost("shp-to-geojson")]
    public async Task<IActionResult> ConvertShapefileToGeoJson(IFormFile file)
    {
        // 1. 参数校验
        if (file == null || file.Length == 0)
            return BadRequest("请上传文件");

        if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
            return BadRequest("请上传 .zip 格式的 Shapefile 压缩包");

        var tempDir = Path.Combine(Path.GetTempPath(), $"gis_{Guid.NewGuid():N}");
        var outputPath = Path.Combine(tempDir, "output.geojson");

        try
        {
            // 2. 解压上传文件
            Directory.CreateDirectory(tempDir);
            var zipPath = Path.Combine(tempDir, file.FileName);

            await using (var stream = new FileStream(zipPath, FileMode.Create))
            {
                await file.CopyToAsync(stream);
            }

            ZipFile.ExtractToDirectory(zipPath, tempDir);

            // 3. 查找 .shp 文件
            var shpFile = Directory.GetFiles(tempDir, "*.shp").FirstOrDefault();
            if (shpFile == null)
                return BadRequest("压缩包中未找到 .shp 文件");

            // 4. 调用 GDAL ogr2ogr 进行格式转换
            var processInfo = new ProcessStartInfo
            {
                FileName = "ogr2ogr",
                Arguments = $"-f GeoJSON \"{outputPath}\" \"{shpFile}\" -t_srs EPSG:4326",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            };

            using var process = Process.Start(processInfo);
            if (process == null)
                return StatusCode(500, "无法启动 GDAL 进程");

            var stderr = await process.StandardError.ReadToEndAsync();
            await process.WaitForExitAsync();

            if (process.ExitCode != 0)
            {
                _logger.LogError("GDAL 转换失败: {Error}", stderr);
                return StatusCode(500, $"GDAL 转换失败: {stderr}");
            }

            // 5. 读取生成的 GeoJSON 并返回
            if (!System.IO.File.Exists(outputPath))
                return StatusCode(500, "GeoJSON 文件未生成");

            var geoJsonContent = await System.IO.File.ReadAllTextAsync(outputPath);
            return Content(geoJsonContent, "application/json", System.Text.Encoding.UTF8);
        }
        finally
        {
            // 6. 清理临时文件
            if (Directory.Exists(tempDir))
            {
                try { Directory.Delete(tempDir, true); } catch { /* 清理失败不阻塞响应 */ }
            }
        }
    }

    /// <summary>
    /// 查询 GeoJSON 的基本信息(调用 ogrinfo)
    /// </summary>
    [HttpPost("geojson-info")]
    public async Task<IActionResult> GetGeoJsonInfo(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return BadRequest("请上传文件");

        var tempFile = Path.GetTempFileName();
        try
        {
            await using (var stream = new FileStream(tempFile, FileMode.Create))
            {
                await file.CopyToAsync(stream);
            }

            var processInfo = new ProcessStartInfo
            {
                FileName = "ogrinfo",
                Arguments = $"-so -json \"{tempFile}\"",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true
            };

            using var process = Process.Start(processInfo);
            if (process == null)
                return StatusCode(500, "无法启动 GDAL 进程");

            var stdout = await process.StandardOutput.ReadToEndAsync();
            var stderr = await process.StandardError.ReadToEndAsync();
            await process.WaitForExitAsync();

            if (process.ExitCode != 0)
                return StatusCode(500, $"ogrinfo 执行失败: {stderr}");

            return Content(stdout, "application/json", System.Text.Encoding.UTF8);
        }
        finally
        {
            if (System.IO.File.Exists(tempFile))
                System.IO.File.Delete(tempFile);
        }
    }
}

安全提示:生产环境中,直接调用外部命令行工具(Process.Start)存在命令注入风险。务必对用户输入做严格的白名单校验(如文件扩展名、参数内容),并考虑使用 Docker 容器隔离 GDAL 执行环境。

10.4.4 第三阶段:方法论落地(superpowers-zh)

AI 加载技能@ai/superpowers-zh/SKILL.md

Superpowers 是一套 AI 编程方法论 Skills 集合,包含 TDD(测试驱动开发)、Code Review、系统化调试、头脑风暴等流程。它不写代码,而是告诉 AI”怎么写代码”——在什么阶段做什么事、以什么顺序验证。

在 GIS API 开发完成后,用 Superpowers 做质量保障:

用户:@ai/superpowers-zh/SKILL.md 对刚才写的 GisConversionController 做一下 Code Review

AI:[加载 code-review 技能]
从以下维度审查代码:

1. 安全性:
   - 文件上传缺少大小限制,建议添加 RequestSizeLimit 属性
   - 命令注入风险:shpFile 路径来自用户上传的文件名,建议对文件名做正则校验
   - 临时文件清理的 try/catch 是空块——失败时文件残留在服务器上

2. 错误处理:
   - GDAL 进程超时:没有设置 Process.Timeout,如果 GDAL 挂起会永久阻塞
   - 大文件处理:整个 GeoJSON 读入内存返回——对于大文件应改为流式传输

3. 可测试性:
   - 直接依赖 Process.Start,单元测试难以 Mock
   - 建议抽象一个 IGdalRunner 接口,便于测试时注入 Mock

4. API 设计:
   - ConvertShapefileToGeoJson 同时做了"上传→转换→返回"三件事
   - 对于大文件,建议拆分为:上传返回任务ID + 轮询查询结果

建议的改进优先级:
P0(安全问题):添加文件大小限制、命令注入防护
P1(稳定性):添加进程超时
P2(性能):大文件流式传输
P3(架构):抽象 IGdalRunner

方法论的价值:Superpowers 不替换你的技术判断力,但它提供了一套结构化的检查清单——确保你不会在兴奋地完成功能后,忽略了安全、性能、可测试性这些工程基础。在实际项目中,这套方法论可以和 OpenCode 编码代理形成闭环:先用 Superpowers 做头脑风暴设计架构,用 OpenCode 执行编码,再用 Superpowers 做 Code Review 检查质量。


10.5 .NET 企业开发全链路:Furion → SqlSugar → NPOI → Reactor

这是面向 .NET 企业级后端开发的全链路线。四个技能对应企业后台系统的四个典型需求:RESTful API 框架、ORM 数据库操作、Office 文档生成、代码保护。

10.5.1 场景设定

需求:开发一个 GIS 数据管理后台,支持查询 POI 列表(分页+筛选)、按分类统计、导出 Excel 报表、最终编译发布时用 .NET Reactor 保护代码

10.5.2 完整实现

AI 加载技能:AI 依次加载 @csharp/furion/SKILL.md@csharp/sqlsugar/SKILL.md@csharp/npoi/SKILL.md@csharp/reactor/SKILL.md

// PoiService.cs — 基于 SqlSugar ORM 的数据服务
// Furion 框架下的服务层

using Furion.DependencyInjection;
using SqlSugar;
using GisApi.Models;

namespace GisApi.Services;

public class PoiService : ITransient
{
    private readonly ISqlSugarClient _db;

    public PoiService(ISqlSugarClient db)
    {
        _db = db;
    }

    /// <summary>
    /// 分页查询 POI 列表,支持按分类和密度筛选
    /// </summary>
    public async Task<SqlSugarPagedList<PoiDto>> QueryPagedAsync(PoiQueryInput input)
    {
        var query = _db.Queryable<Poi>();

        // 分类筛选
        if (!string.IsNullOrWhiteSpace(input.Category))
        {
            query = query.Where(p => p.Category == input.Category);
        }

        // 密度筛选
        if (input.MinNearbyCount.HasValue)
        {
            query = query.Where(p => p.NearbyCount >= input.MinNearbyCount.Value);
        }

        if (input.MaxNearbyCount.HasValue)
        {
            query = query.Where(p => p.NearbyCount <= input.MaxNearbyCount.Value);
        }

        // 关键词搜索
        if (!string.IsNullOrWhiteSpace(input.Keyword))
        {
            query = query.Where(p => p.Name.Contains(input.Keyword) ||
                                     p.Address.Contains(input.Keyword));
        }

        // 排序(默认按密度降序)
        var orderField = input.OrderBy switch
        {
            "name" => nameof(Poi.Name),
            "category" => nameof(Poi.Category),
            "nearby_count" => nameof(Poi.NearbyCount),
            _ => nameof(Poi.NearbyCount)
        };

        query = input.OrderDesc
            ? query.OrderByDescending(orderField)
            : query.OrderBy(orderField);

        // 分页
        var total = 0;
        var list = await query
            .Select(p => new PoiDto
            {
                Id = p.Id,
                Name = p.Name,
                Category = p.Category,
                Address = p.Address,
                NearbyCount = p.NearbyCount,
                Longitude = p.Longitude,
                Latitude = p.Latitude
            })
            .ToPageListAsync(input.Page, input.PageSize, total);

        return new SqlSugarPagedList<PoiDto>
        {
            Items = list,
            Total = total,
            Page = input.Page,
            PageSize = input.PageSize
        };
    }

    /// <summary>
    /// 按分类统计 POI 数量和平均密度
    /// </summary>
    public async Task<List<CategoryStatDto>> GetCategoryStatsAsync()
    {
        return await _db.Queryable<Poi>()
            .GroupBy(p => p.Category)
            .Select(g => new CategoryStatDto
            {
                Category = g.Category,
                Count = SqlFunc.AggregateCount(g.Id),
                AvgDensity = SqlFunc.AggregateAvg(g.NearbyCount),
                MaxDensity = SqlFunc.AggregateMax(g.NearbyCount)
            })
            .OrderByDescending(s => s.Count)
            .ToListAsync();
    }
}
// PoiExportService.cs — 基于 NPOI 的 Excel 导出
// 将 POI 查询结果导出为 .xlsx 文件

using Furion.DependencyInjection;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
using NPOI.SS.Util;

namespace GisApi.Services;

public class PoiExportService : ITransient
{
    private readonly PoiService _poiService;

    public PoiExportService(PoiService poiService)
    {
        _poiService = poiService;
    }

    /// <summary>
    /// 导出 POI 数据为 Excel 文件并返回文件路径
    /// </summary>
    public async Task<string> ExportToExcelAsync(PoiQueryInput input)
    {
        // 查询数据(不分页,全量导出)
        input.Page = 1;
        input.PageSize = 100000;
        var result = await _poiService.QueryPagedAsync(input);

        // 创建 Excel 工作簿
        var workbook = new XSSFWorkbook();
        var sheet = workbook.CreateSheet("POI数据");

        // ====== 标题行样式 ======
        var headerStyle = workbook.CreateCellStyle();
        var headerFont = workbook.CreateFont();
        headerFont.IsBold = true;
        headerFont.FontHeightInPoints = 12;
        headerStyle.SetFont(headerFont);
        headerStyle.FillForegroundColor = NPOI.HSSF.Util.HSSFColor.Grey25Percent.Index;
        headerStyle.FillPattern = FillPattern.SolidForeground;
        headerStyle.BorderBottom = BorderStyle.Medium;
        headerStyle.Alignment = HorizontalAlignment.Center;

        // ====== 数据行样式 ======
        var dataStyle = workbook.CreateCellStyle();
        dataStyle.BorderBottom = BorderStyle.Thin;
        dataStyle.Alignment = HorizontalAlignment.Left;

        // 密度高亮样式
        var highDensityStyle = workbook.CreateCellStyle();
        highDensityStyle.CloneStyleFrom(dataStyle);
        var highFont = workbook.CreateFont();
        highFont.Color = NPOI.HSSF.Util.HSSFColor.Red.Index;
        highFont.IsBold = true;
        highDensityStyle.SetFont(highFont);

        // ====== 表头 ======
        var headers = new[] { "序号", "名称", "分类", "地址", "500m内POI数", "经度", "纬度" };
        var widths = new[] { 8, 30, 15, 40, 15, 14, 14 };

        var headerRow = sheet.CreateRow(0);
        for (int i = 0; i < headers.Length; i++)
        {
            var cell = headerRow.CreateCell(i);
            cell.SetCellValue(headers[i]);
            cell.CellStyle = headerStyle;
            sheet.SetColumnWidth(i, widths[i] * 256); // NPOI 列宽单位
        }

        // ====== 数据行 ======
        for (int i = 0; i < result.Items.Count; i++)
        {
            var item = result.Items[i];
            var row = sheet.CreateRow(i + 1);
            var isHighDensity = item.NearbyCount > 50;

            void SetCell(int col, object value)
            {
                var cell = row.CreateCell(col);
                if (value is string s) cell.SetCellValue(s);
                else if (value is int n) cell.SetCellValue(n);
                else if (value is double d) cell.SetCellValue(d);

                cell.CellStyle = isHighDensity ? highDensityStyle : dataStyle;
            }

            SetCell(0, i + 1);
            SetCell(1, item.Name);
            SetCell(2, item.Category);
            SetCell(3, item.Address);
            SetCell(4, item.NearbyCount);
            SetCell(5, item.Longitude);
            SetCell(6, item.Latitude);
        }

        // ====== 冻结首行 ======
        sheet.CreateFreezePane(0, 1);

        // ====== 自动筛选 ======
        sheet.SetAutoFilter(new CellRangeAddress(0, result.Items.Count, 0, headers.Length - 1));

        // ====== 保存文件 ======
        var outputDir = Path.Combine(AppContext.BaseDirectory, "exports");
        Directory.CreateDirectory(outputDir);
        var fileName = $"POI_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx";
        var filePath = Path.Combine(outputDir, fileName);

        await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
        workbook.Write(fileStream);

        return filePath;
    }
}
// PoiController.cs — API 控制器
// Furion 控制器的标准模式

using Furion.DynamicApiController;
using Microsoft.AspNetCore.Mvc;

namespace GisApi.Controllers;

[ApiController]
[Route("api/poi")]
public class PoiController : IDynamicApiController
{
    private readonly PoiService _poiService;
    private readonly PoiExportService _exportService;

    public PoiController(PoiService poiService, PoiExportService exportService)
    {
        _poiService = poiService;
        _exportService = exportService;
    }

    /// <summary>
    /// 分页查询 POI 列表
    /// </summary>
    [HttpGet("list")]
    public async Task<IActionResult> GetPagedList([FromQuery] PoiQueryInput input)
    {
        var result = await _poiService.QueryPagedAsync(input);
        return Ok(new { code = 200, data = result });
    }

    /// <summary>
    /// 按分类统计
    /// </summary>
    [HttpGet("stats")]
    public async Task<IActionResult> GetStats()
    {
        var stats = await _poiService.GetCategoryStatsAsync();
        return Ok(new { code = 200, data = stats });
    }

    /// <summary>
    /// 导出 Excel
    /// </summary>
    [HttpGet("export")]
    public async Task<IActionResult> Export([FromQuery] PoiQueryInput input)
    {
        var filePath = await _exportService.ExportToExcelAsync(input);
        var bytes = await System.IO.File.ReadAllBytesAsync(filePath);
        return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                    Path.GetFileName(filePath));
    }
}

.NET Reactor 代码保护(发布阶段的最后一步):

<!-- reactor_config.nrproj — .NET Reactor 项目配置 -->
<ReactorProject>
  <Assembly>
    <MainAssembly>GisApi.dll</MainAssembly>
    <!-- 保护选项 -->
    <NecroBit>true</NecroBit>              <!-- 代码虚拟化 -->
    <Obfuscation>true</Obfuscation>         <!-- 名称混淆 -->
    <ControlFlowObfuscation>3</ControlFlowObfuscation> <!-- 控制流混淆(级别3) -->
    <StringEncryption>true</StringEncryption> <!-- 字符串加密 -->
    <AntiILDASM>true</AntiILDASM>           <!-- 反反编译 -->
    <AntiDebug>true</AntiDebug>             <!-- 反调试 -->
    <!-- 排除项(公开 API 控制器的方法名不混淆,保证路由正常) -->
    <Exclusions>
      <Namespace name="GisApi.Controllers" obfuscate="false" />
    </Exclusions>
  </Assembly>
</ReactorProject>
# 命令行保护
dotnet_reactor -project reactor_config.nrproj

.NET 全链路总结:Furion 提供 RESTful API 框架 → SqlSugar 提供高效的 ORM 和查询能力 → NPOI 实现 Excel 报表导出(不依赖 Office 安装)→ .NET Reactor 在发布前保护知识产权。这四个技能配合使用,可以在一天内从零搭建一个完整的 GIS 数据管理后台。opengis-skills 的 C# 分类下还有更多技能(如 admin-net-frontend 前端模板、admin-net-backend 后端模板),可以进一步加速开发。


10.6 桌面自动化全链路:Go → RobotGo → RobotGo-Flow

GIS 和 CAD 之外,opengis-skills 还提供了 Go 语言的桌面自动化能力。这在需要”自动化操作 GIS 桌面软件”(如 ArcGIS、QGIS 的 GUI 版本)时非常有用——有些老旧系统只有桌面客户端,没有 API 接口,只能通过模拟鼠标键盘来操作。

10.6.1 场景设定

需求:用 Go 编写一个数据采集脚本,定时打开 QGIS Desktop 执行一系列操作(加载地图、截图、导出),用 RobotGo 实现 GUI 自动化,用 RobotGo-Flow 编排多个自动化步骤

10.6.2 技能组合

技能 角色 关键能力
go 编程语言 Go 语言开发环境、编译、包管理
robotgo GUI 自动化 鼠标控制、键盘输入、屏幕截图、窗口操作
robotgo-flow 流程编排 多步骤自动化脚本编排、条件判断、错误重试

10.6.3 完整示例

// qgis_automation.go
// 自动启动 QGIS、加载地图工程、截图、导出
// 依赖: go get github.com/go-vgo/robotgo

package main

import (
	"fmt"
	"os/exec"
	"time"

	"github.com/go-vgo/robotgo"
)

func main() {
	fmt.Println("=== QGIS 自动截图脚本 ===")

	// 1. 启动 QGIS(假设已安装且 qgis 在 PATH 中)
	cmd := exec.Command("qgis", "--project", "C:/maps/city.qgz")
	if err := cmd.Start(); err != nil {
		panic(fmt.Sprintf("无法启动 QGIS: %v", err))
	}

	// 2. 等待 QGIS 完全加载(根据机器性能调整)
	fmt.Println("等待 QGIS 加载...")
	time.Sleep(15 * time.Second)

	// 3. 激活 QGIS 窗口
	// robotgo 通过窗口标题定位
	pid, err := robotgo.FindIds("QGIS")
	if err != nil || len(pid) == 0 {
		fmt.Println("警告:未找到 QGIS 窗口,尝试通过进程查找...")
		// 降级方案:直接用 pid
	} else {
		robotgo.ActivePID(pid[0])
		time.Sleep(500 * time.Millisecond)
	}

	// 4. 最大化窗口
	robotgo.KeyTap("f11") // QGIS 的 F11 是全屏切换
	time.Sleep(1 * time.Second)

	// 5. 等待地图渲染完成
	time.Sleep(3 * time.Second)

	// 6. 截图保存
	width, height := robotgo.GetScreenSize()
	bitmap := robotgo.CaptureScreen(0, 0, width, height)
	robotgo.SaveBitmap(bitmap, fmt.Sprintf("qgis_screenshot_%s.png",
		time.Now().Format("20060102_150405")))

	fmt.Println("截图已保存")

	// 7. 导出地图为 PDF(模拟菜单操作)
	// QGIS 菜单:项目 → 新建打印布局 → 导出为 PDF
	// 快捷键序列(可能需要根据 QGIS 版本调整)
	robotgo.KeyTap("p", "ctrl") // 打开打印布局
	time.Sleep(2 * time.Second)

	// 在打印布局中导出 PDF
	robotgo.KeyTap("e", "ctrl") // 导出
	time.Sleep(1 * time.Second)

	// 输入文件名
	robotgo.TypeStr(fmt.Sprintf("export_%s",
		time.Now().Format("20060102_150405")))
	time.Sleep(500 * time.Millisecond)
	robotgo.KeyTap("enter")
	time.Sleep(2 * time.Second)

	// 8. 关闭 QGIS
	robotgo.KeyTap("q", "ctrl")
	time.Sleep(500 * time.Millisecond)

	fmt.Println("=== 脚本执行完成 ===")
}

RobotGo-Flow 流程编排示例(将多个自动化步骤串联为工作流):

// workflow_example.go
// RobotGo-Flow 将多个 GUI 自动化任务编排为工作流
// 场景:每小时自动执行一次 QGIS 截图 + 数据导出

package main

import (
	"fmt"
	"log"
	"time"
)

// Step 定义工作流中的一个步骤
type Step struct {
	Name string
	Run  func() error
}

// Workflow 按顺序执行步骤,任何步骤失败则停止
type Workflow struct {
	Steps      []Step
	RetryCount int
}

func (w *Workflow) Execute() error {
	for i, step := range w.Steps {
		log.Printf("[%d/%d] 执行步骤: %s", i+1, len(w.Steps), step.Name)

		var lastErr error
		for attempt := 0; attempt <= w.RetryCount; attempt++ {
			if attempt > 0 {
				log.Printf("    重试 %d/%d...", attempt, w.RetryCount)
				time.Sleep(time.Duration(attempt) * 2 * time.Second)
			}

			lastErr = step.Run()
			if lastErr == nil {
				break
			}
		}

		if lastErr != nil {
			return fmt.Errorf("步骤 '%s' 失败: %w", step.Name, lastErr)
		}

		log.Printf("    步骤 '%s' 完成", step.Name)
	}
	return nil
}

func main() {
	workflow := Workflow{RetryCount: 2}

	// 定义工作流步骤
	workflow.Steps = []Step{
		{
			Name: "检查 QGIS 是否运行",
			Run: func() error {
				// 检查进程是否存在
				// proc, _ := process.Processes()
				// ... 实现略
				fmt.Println("  QGIS 进程状态正常")
				return nil
			},
		},
		{
			Name: "执行地图截图",
			Run: func() error {
				// 调用 qgisSnapshot() 函数(略)
				fmt.Println("  地图截图完成")
				return nil
			},
		},
		{
			Name: "导出矢量数据为 GeoJSON",
			Run: func() error {
				// 调用 QGIS 菜单:图层 → 另存为...
				// 模拟一系列键盘操作
				fmt.Println("  数据导出完成")
				return nil
			},
		},
		{
			Name: "上传截图到服务器",
			Run: func() error {
				// HTTP POST 上传文件
				fmt.Println("  截图上传完成")
				return nil
			},
		},
		{
			Name: "发送完成通知",
			Run: func() error {
				// 发送邮件或企业微信通知
				fmt.Println("  通知已发送")
				return nil
			},
		},
	}

	if err := workflow.Execute(); err != nil {
		log.Fatalf("工作流执行失败: %v", err)
	}

	log.Println("=== 工作流全部完成 ===")
}

桌面自动化的局限:GUI 自动化是最不可靠的自动化方式——窗口坐标变化、软件版本升级、字体渲染差异都可能让脚本失效。能走 API 就直接走 API(GDAL/ogr2ogr 命令行 > QGIS GUI);API 行不通再上 GUI 自动化。RobotGo 和 RobotGo-Flow 是”最后手段”,不是首选方案。opengis-skills 之所以包含它们,是因为现实中确实存在只能用 GUI 操作的遗留系统。


10.7 跨域组合:GIS + IoT

这是最”科幻”的一个组合——它把 opengis-skills 的两个不同领域(GIS 和 IoT)桥接起来,实现从物理世界到数字地图的完整数据流。

10.7.1 场景设定

需求:用 Raspberry Pi Pico 采集 GPS 坐标和传感器数据 → 通过 MQTT 协议实时上报 → 后端接收后存入 PostGIS 空间数据库 → GeoServer 实时发布 WMS → CesiumJS 做 3D 轨迹展示

这个场景涉及五个技能:picomqttpostgisgeoservercesiumjs

10.7.2 数据流架构

[Raspberry Pi Pico]                          [云端]
   GPS 模块                                   
     ↓                                        
  MQTT Client ──(WiFi)──→ MQTT Broker ──→ 后端服务 ──→ PostGIS
     ↓                                        ↓
  传感器数据                              GeoServer WMS
                                             ↓
                                        CesiumJS 3D 前端

10.7.3 Pico 端代码(MicroPython)

AI 加载技能@iot/pico/SKILL.md + @iot/mqtt/SKILL.md

# pico_gps_publisher.py
# Raspberry Pi Pico W + GPS 模块 + MQTT 上报
# 运行环境: MicroPython on Pico W

import network
import time
import json
from machine import Pin, UART
from umqtt.simple import MQTTClient

# ========== WiFi 连接 ==========
WIFI_SSID = "your_ssid"
WIFI_PASS = "your_password"

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASS)

while not wlan.isconnected():
    time.sleep(0.5)

print("WiFi 已连接:", wlan.ifconfig())

# ========== GPS 模块初始化(UART) ==========
gps_uart = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1))

def parse_nmea(sentence):
    """解析 NMEA GPGGA 语句,提取经纬度"""
    if not sentence.startswith('$GPGGA'):
        return None

    parts = sentence.strip().split(',')
    if len(parts) < 7 or not parts[2] or not parts[4]:
        return None

    # 纬度: ddmm.mmmm → dd.dddddd
    lat_raw = parts[2]
    lat_deg = int(lat_raw[:2])
    lat_min = float(lat_raw[2:])
    lat = lat_deg + lat_min / 60.0
    if parts[3] == 'S':
        lat = -lat

    # 经度: dddmm.mmmm → ddd.dddddd
    lon_raw = parts[4]
    lon_deg = int(lon_raw[:3])
    lon_min = float(lon_raw[3:])
    lon = lon_deg + lon_min / 60.0
    if parts[5] == 'W':
        lon = -lon

    return {"lat": round(lat, 6), "lon": round(lon, 6), "alt": float(parts[9])}

# ========== MQTT 连接 ==========
MQTT_BROKER = "mqtt.example.com"
MQTT_TOPIC = "gps/tracker/pico01"

client = MQTTClient("pico_gps_01", MQTT_BROKER, port=1883)
client.connect()
print("MQTT 已连接")

# ========== 主循环:读取 GPS + 上报 MQTT ==========
gps_buffer = ""

while True:
    if gps_uart.any():
        char = gps_uart.read(1).decode('utf-8', errors='ignore')
        gps_buffer += char

        if char == '\n':
            data = parse_nmea(gps_buffer)
            if data:
                payload = json.dumps({
                    "device_id": "pico01",
                    "timestamp": time.time(),
                    "lat": data["lat"],
                    "lon": data["lon"],
                    "alt": data["alt"]
                })
                client.publish(MQTT_TOPIC, payload)
                print("已上报:", payload)

            gps_buffer = ""

    time.sleep(0.1)

10.7.4 后端接收服务(Python + Psycopg2 + PostGIS)

# mqtt_to_postgis.py
# 订阅 MQTT 消息,解析 GPS 坐标,写入 PostGIS
# pip install paho-mqtt psycopg2-binary

import json
import paho.mqtt.client as mqtt
import psycopg2
from datetime import datetime

# ========== 数据库连接 ==========
conn = psycopg2.connect(
    host="localhost", port=5432,
    database="gis", user="postgres", password="yourpass"
)
conn.autocommit = True

# 确保目标表存在
with conn.cursor() as cur:
    cur.execute("""
        CREATE TABLE IF NOT EXISTS gps_tracks (
            id SERIAL PRIMARY KEY,
            device_id VARCHAR(50),
            timestamp TIMESTAMPTZ,
            geom geometry(Point, 4326),
            altitude DOUBLE PRECISION
        );
        CREATE INDEX IF NOT EXISTS gps_tracks_geom_idx
            ON gps_tracks USING GIST(geom);
        CREATE INDEX IF NOT EXISTS gps_tracks_device_time_idx
            ON gps_tracks(device_id, timestamp);
    """)

# ========== MQTT 回调 ==========
def on_message(client, userdata, msg):
    try:
        data = json.loads(msg.payload.decode('utf-8'))
        device_id = data["device_id"]
        ts = datetime.fromtimestamp(data["timestamp"])
        lat = data["lat"]
        lon = data["lon"]
        alt = data.get("alt", 0)

        with conn.cursor() as cur:
            cur.execute("""
                INSERT INTO gps_tracks (device_id, timestamp, geom, altitude)
                VALUES (%s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s)
            """, (device_id, ts, lon, lat, alt))

        print(f"[{ts}] {device_id}: ({lat}, {lon}) @ {alt}m")

        # 实时发布到 GeoServer(可选:通过 GeoServer REST API 触发图层刷新)
        # 或依赖 GeoServer 的 WMS 时间维度自动更新

    except Exception as e:
        print(f"处理消息失败: {e}")

# 连接 MQTT Broker
client = mqtt.Client()
client.on_message = on_message
client.connect("mqtt.example.com", 1883)
client.subscribe("gps/tracker/#")
print("MQTT 监听中...")
client.loop_forever()

10.7.5 CesiumJS 3D 轨迹展示

AI 加载技能@gis/cesiumjs/SKILL.md

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>GPS 实时轨迹 - CesiumJS</title>
  <script src="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Cesium.js"></script>
  <link href="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Cesium/Widgets/widgets.css" rel="stylesheet">
  <style>
    #cesiumContainer { width: 100%; height: 100vh; margin: 0; padding: 0; }
  </style>
</head>
<body>
  <div id="cesiumContainer"></div>
  <script>
    Cesium.Ion.defaultAccessToken = 'your_ion_token';

    const viewer = new Cesium.Viewer('cesiumContainer', {
      terrain: Cesium.Terrain.fromWorldTerrain(),
      animation: false,
      timeline: false
    });

    // 叠加 GeoServer WMS 图层(POI 数据)
    const poiLayer = viewer.imageryLayers.addImageryProvider(
      new Cesium.WebMapServiceImageryProvider({
        url: 'http://localhost:8080/geoserver/city/wms',
        layers: 'city:poi',
        parameters: {
          transparent: true,
          format: 'image/png'
        }
      })
    );

    // GPS 轨迹数据源(从 PostGIS 通过 API 获取)
    async function loadTrack(deviceId, startTime, endTime) {
      const response = await fetch(
        `/api/gps/track?device=${deviceId}&start=${startTime}&end=${endTime}`
      );
      const data = await response.json();

      // 转换为 Cesium 实体
      data.points.forEach(point => {
        viewer.entities.add({
          position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, point.alt),
          point: {
            pixelSize: 8,
            color: Cesium.Color.fromCssColorString('#00FF88'),
            outlineColor: Cesium.Color.WHITE,
            outlineWidth: 2
          },
          label: {
            text: new Date(point.timestamp).toLocaleTimeString(),
            font: '12px sans-serif',
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            pixelOffset: new Cesium.Cartesian2(0, -12)
          }
        });
      });

      // 绘制轨迹线
      viewer.entities.add({
        polyline: {
          positions: Cesium.Cartesian3.fromDegreesArray(
            data.points.flatMap(p => [p.lon, p.lat])
          ),
          width: 3,
          material: new Cesium.PolylineGlowMaterialProperty({
            glowPower: 0.25,
            color: Cesium.Color.CYAN
          })
        }
      });

      // 飞至轨迹区域
      viewer.flyTo(viewer.entities);
    }

    // 加载最近 1 小时的轨迹
    const now = new Date();
    const oneHourAgo = new Date(now.getTime() - 3600000);
    loadTrack('pico01', oneHourAgo.toISOString(), now.toISOString());

    // 定时刷新(每 30 秒拉取新数据)
    setInterval(() => {
      const refreshTime = new Date(Date.now() - 60000); // 最近 1 分钟
      loadTrack('pico01', refreshTime.toISOString(), new Date().toISOString());
    }, 30000);
  </script>
</body>
</html>

跨域组合的价值:GIS + IoT 的跨域组合展示了 opengis-skills 的真正威力——它不仅覆盖单一领域,还能桥接不同领域的知识。从 Pico 的 MicroPython GPIO 编程到 CesiumJS 的 WebGL 3D 可视化,这中间的五个技能文件覆盖了一个物理世界到数字孪生的完整技术栈。对于搞智慧城市、数字孪生、物联网地图可视化的团队,这个技能组合可以直接作为技术方案的骨架。


10.8 工作流模板库

以上七个实战场景展示了具体的对话流程和代码示例。这一节将高频组合提炼为可直接套用的模板。每个模板是一张”技能配料表”——你只需要告诉 AI”我按照模板 X 来”,AI 就知道该依次加载哪些技能、每个阶段做什么。

模板一:GIS 数据分析

适用场景:有原始空间数据(Shapefile/GeoJSON/GeoPackage),需要做格式转换、入库、空间分析和统计。

技能加载顺序

gdal → postgis → geopandas → jts/shapely

阶段划分

阶段 技能 任务 输出
1. 预处理 gdal 格式转换、投影变换、数据筛选 标准化的输入数据
2. 入库 postgis 数据入库、空间索引、SQL 分析 分析结果表
3. 分析 geopandas 缓冲区、叠加、空间统计 GeoDataFrame + 图表
4. 几何 shapelyjts 自定义几何运算 几何对象

典型耗时(AI 辅助下):30 分钟 - 2 小时(取决于数据量和分析复杂度)

模板二:地图可视化

适用场景:已有空间数据或数据库,需要发布为 Web 地图服务并做前端展示。

技能加载顺序

geopandas/geoserver → openlayers/cesiumjs

阶段划分

阶段 技能 任务 输出
1. 服务 geoserver + geoserver-rest-api 发布 WMS/WFS 图层,配置样式 地图服务 URL
2. 2D openlayers 2D Web 地图,点击查询,图层控制 HTML/JS 前端页面
3. 3D cesiumjs(可选) 3D 地球,3D Tiles,地形叠加 3D Web 应用

选型建议:OpenLayers 适合传统 2D 地图(电子地图、数据大屏),CesiumJS 适合三维场景(数字孪生、倾斜摄影、BIM+GIS)。

模板三:CAD 设计

适用场景:机械零件设计、建筑模型构建、工程图纸生成。

技能加载顺序

freecad → occt → clipper2

阶段划分

阶段 技能 任务 输出
1. 建模 freecad Python 脚本参数化建模 .FCStd 项目文件 + .STEP 导出
2. 分析 occt STEP 加载、布尔验证、碰撞检测 分析报告
3. 截面 clipper2 二维截面裁剪、偏移、布尔运算 截面分析结果

模板四:AutoCAD 开发

适用场景:基于 AutoCAD 平台的二次开发,包括代码生成、标注管理和图纸翻译。

技能加载顺序

ifoxcad → lightningcad → tongwen

阶段划分

阶段 技能 任务 输出
1. 基础 ifoxcad 封装好的 AutoCAD .NET API,快速二次开发 .NET 程序集
2. 标注 lightningcad 快速标注管理、编号、批量处理 标注配置
3. 翻译 tongwen 图纸标注中英文翻译 翻译后图纸

模板五:GIS 后端 API

适用场景:搭建 GIS 数据处理和管理的后端 API 服务(.NET 技术栈)。

技能加载顺序

furion → sqlsugar → postgis → npoi

阶段划分

阶段 技能 任务 输出
1. 框架 furion RESTful API 控制器、依赖注入、动态 API 可运行的 API 项目
2. 数据 sqlsugar + postgis 数据库连接、ORM 查询、空间查询 数据访问层
3. 报表 npoi Excel/Word 导出 报表文件

扩展技能admin-net-backend(管理后台后端模板)、admin-net-frontend(管理后台前端模板)、reactor(代码保护)

模板六:AI 应用开发

适用场景:从零构建一个基于 LLM 的应用,覆盖知识库、后端 API 和前端。

技能加载顺序

dify → opencode → superpowers-zh

阶段划分

阶段 技能 任务 输出
1. AI 应用 dify 知识库构建、Chatbot/Workflow 编排 可用的 AI 应用
2. 后端 opencode + furion 编码代理 + 后端框架,自动完成 API 开发 后端服务代码
3. 质量 superpowers-zh TDD 开发、Code Review、系统化调试 质量保障

模板七:数据处理管道

适用场景:批量 Spatial ETL(Extract, Transform, Load)数据处理。

技能加载顺序

gdal → qgis-process → geopipe-agent

阶段划分

阶段 技能 任务 输出
1. 手工 gdal 少量数据的快速命令行处理 处理后的数据
2. 批量 qgis-process 200+ 算法的 Headless 批量处理 批量处理脚本
3. 管道 geopipe-agent AI 原生 YAML 声明的 ETL 流水线 可复用的管道定义

模板八:文档翻译

适用场景:批量翻译技术文档(GIS 手册、CAD 图纸)并保留格式。

技能加载顺序

docutranslate → tongwen

阶段划分

阶段 技能 任务 输出
1. 文档 docutranslate PDF/Word/Markdown 技术文档翻译 翻译后文档
2. 图纸 tongwen CAD 图纸标注翻译 翻译后 DWG/DXF

模板使用建议:模板不是死板的教条。实际项目中,你可能会组合多个模板——例如先用”模板一”做数据分析,再用”模板二”做可视化发布,中间用”模板五”搭一个 API 做数据中转。模板的价值在于减少”选择困难”——当你知道要做什么但不确定先加载哪个技能时,模板给出了经过验证的推荐顺序。


10.9 本章小结

本章通过七个实战场景和八个工作流模板,展示了 opengis-skills 从”单个技能的知识库”到”多技能协作的生产力工具”的跃迁。回顾核心要点:

  1. 技能是积木,工作流是建筑图。单个技能解决的是一个工具或库的 API 查询问题;工作流组合解决的是一个”从需求到交付”的完整项目流程。前者是知识,后者是能力。

  2. 三层索引在工作流中的价值:它不是一次性的加载,而是动态的按需切换——AI 在每个阶段只加载当前需要的技能,保持上下文的轻量和准确。

  3. 领域交叉是最大亮点:GIS + IoT(10.7 节)展示了跨域组合的威力。在实际项目中,很少有项目只涉及一个领域——智慧城市、数字孪生、工业互联网,都需要 GIS、IoT、3D、后端的混合技能组合。

  4. 模板是起点,不是终点:10.8 节的八个模板覆盖了最常见的技能组合模式。在你的具体项目中,可以按需裁剪、扩展、混搭。关键是理解每个技能在整个链路中的角色和输入/输出关系。

  5. AI 辅助下的效率提升:传统模式下,一个 GIS 全链路项目(数据处理+数据库+服务+前端)可能需要一个 3-4 人的小团队花一周时间——因为每个人的专业领域不同,沟通和协作有摩擦。在 AI + opengis-skills 模式下,一个全栈开发者可以在半天到一天内走通全流程——因为 AI 替代了跨领域文档查阅和部分编码工作。

下一章(第十一章:最佳实践与 FAQ)将汇总来自真实用户的常见问题、踩坑记录和效率提升技巧——帮助你避开使用 opengis-skills 过程中的常见陷阱。


引用说明:本章中引用的所有技能文件(gdal、postgis、geoserver、openlayers、cesiumjs、freecad、clipper2、occt、tongwen、dify、opencode、furion、sqlsugar、npoi、reactor、robotgo、robotgo-flow、pico、mqtt、superpowers-zh 等)均由 znlgis/opengis-skills 仓库维护。各技能的详细 API 参考请查阅本书第五至九章对应小节。