> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livingsecurity.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Power BI

> 使用 Power Query 将 Power BI 连接到 Living Security 公共 API，在 /v1 上构建星型架构模型，并为网络钓鱼场景选择运营数据集或报表数据集。

本指南是将 `/v1` 读取到 Power BI 中的端到端示例，涵盖 Power Query (M) 连接模式、基于 `/v1/activities` 构建的星型架构语义模型、增量刷新，以及何时使用 **reporting** 预览数据集获取按人员统计的网络钓鱼成效。

示例使用美国生产主机（`https://api.us-east-1.livingsecurity.ai/v1`）。对于欧盟组织，请在 `Fn_FetchAll` 和 `Fn_GetJson` 中均使用 `https://api.eu-west-1.livingsecurity.ai/v1`，然后将数据源凭据绑定到该主机。请参阅[区域](/zh-Hans/developers/v1/regions)。

本指南使用带有 API 密钥的已存储 Basic 凭据，无需 OAuth 客户端。有关凭据选项，请参阅[身份验证](/zh-Hans/developers/v1/authentication)；有关访问和设置，请参阅[开发者中心](/zh-Hans/developers/developer-hub/overview)。

## 本示例的适用场景

在以下情况下可使用本示例：

* 通过正确的游标分页和速率限制重试机制，将 `/v1` 列表端点加载到 Power BI 数据集中。
* 构建语义模型，使度量值（如“活跃实体数”“每个活跃实体的活动数”）基于维度计算，而不是对扁平表进行计数。
* 通过增量刷新，将 Power BI 的计划刷新从遍历全部历史数据缩减为较小的每日时间窗口。
* 选择正确的网络钓鱼粒度：活动总计（运营）或按人员统计的结果和类型化事件（reporting 预览）。

## 先决条件

* 具有查询所需权限的 API 密钥：
  * 运营示例：`activity:read`、`entity:read` 和 `simulation:read`。
  * 通过 reporting 数据集获取按人员统计的网络钓鱼数据：`reporting:read`。
    请参阅 [API 密钥](/zh-Hans/developers/developer-hub/api-keys)。
* Power BI Desktop，或可创建语义模型并安排刷新的 Power BI / Fabric 工作区。

## 选择正确的粒度

`/v1` 为网络钓鱼提供了两个相关但不可互换的接口。

| 需求 | 使用 | 权限 | 粒度 |
| - | - | - | - |
| 活动漏斗总计 | `GET /v1/simulations/{id}/results` | `simulation:read` | 每个模拟一行（已送达、已打开、已点击等） |
| 按人员统计的成效（运营） | `GET /v1/simulation_results` | `simulation:read` | 每个实体 × 模拟一行 |
| 按人员统计的成效（BI / 报表） | `GET /v1/reporting/simulation_results` | `reporting:read` | 粒度相同，使用 BI 字段名称（`entity_id`、`simulation_id`），带有 `updated_after` 水位线 |
| 类型化交互事实 | `GET /v1/reporting/simulation_events` | `reporting:read` | 每次交互一条事实（`simulation.link_clicked` 等），`occurred_after` |
| 当前员工队伍维度 | `GET /v1/reporting/entities` | `reporting:read` | 每个实体一个当前行。关联 `reporting.simulation_results.entity_id = reporting.entities.id` |
| 当前实体版本 | `GET /v1/reporting/entity_versions` | `reporting:read` | 每个实体一个无结束时间的当前区间。不存在版本历史表 |
| 当前群组成员关系 | `GET /v1/reporting/cohort_memberships` | `reporting:read` | 每个 `(cohort_id, entity_id)` 一条当前事实。不存在成员关系历史表 |
| 所有活动类型的关联主干 | `GET /v1/activities` | `activity:read` | 每个活动一个事件，嵌套 `participants[]` |

下面的 Power BI 示例查询调用的是**运营**路由（`/v1/simulations`、`/v1/simulations/{id}/results`、`/v1/activities`、`/v1/entities`）。对于按人员统计的网络钓鱼报表，建议优先使用下文的 **reporting** 预览系列。仅被授予 `reporting:read` 的密钥无法调用运营 `/v1/simulation_results`（API 返回 `403`）。

