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

# 通过 Plaid 关联银行账户

使用以下操作让用户通过 **Plaid Link** 将银行账户连接到 Fluz。关联是所有银行账户资金功能的入口：一旦账户连接，您就可以读取其余额和可用支出额度、拉取其交易历史，并在连接后来断开时进行修复——每项功能都在各自的页面中介绍。

Transactional Graph Service (TGS) 提供了一个公开的 GraphQL 封装来对接 Fluz 的 Plaid 集成。identity-service 仍是 Plaid 令牌、银行账户接入、余额、历史交易以及 Plaid webhook 的权威记录系统。TGS 永远不会向您的应用返回 Plaid 访问令牌或公用令牌。

## 认证

使用包含 `MANAGE_PAYMENT` 范围的 Fluz **用户 Bearer 访问令牌** 调用 `/api/v1/graphql`。

Plaid 字段**不**支持 Basic 认证。请先通过标准的 TGS 认证流程获取用户访问令牌，然后使用以下方式调用这些字段：

```http theme={null}
Authorization: Bearer <fluz-user-access-token>
```

## 关联流程速览

1. 创建 Link 令牌（`createPlaidLinkToken`）。
2. 使用该令牌打开 Plaid Link。
3. 在 Plaid 的 `onSuccess` 中，将返回的 `public_token` 发送回 TGS（`completePlaidLink`）。
4. 存储完成响应中的 `platformItemId`——这是后续用于重新关联的安全公共标识符。

### 1. 创建 Link 令牌

```graphql theme={null}
mutation CreatePlaidLinkToken($input: CreatePlaidLinkTokenInput!) {
  createPlaidLinkToken(input: $input) {
    linkToken
    expiration
    requestId
    mode
  }
}
```

```json theme={null}
{
  "input": {}
}
```

对于**原生** Link，请将 `deviceOs` 传为 `IOS` 或 `ANDROID`，以便 identity-service 包含正确的 Plaid OAuth 重定向选项。

```json theme={null}
{
  "input": {
    "deviceOs": "IOS"
  }
}
```

### 2. 打开 Plaid Link

使用返回的 `linkToken` 初始化 Plaid Link。（参见下方 Web SDK 示例。）

### 3. 完成关联

在 Plaid Link 的 `onSuccess` 中，将返回的 `public_token` 发送给 TGS。

```graphql theme={null}
mutation CompletePlaidLink($input: CompletePlaidLinkInput!) {
  completePlaidLink(input: $input) {
    requiresAddress
    bankAccountId
    bankInstitutionAuthId
    newlyLinkedBankInstitutionAuthId
    bankInstitutionName
    platformItemId
    bankAccounts {
      bankInstitutionAuthId
      bankAccountId
      bankName
      lastFour
      type
      subtype
    }
  }
}
```

```json theme={null}
{
  "input": {
    "publicToken": "public-sandbox-..."
  }
}
```

### 4. 存储 `platformItemId`

保存完成响应中的 `platformItemId`。这是后续用于重新关联断开连接时的安全公共标识符。如果未返回，请为该用户调用 `getPlaidBankAccounts` 并从该响应中存储已持久化的 `platformItemId`。

如果 `completePlaidLink` 返回 `requiresAddress: true`，在账户可用之前需要先附加地址——参见下文的**附加地址**。

## Plaid Web SDK 示例

Plaid 的 Web SDK 脚本必须直接从 Plaid 的 CDN 加载。

```html theme={null}
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
```

通过 TGS 创建一个 Link 令牌，使用该令牌初始化 Plaid Link，并在 `onSuccess` 中将 Plaid 返回的 `public_token` 传回 TGS。不带参数调用 `startPlaidLink()` 会开始一次**新的**关联；调用 `startPlaidLink(existingPlatformItemId)` 会开始一次**重新关联**（参见 *Relinking Disconnected Bank Accounts*）。