### 难度等级

模拟难度是 `/v1/simulations`（`difficulty_level`）、`/v1/simulation_results`（`difficulty_level`）、`/v1/activities` 属性（`difficulty_tier`）以及 `/v1/reporting/simulation_events`（`difficulty_tier`）上的 1–5 整数。`1` 最简单，`5` 最难。自适应和渐进式模拟没有单一的活动级别取值，因此 `/v1/simulations.difficulty_level` 为 `null`，而结果中每位收件人的 `difficulty_level` 仍可设置。若要映射到旧版的低/中/高导出，请将 `1–2` 视为低，`3` 视为中，`4–5` 视为高。

<Warning>
  请勿针对未记录的仪表板网络调用或旧版精选的 `/api/v1` 公共 API 概述构建新的 BI 集成。请使用开发者中心 API 密钥对组织所在区域的 `/v1` 主机进行身份验证。请参阅[区域](/zh-Hans/developers/v1/regions)。
</Warning>

## 连接模式 (Power Query)

示例中的每个查询都遵循四条规则。每条规则都是由 Power BI 服务强制执行的，而非出于风格偏好。

<AccordionGroup>
  <Accordion title="保持 Web.Contents 基础 URL 为静态，并以 /v1 结尾">
    将所有动态内容放入 `RelativePath` 和 `Query`。`Web.Contents` 的第一个参数必须是以组织所在区域的 `/v1` 根路径结尾的字面字符串：

    * 美国：`https://api.us-east-1.livingsecurity.ai/v1`
    * 欧盟：`https://api.eu-west-1.livingsecurity.ai/v1`

    调用方随后仅传入资源（`"simulations"`，而非 `"v1/simulations"`）。请勿为欧盟组织粘贴美国主机。

    如果第一个参数是通过字符串拼接构建的，Power BI 服务将无法静态识别数据源，计划刷新会失败并出现“动态数据源”错误，即使该查询在 Power BI Desktop 中可以正常运行。
  </Accordion>

  <Accordion title="将 API 密钥作为已存储的 Basic 凭据提供，而不要写入查询文本">
    当 Power BI 在 `/v1` 数据源上提示输入凭据时，请选择 **Basic**，并将 API 密钥粘贴为**用户名**。在**密码**字段中填入占位符，例如 `x`。尽管 API 会忽略该字段，但 Power BI 服务会拒绝空白密码。将密钥作为密码并使用占位符用户名同样可行。

    请勿在 M 中添加 `Authorization` 标头。它在您的桌面上可以运行，但会将密钥固化到查询文件中，并与计划刷新冲突。
  </Accordion>

  <Accordion title="让凭据对话框的连接测试正常运行">
    `GET /v1` 需要身份验证：使用有效密钥时返回 `200`，否则返回 `401`。请将数据源指向 `…/v1`，并且**不要**跳过测试——有效密钥可以保存，已撤销或格式错误的密钥会立即失败。

    旧版示例使用裸主机作为数据源，并告知您跳过测试，因为 `GET /` 始终为 `404`。请将这些模型重新指向 `…/v1`。
  </Accordion>

  <Accordion title="在每个列表查询中显式设置 limit=500">
    每个 `/v1` 列表端点的默认页大小均为 25。保留默认值会使相同数据的请求数成倍增加。查询值必须是文本——在 M 中使用 `limit = "500"`，而不是 `limit = 500`。
  </Accordion>
</AccordionGroup>

### 共享分页函数

示例中的每个查询都调用同一个 `Fn_FetchAll`，该函数会遍历 `next_page_token`，直到其为 `null`（请参阅[分页](/zh-Hans/developers/v1/pagination)）。其主要特性如下：

* **静态 `/v1` 基础 URL，动态 `RelativePath` 和 `Query`。** 这是计划刷新和凭据测试所必需的。
* **`ManualStatusHandling = {429}` 并带有有限次数的重试。** 该函数在 Power Query 公开 `Retry-After` 时读取该值，否则等待 30 秒；在连续收到 5 次 `429` 响应后，会以明确的错误放弃，而不是一直循环直到刷新超时。`5xx` 特意不进行重试——服务器错误应当显现出来，而不是悄悄生成不完整的数据集。
* **60 秒 `Timeout`。** 页面通常在不到一秒内响应，因此一分钟没有响应就意味着请求已失效。
* **在 `Json.Document` 之前使用 `Binary.Buffer`。** 如果不进行缓冲，当混合引擎多次读取响应流时，可能会重新请求该响应流。
* **每次迭代都原样传递 `baseQuery`。** 页面令牌是不透明的已签名游标，绑定到组织、凭据、契约版本以及过滤器和排序的指纹。在遍历过程中更改过滤器会被作为无效令牌拒绝，而不会从第一页重新开始。

```m Fn_FetchAll.pq — base and call shape theme={null}
let
    // Literal /v1 root. Callers pass the resource alone ("simulations").
    // US: "https://api.us-east-1.livingsecurity.ai/v1"
    BaseUrl = "https://api.eu-west-1.livingsecurity.ai/v1",

    Fn_FetchAll = (relativePath as text, baseQuery as record) as list =>
        // Walk next_page_token until null. Full pagination + 429 retry lives
        // in examples/powerbi/queries/Fn_FetchAll.pq.
        ...
in
    Fn_FetchAll
```

### 每个端点的类型化查询

`Fn_FetchAll` 的每个使用方都遵循相同的结构：调用端点，投影固定的列列表，丢弃 `object` 鉴别字段，并将时间戳类型设置为 `datetimezone`，以免日期度量值因刷新容量的时区而悄然偏移。

<CodeGroup>
  ```m Simulations.pq (GET /v1/simulations) theme={null}
  let
      Source = Fn_FetchAll("simulations", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          {
              "id", "object", "name", "description", "status", "vectors",
              "difficulty_level", "difficulty_mode",
              "started_at", "completed_at", "created_at", "updated_at"
          },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Flattened = Table.TransformColumns(
          Dropped,
          {
              {
                  "vectors",
                  each if _ = null then null
                       else Text.Combine(List.Transform(_, Text.From), ", "),
                  type text
              }
          }),

      Typed = Table.TransformColumnTypes(
          Flattened,
          {
              { "id",           type text },
              { "name",         type text },
              { "description",  type text },
              { "status",       type text },
              { "started_at",   type datetimezone },
              { "completed_at", type datetimezone },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```

  ```m Entities.pq (GET /v1/entities) theme={null}
  let
      Source = Fn_FetchAll("entities", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          { "id", "object", "type", "status", "display_name", "email",
            "created_at", "updated_at" },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Typed = Table.TransformColumnTypes(
          Dropped,
          {
              { "id",           type text },
              { "type",         type text },
              { "status",       type text },
              { "display_name", type text },
              { "email",        type text },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```
</CodeGroup>

<Tip>
  在 `Table.FromRecords` 上使用显式的列列表，可在端点返回零行时保持架构稳定。单独使用 `Table.FromRecords({})` 会生成零列的表，之后的每个步骤都会因缺少字段而失败。
</Tip>

## Excel

相同的 Power Query (M) 可在 Excel 的 Get & Transform 中运行——它与 Power BI Desktop 使用相同的引擎。当您需要工作表表格而不是语义模型时，请使用此方式。

在 Excel for Windows（Microsoft 365、Excel 2024、2021、2019 或 2016）上：