```javascript theme={null}
const FLUZ_GRAPHQL_URL = '<fluz-api-base-url>/api/v1/graphql';
const fluzUserAccessToken = '<fluz-user-access-token>';

const fluzGraphql = async (query, variables) => {
  const response = await fetch(FLUZ_GRAPHQL_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${fluzUserAccessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query, variables }),
  });

  const body = await response.json();
  if (!response.ok || body.errors?.length) {
    throw new Error(body.errors?.[0]?.message ?? 'Fluz GraphQL request failed');
  }

  return body.data;
};

const createPlaidLinkToken = async (platformItemId) => {
  const data = await fluzGraphql(
    `
      mutation CreatePlaidLinkToken($input: CreatePlaidLinkTokenInput!) {
        createPlaidLinkToken(input: $input) {
          linkToken
          mode
        }
      }
    `,
    { input: { platformItemId } },
  );

  return data.createPlaidLinkToken;
};

const completePlaidLink = async (publicToken, platformItemId) => {
  const data = await fluzGraphql(
    `
      mutation CompletePlaidLink($input: CompletePlaidLinkInput!) {
        completePlaidLink(input: $input) {
          requiresAddress
          bankInstitutionAuthId
          newlyLinkedBankInstitutionAuthId
          bankAccountId
          platformItemId
          bankAccounts {
            bankInstitutionAuthId
            bankAccountId
            bankName
            lastFour
            type
            subtype
          }
        }
      }
    `,
    { input: { publicToken, platformItemId } },
  );

  return data.completePlaidLink;
};

const getPlaidBankAccounts = async (input) => {
  const data = await fluzGraphql(
    `
      query GetPlaidBankAccounts($input: PlaidBankAccountFilterInput) {
        getPlaidBankAccounts(input: $input) {
          platformItemId
          bankInstitutionAuthId
          bankAccountId
          bankInstitutionName
          lastFour
          type
          subtype
        }
      }
    `,
    { input },
  );

  return data.getPlaidBankAccounts;
};

const resolvePlatformItemId = async (completion) => {
  if (completion.platformItemId) return completion.platformItemId;

  const bankInstitutionAuthId =
    completion.bankInstitutionAuthId ?? completion.newlyLinkedBankInstitutionAuthId;
  const accounts = await getPlaidBankAccounts({ bankInstitutionAuthId });
  return accounts[0]?.platformItemId;
};

const startPlaidLink = async (platformItemId) => {
  const { linkToken } = await createPlaidLinkToken(platformItemId);

  const handler = window.Plaid.create({
    token: linkToken,
    onSuccess: async (publicToken, metadata) => {
      const result = await completePlaidLink(publicToken, platformItemId);
      const persistedPlatformItemId = await resolvePlatformItemId(result);

      await saveConnectionForRelinkLater({
        platformItemId: persistedPlatformItemId,
        bankInstitutionAuthId:
          result.bankInstitutionAuthId ?? result.newlyLinkedBankInstitutionAuthId,
        bankAccounts: result.bankAccounts,
        plaidLinkSessionId: metadata.link_session_id,
      });
    },
    onExit: (error, metadata) => {
      console.log('Plaid Link exited', { error, metadata });
    },
  });

  handler.open();
};
```

## 列出已关联账户

返回已认证用户的安全 Plaid 银行账户列表。

```graphql theme={null}
query GetPlaidBankAccounts($input: PlaidBankAccountFilterInput) {
  getPlaidBankAccounts(input: $input) {
    platformItemId
    bankInstitutionAuthId
    bankInstitutionName
    bankAccountId
    accountName
    lastFour
    type
    subtype
    status
  }
}
```

```json theme={null}
{
  "input": {
    "platformItemId": "plaid-item-id"
  }
}
```

## 附加地址

如果 `completePlaidLink` 返回了 `requiresAddress: true`，请为已关联的银行机构附加一个地址。

```graphql theme={null}
mutation CreatePlaidLinkAddress($input: CreatePlaidLinkAddressInput!) {
  createPlaidLinkAddress(input: $input) {
    addressId
  }
}
```

```json theme={null}
{
  "input": {
    "bankInstitutionAuthId": "bank-institution-auth-id",
    "address": {
      "streetAddressLine1": "123 Main St",
      "streetAddressLine2": "Apt 4",
      "city": "New York",
      "state": "NY",
      "postalCode": "10001",
      "country": "US"
    }
  }
}
```

## 移除银行连接

从用户账户中移除一个 Plaid 银行机构。

```graphql theme={null}
mutation RemovePlaidBankInstitution($input: RemovePlaidBankInstitutionInput!) {
  removePlaidBankInstitution(input: $input) {
    removed
    bankInstitutionAuthId
  }
}
```

```json theme={null}
{
  "input": {
    "bankInstitutionAuthId": "bank-institution-auth-id",
    "reason": "USER_REQUESTED"
  }
}
```

## 安全边界

TGS 永远不会返回 Plaid 访问令牌、Plaid 公用令牌、身份匹配详情、银行账号或路由号。银行账户名称和昵称可能是用户输入的银行标签，仅作为账户元数据返回。

Identity-service 仍负责：

* Plaid 令牌交换
* Plaid 访问令牌存储
* 银行机构与银行账户的创建或修复
* 余额获取
* 历史交易摄取
* 银行机构移除
* Plaid webhooks

TGS 不会直接暴露 identity-service，也不会新增公共的 Plaid webhook 路由。

## 不支持的流程

手动 Plaid 小额存款验证关联在此公共封装中被有意不支持。新的关联与重新关联必须使用常规的 Plaid Link 流程，该流程支持余额与历史交易。

如果用户为同一机构发起新的关联而非选择重新关联，请将其当作正常的新关联来完成。Identity-service 负责银行账户去重与修复；在完成后存储返回的 `platformItemId`。

## 错误处理

如果 `completePlaidLink` 在 Plaid 已返回 `public_token` 后超时或失败，请在重试前，先使用已存储或预期的 `platformItemId` 调用 `getPlaidBankAccounts`。仅当 Plaid 从新的 Link 会话返回了新的 `public_token` 时再进行重试——Plaid 公用令牌有效期短且仅可使用一次。

<br />