1. **Data → Get Data → From Other Sources → Blank Query。**
2. 在 Power Query Editor 中，依次选择 **首页 → 高级编辑器**。先粘贴 `Fn_FetchAll`，然后将该查询重命名为 `Fn_FetchAll`（Query Settings → Name，或在 Queries 窗格中右键单击 → **Rename**）。
3. 对 `Fn_GetJson` 重复上述操作，然后对每个报表查询重复。
4. **首页 → Close & Load → Close & Load To…** 会打开 **Import Data** 对话框。对 `Fn_FetchAll` 和 `Fn_GetJson` 选择 **Only Create Connection**（它们是函数，而不是表）。对于要放在工作表上的每个报表查询，选择 **Table**。
5. 首次刷新时，选择 **Basic**：将 API 密钥作为 **User name**，将 `x` 等占位符作为**密码**。Excel 为每个数据源 URL 存储一份凭据；之后访问相同 `/v1` 主机的查询会重复使用该凭据。

<Note>
  在工作簿中首次创建查询时，**Close & Load To…** 可能呈灰色不可用状态。请使用 **Close & Load**，然后在 Excel 中依次选择 **Data → Queries & Connections**，右键单击该查询，并选择 **Load To**。Microsoft 针对这种情况记录了该路径。
</Note>

在已进行身份验证的数据源上，您无法通过 Power Query 发送 `Living-Security-Version`——这与 Power BI 的限制相同。请固定服务账号，或在查询记录中传入 `api_version`。请参阅[契约版本和已存储凭据](#契约版本和已存储凭据)。

现成的报表查询（列映射以及与先前提供商导出内容的差异）：

<CardGroup cols={3}>
  <Card title="按人员统计的合规情况" href="/zh-Hans/developers/v1/reports/per-person-compliance">
    人员 × 分配粒度的培训注册情况。
  </Card>

  <Card title="电子邮件网络钓鱼日志" href="/zh-Hans/developers/v1/reports/email-phishing-log">
    事件级网络钓鱼交互，涵盖最近 12 个月。
  </Card>

  <Card title="最易受攻击的用户" href="/zh-Hans/developers/v1/reports/most-susceptible-users">
    按人员汇总的 12 个月点击、凭据和报告情况。
  </Card>
</CardGroup>

## Reporting 预览数据集（按人员统计的网络钓鱼）

Reporting 预览系列提供用于按人员统计网络钓鱼的数据集。它需要 `reporting:read` 以及贵组织的报表访问权限。请使用下面的数据集目录查看其架构和数据策略。

查看实时目录：

```text theme={null}
GET /v1/reporting/datasets
GET /v1/reporting/datasets/simulation_results
GET /v1/reporting/datasets/simulation_events
GET /v1/reporting/datasets/entities
GET /v1/reporting/datasets/entity_versions
GET /v1/reporting/datasets/cohort_memberships
```

每个数据集都声明了粒度、主键、关联、水位线、更正和删除策略。

### simulation\_results

* **路径：** `GET /v1/reporting/simulation_results`
* **粒度：** 实体 × 模拟
* **过滤器：** `simulation`、`entity`、`updated_after`，以及 `limit` / `page_token`
* **传输对象：** `reporting.simulation_result`
* **关联键：** `entity_id`、`simulation_id`

```m ReportingSimulationResults.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_results",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id", "status",
            "targeted_at", "delivered_at", "opened_at", "clicked_at",
            "credential_submitted_at", "reported_at",
            "email_subject", "sender_email", "difficulty_level",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "status",        type text },
            { "targeted_at",   type datetimezone },
            { "updated_at",    type datetimezone }
        })
in
    Typed
```

刷新近期分区时，请将 `updated_after` 用作包含边界的下限水位线。可变的结果维度会在相同的 `id` 下原地重述。

### simulation\_events

* **路径：** `GET /v1/reporting/simulation_events`
* **粒度：** 每次观察到的交互对应一条类型化事实
* **过滤器：** `simulation`、`entity`、`event_type`、`occurred_after`，以及 `limit` / `page_token`
* **传输对象：** `reporting.simulation_event`
* **事件类型：** 封闭的公共令牌，例如 `simulation.delivered`、`simulation.opened`、`simulation.link_clicked`、`simulation.credentials_submitted`、`simulation.reported`——而不是原始的 ClickHouse `phishing.simulation.*` 活动类型
* **设备：** `device_type` 是派生的类别（已观察到的令牌包括 `desktop`、`mobile`、`tablet`、`unknown`）。原始 `user_agent` 不在 `/v1` 上。原始 IP 绝不会出现在 `/v1` 上（从不持久化；仅保留加盐的 `ip_hash`）。

```m ReportingSimulationEvents.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_events",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id",
            "event_type", "occurred_at",
            "email_subject", "sender_email", "sender_domain", "difficulty_tier",
            "device_type",
            "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "event_type",    type text },
            { "occurred_at",   type datetimezone }
        })
in
    Typed
```

增量加载事件时，请使用 `occurred_after`。事实仅追加；延迟到达的数据会作为新行。

### entities

* **路径：** `GET /v1/reporting/entities`
* **粒度：** 每个实体一个当前行
* **过滤器：** `type`、`updated_after`，以及 `limit` / `page_token`
* **传输对象：** `reporting.entity`
* **关联键：** `id` / `entity_id`（值相同）

使用 `reporting.simulation_results.entity_id = reporting.entities.id` 将网络钓鱼事实与人员关联。运营 `GET /v1/entities` 仍在 `entity:read` 上，并且属于不同的权限。

```m ReportingEntities.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entities", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status",
            "display_name", "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",          type text },
            { "entity_id",   type text },
            { "type",        type text },
            { "status",      type text },
            { "display_name", type text },
            { "created_at",  type datetimezone },
            { "updated_at",  type datetimezone }
        })
in
    Typed
```

### entity\_versions 和 cohort\_memberships

这两个数据集带有一项明确的仅限当前数据的例外：

> No authoritative version or membership-history table exists. This dataset projects the current row as one open-ended interval (`valid_to` is null, `is_current` is true). `valid_from` is `created_at` or else `updated_at` for entity versions, and the request observation time for cohort memberships. Closed historical intervals are not fabricated.

* **entity\_versions：** `GET /v1/reporting/entity_versions`。过滤器与 entities 相同。`valid_from` 为 `created_at`，否则为 `updated_at`。两个时间戳均为 null 的实体将被省略。
* **cohort\_memberships：** `GET /v1/reporting/cohort_memberships`。过滤器：`cohort`、`entity`、`updated_after`。`updated_at` 是群组的最后更新时间，使增量刷新保持准确。`valid_from` 是请求观察时间。将 `entity_id` 关联到 `reporting.entities.id`。

```m ReportingEntityVersions.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entity_versions", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status", "display_name",
            "valid_from", "valid_to", "is_current",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical }
        })
in
    Typed
```

```m ReportingCohortMemberships.pq theme={null}
let
    Source = Fn_FetchAll("reporting/cohort_memberships", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "cohort_id", "entity_id",
            "valid_from", "valid_to", "is_current",
            "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "cohort_id",  type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical },
            { "updated_at", type datetimezone }
        })
in
    Typed
```

## 基于 /v1/activities 的星型架构语义模型

`/v1/activities` 是组织带时间戳的事件流。它没有数值型度量值——报表中的每个数量都是行数——这使得星型架构成为天然的选择。

```text theme={null}
Dates (dimension)
      │  1
      ▼  ∗
  Activities (fact)   1  ∗  ActivityParticipants (bridge)   ∗  1   Entities (dimension)
```

* **`Activities`** 是真实粒度的事件事实表，每个活动一行。参与者在此处被移除，并重建为独立的表。
* **`ActivityParticipants`** 之所以存在，是因为参与者确实是一对多的，具有 `actor` 和 `target` 等角色。将其展平到 `Activities` 上，要么会重复活动行（使所有计数虚高），要么会丢弃额外的参与者。与 `Activities` 的关系是该模型中唯一的双向关系，因此实体切片器可以作用于事实表。
* **`Entities`** 是用于命名参与者的维度。活动上的参与者行带有 `entity_id` 和角色，但不带显示名称。
* **`Dates`** 在 M 中生成（零次 API 调用），并被标记为模型的日期表。这使得 DAX 时间智能分析（`DATESINPERIOD`、`DATEADD`）得以合法使用。月份和星期名称列在其数字对应列上声明了 `sortByColumn`，以免视觉对象按字母顺序排列月份。

所有度量值均为显式定义（模型设置了 `discourageImplicitMeasures`）：活动总数、活跃实体数、每个活跃实体的活动数、有活动的天数，以及 7 天滚动日均值。

### 桥接表结构

`ActivityParticipants` 遍历 `/v1/activities`，并将 `participants` 数组展开为每个参与者一行：

```m ActivityParticipants.pq theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Selected = Table.SelectColumns(AsTable, { "id", "event_ts", "participants" }),
    Renamed  = Table.RenameColumns(Selected, { { "id", "activity_id" } }),
    Expanded = Table.ExpandListColumn(Renamed, "participants"),
    NonNull  = Table.SelectRows(Expanded, each [participants] <> null),
    Fields   = Table.ExpandRecordColumn(
        NonNull, "participants",
        { "role", "entity_id", "display_name" }),

    Typed = Table.TransformColumnTypes(
        Fields,
        {
            { "activity_id",  type text },
            { "event_ts",     type datetimezone },
            { "role",         type text },
            { "entity_id",    type text },
            { "display_name", type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd)
in
    Filtered
```

`ActivityParticipants` 独立遍历 `/v1/activities`，而不是引用 `Activities` 查询。在 Power BI 中，加载为两张表的两个查询会在刷新时各自独立获取数据。保持它们相互独立，可使服务并行刷新两张表的分区。

## 增量刷新

两个事实粒度的表（`Activities` 和 `ActivityParticipants`）都声明了 Power BI 增量刷新策略：保留 5 年的按年分区，并在每次刷新时重新导入最近 14 天的数据。

其机制是一对 DateTime 参数 `RangeStart` 和 `RangeEnd`，Power BI 服务会在刷新时为每个分区重新绑定它们。每个事实查询都会将这些参数折叠到 API 的 `start_date` 和 `end_date` 过滤器中，因此分区级别的请求量与分区大小成正比，而不是与全部历史数据量成正比。该查询会在两侧各将 API 时间窗口放宽一分钟，并在本地重新应用精确的左闭右开 `[RangeStart, RangeEnd)` 边界，因此分区的正确性不取决于服务器将其边界视为包含还是不包含。

```m Activities.pq — the fold that makes incremental refresh work theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object", "participants" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "activity_type", type text },
            { "event_ts",      type datetimezone },
            { "service_id",    type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd),

    WithDate = Table.AddColumn(
        Filtered, "event_date", each Date.From([event_ts]), type date)
in
    WithDate
```

部署后的首次刷新会应用该策略：完整的历史加载，速度较慢。此后的每次刷新只会处理增量窗口。

在包含约 158,000 个活动和约 2,300 个实体的数据集上的测量结果：

| 刷新 | 耗时 |
| - | - |
| 首次（应用策略，完整历史加载） | \~31 min |
| 此后每次刷新 | **\~15 s** |

<Warning>
  Power BI 经典刷新 REST 端点（仅带有 `notifyOption` 的 `POST .../refreshes`）会悄然忽略刷新策略，并且每次都执行完整遍历。若要通过 REST API 应用该策略，请使用增强刷新：在请求正文中发送 `type`、`commitMode` 和 `applyRefreshPolicy`。由 Power BI 服务计划程序触发的刷新始终会应用策略——此特殊情况仅限于 REST 调用。
</Warning>

<Note>
  14 天的窗口是有意设置得较为宽裕的。活动摄取并非只在当前时间追加：连接器回填会以过去的时间戳写入事件。1–2 天的窗口将永久错过这些事件。
</Note>

## 将刷新窗口扩展到 API

`GET /v1/activities` 接受 Power BI 参数折叠到的 `start_date` 和 `end_date` 过滤器，并在这两个边界之间过滤 `event_ts`。`Entities` 是一个维度，因此本示例每次都对其进行完整刷新，而不是增量刷新。

Reporting 数据集使用各自的水位线（`updated_after`、`occurred_after`），而不是活动日期窗口。

## 刷新期间的速率限制

`/v1` 分别强制执行每个密钥和每个 IP 的预算（请参阅[速率限制](/zh-Hans/developers/v1/rate-limits)）。请遵循 `429` + `Retry-After`；不要在模型中硬编码请求预算。Power BI 刷新有两个重要特性：

* **单个表的遍历是顺序进行的。** 每个页面请求都需要上一页的令牌，因此无论页大小如何，单个表都受延迟限制。
* **并发的表刷新会叠加。** 多个大表并行刷新会触及上限，这就是 `Fn_FetchAll` 中的 429 重试路径非常重要的原因。与其假定并发不受限制，不如减少并行的表数量。

该重试机制已针对真正饱和的限流器进行过验证。当并行工作负载有意将密钥维持在其上限时，运行的刷新仍然完成，并生成了与无竞争基线相同的行数——重试返回的是完整的页面，而不是缺口。

## 契约版本和已存储凭据

`/v1` 接受 `Living-Security-Version` 请求标头，用于固定已发布的契约版本（请参阅 [API 快速入门](/zh-Hans/developers/v1/quickstart)）。当数据源使用已存储凭据时，Power Query 无法发送该标头——只有匿名数据源才允许自定义标头。添加该标头会导致刷新失败，并出现：

```text theme={null}
Expression.Error: The 'Living-Security-Version' header is only supported when
connecting anonymously.
Microsoft.Data.Mashup.ErrorCode = 10348.
```

请将密钥保存在 Power BI 的已存储凭据中，并省略自定义标头。如果请求中没有显式版本，API 会使用服务账号已固定的 `contract_version`；如果未设置固定版本，则使用最新发布的版本。在发布刷新之前，请向您的 API 管理员确认该账号的契约版本。有关受支持的版本，请参阅 [API 变更日志](/zh-Hans/developers/v1/changelog)。

如果刷新开始出现意外行为，请检查 API 的 `Living-Security-Version` 响应标头，以查看实际由哪个契约处理了请求。

## 刷新并验证您的模型

将语义模型发布到您的 Power BI 工作区，将其已存储凭据绑定到区域 `/v1` 数据源，并在启用计划之前运行一次刷新。请检查刷新历史记录中是否存在失败或读取不完整的情况。

使用相同的过滤器，将行数与独立的 API 读取结果进行核对。[分页指南](/zh-Hans/developers/v1/pagination)包含用于读取完整结果集的 Python 和 cURL 示例。仅读取第一页的刷新在小型数据集上可能看起来是成功的；在依赖报表之前，请先比较计数。

## 模型选择

* **活动总计与按人员统计的结果粒度不同。** `SimulationResults` 调用 `GET /v1/simulations/{id}/results`（每次运行一个 GET）。对于按人员统计的网络钓鱼数据，请为 `GET /v1/reporting/simulation_results` 和 `GET /v1/reporting/simulation_events` 添加查询（如果您已持有 `simulation:read`，也可以使用运营 `GET /v1/simulation_results`）。
* **`/v1/simulations` 上的 `vectors` 被展平为以逗号连接的字符串。** 列表类型的列无法作为真正的维度进行切片。如果报表页面需要按向量过滤，请添加模拟到向量的桥接表。

## 后续步骤

<CardGroup cols={2}>
  <Card title="开发者中心" href="/zh-Hans/developers/developer-hub/overview">
    为 Power BI 创建服务账号和 API 密钥。
  </Card>

  <Card title="API 快速入门" href="/zh-Hans/developers/v1/quickstart">
    使用 Basic 身份验证对 `/v1` 请求进行身份验证。
  </Card>

  <Card title="分页" href="/zh-Hans/developers/v1/pagination">
    列表信封、游标令牌，以及遍历过程中的过滤器稳定性。
  </Card>

  <Card title="速率限制" href="/zh-Hans/developers/v1/rate-limits">
    每个密钥和每个 IP 的预算、429 约定以及重试指南。
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.