> ## 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.

# 安全卡片输入

> 收集一张实体卡并将其添加为用户 Fluz 账户的支付方式，而无需让 PAN 或 CVV 接触到你的服务器或页面的 JavaScript。

<Note>
  本页假设你已阅读 [Secure Elements 概览](/build-a-platform/secure-elements-overview) —— 其中涵盖了加载 SDK 以及共享的 client-token 流程，这两者在此同样适用。
</Note>

## 在线演示

<iframe
  src="https://demo.secure.fluz.app/collect/"
  title="Fluz Secure Elements — Secure Card Input 演示"
  loading="lazy"
  style={{
width: "100%",
height: "720px",
border: "1px solid #e5e5e5",
borderRadius: "8px",
}}
/>

该演示会自行生成令牌并自动挂载字段。输入任意能通过 Luhn 校验的卡号，填写持卡人姓名并提交。如果表单无响应，使用 **Remint token & remount**。 [在新标签页打开 →](https://demo.secure.fluz.app/collect/)

## 铸造一个 tokenization 令牌

调用 [`POST /v1/client-token`](/build-a-platform/secure-elements-overview#mint-a-client-token)，并传入 `"purpose": "tokenization"`：

```json theme={null}
{
  "purpose": "tokenization"
}
```

与 reveal 令牌不同，此令牌不需要 `virtualCardId`。但它确实需要你访问令牌上的不同 scope —— 使用 `MANAGE_PAYMENT`，而不是 `CREATE_VIRTUALCARD` —— 并且它的有效期更长（默认 30 分钟），因为用户填写卡片表单通常比一次 reveal 点击花费更久。

## 渲染字段

```js theme={null}
const inputs = renderFieldsForTokenization({
  clientToken,
  loadToken,
  frameHostOrigin: "https://staging.secure.fluz.app",
});
```

`renderFieldsForTokenization` 没有 `fields` 选项 —— PAN、有效期和 CVV 始终作为一个组合的 frame 一起挂载，因为 CVV 校验与品牌相关（Amex 的 CVV 为 4 位，其他品牌为 3 位），只有当该字段能知道旁边 frame 中输入的卡号时才可行。你无法像 `createCardViewer` 的字段那样将它们独立挂载。

| 选项                          | 必填 | 详情                                                                                |
| :-------------------------- | :- | :-------------------------------------------------------------------------------- |
| `clientToken` / `loadToken` | 是  | 来自上面的令牌铸造调用                                                                       |
| `frameHostOrigin`           | 否  | 与 [Card Reveal](/build-a-platform/card-reveal) 相同的已允许来源规则 —— 省略时默认指向生产环境          |
| `style`                     | 否  | 与 `createCardViewer` 相同的 `{ color, fontSize, fontFamily, fontWeight }` —— 见下方注意事项 |
| `mountTimeoutMs`            | 否  | 默认 10 秒                                                                           |
| `submitTimeoutMs`           | 否  | 默认 15 秒 —— 见 [提交](#submit)                                                        |
| `excludedCardBrands`        | 否  | 例如 `["amex"]` —— 见 [跟踪字段状态](#track-field-state)                                   |

<Warning>
  传入 `style.fontFamily` 的 Google 字体会在 [Card Reveal](/build-a-platform/card-reveal) 的字段中渲染，但在此处会被静默忽略 —— 这些字段渲染在供应商托管的保管库 iframe 内，无法将外部 CSS 加载进其中。只有系统字体允许列表（`system-ui`、`Arial`、`Georgia`、`monospace` 等）才能在此能力中真正生效。
</Warning>

## 挂载

```js theme={null}
await inputs.mount(document.getElementById("card-fields"));
```

与 [Card Reveal](/build-a-platform/card-reveal#mount-it) 的形态相同：在 `INVALID_STYLE`、`MOUNT_TIMEOUT` 或 `MOUNT_FAILED`（frame 加载失败，或该实例已被挂载）时以 `FluzElementsError` 拒绝。`frameHostOrigin` 会在你调用 `renderFieldsForTokenization` 时同步校验，与 `createCardViewer` 相同 —— 未识别的来源会在到达 `mount()` 之前抛出 `INVALID_FRAME_HOST_ORIGIN`。

## 跟踪字段状态

```js theme={null}
inputs.onChange((field, state) => {
  // field: "pan" | "expiry" | "cvv"
  // state: { isEmpty, isValid, isDirty, brand? }
});
```

在 frame 内每次按键时触发。`brand` 仅出现在 `pan` 的状态上，由已输入的数字推断：`amex`、`visa`、`mastercard`、`discover`、`diners` 或 `jcb`。使用 `isValid` 来控制你自己的提交按钮并驱动行内校验信息 —— 这些字段都不会向你的页面暴露底层值。

`excludedCardBrands`（例如 `["amex"]`）不会阻止输入 —— 一旦检测到匹配品牌，就会将 `pan` 的 `isValid` 置为 `false`，因此用户仍可输入该卡号，但在使用不同卡之前，`submit()` 不会成功。

## 提交

在你自己的页面上以普通输入的方式收集持卡人姓名和账单地址 —— SDK 不会把它们渲染在 Fluz 托管的 frame 内，因为它们不属于卡数据。你自行处理它们是否会影响你自身的 PCI DSS 范围，取决于你更广泛的持卡人数据环境；请与 QSA 确认。

```js theme={null}
await inputs.submit({
  cardholderName: "Jane Doe",
  billingAddress: {
    line1: "123 Main St",
    line2: "Apt 4", // optional
    city: "Austin",
    state: "TX", // optional
    zipCode: "78701",
    country: "US",
  },
  isBackupPayment: false, // optional
});
```

`cardholderName` 仅在第一个空格处分割为名/姓 —— `"Mary Ann Smith"` 会变为名 `"Mary"`，姓 `"Ann Smith"`；单词姓名将同时作为名和姓。若要复用账户上已有的地址而不收集新地址，传入 `billingAddress: { userAddressId: "<uuid>" }`。

<Note>
  `submit()` 几乎不会以拒绝方式失败，且绝不会因拒付而抛错。它只会在 `MOUNT_FAILED`（尚未挂载）或 `SUBMIT_FAILED`（"a submit() call is already in progress" —— 当已有一次提交在进行中时，忽略第二次调用）时同步抛出。其他所有结果 —— 成功、拒付、校验失败、超时 —— 都会正常 resolve，并通过下方的回调传达。
</Note>

## 处理结果

```js theme={null}
inputs.onSuccess((result) => {
  // result.bankCardId, brand, last4, expirationMonth, expirationYear,
  // cardholderName, billingAddress, createdAt
});

inputs.onDeclined((decline) => {
  // decline.code, decline.message
});

inputs.onError((error) => {
  // error.code, error.message
});
```

`onSuccess` 会在该卡被添加为资金来源后触发。`onDeclined` 则在处理方拒绝该卡时触发 —— 这仍是正常且可预期的结果，不是错误：

| 拒付代码                    | 含义                       |
| :---------------------- | :----------------------- |
| `CARD_DECLINED`         | 通用拒付                     |
| `INSUFFICIENT_FUNDS`    | 因资金不足而被拒                 |
| `CARD_EXPIRED`          | 卡已过期                     |
| `CARD_INVALID`          | 无法验证该卡                   |
| `CVV_MISMATCH`          | 安全码不匹配                   |
| `AVS_MISMATCH`          | 账单地址不匹配                  |
| `CONTACT_BANK`          | 被拒 —— 联系发卡行              |
| `DUPLICATE_CARD`        | 该卡已在账户中                  |
| `PREPAID_REJECTED`      | 不接受预付卡                   |
| `FRAUD_FILTER`          | 被风险过滤拦截                  |
| `BIN_BLOCKED`           | 卡的 BIN 被拦截               |
| `EXPANDED_BIN_REQUIRED` | 该卡需要 Fluz 尚未持有的扩展 BIN 数据 |
| `KYB_GATE`              | 账户暂不具备添加卡的资格             |
| `TRUST_STATUS_FAILED`   | 账户不具备添加卡的资格              |
| `DEVICE_BLOCKED`        | 此设备不具备添加卡的资格             |
| `MAX_CARDS_REACHED`     | 账户已达到卡片数量上限              |
| `DECLINED_OTHER`        | 未映射拒付原因的总括               |

`onError` 用于所有不属于正常拒付的情况：

| 错误代码                        | 出现场景                               | 含义                                                                                                                                                                                                         |
| :-------------------------- | :--------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_FRAME_HOST_ORIGIN` | 由 `renderFieldsForTokenization` 抛出 | `frameHostOrigin` 不是已识别的 Fluz frame 主机                                                                                                                                                                     |
| `INVALID_STYLE`             | 被 `mount()` 拒绝                     | 某个 `style` 值未通过校验                                                                                                                                                                                          |
| `MOUNT_TIMEOUT`             | 被 `mount()` 拒绝                     | frame 未能在 `mountTimeoutMs` 内完成握手                                                                                                                                                                           |
| `MOUNT_FAILED`              | 被 `mount()` 拒绝 / 由 `submit()` 抛出   | frame 加载失败、已被挂载，或在 `mount()` resolve 前调用了 `submit()`                                                                                                                                                       |
| `SUBMIT_FAILED`             | 由 `submit()` 抛出，或投递至 `onError`     | 当已有一次 `submit()` 在进行中又调用第二次；或通过 `onError` 投递的情形：字段值校验失败（`VALIDATION_FAILED`）、环境未开通 collect 能力（`COLLECT_UNAVAILABLE`）、Fluz 后端拒绝请求（`FUNDING_SOURCE_UNAUTHORIZED`）、或发生了意外的处理方错误（`FUNDING_SOURCE_UNAVAILABLE`） |
| `SUBMIT_TIMEOUT`            | 投递至 `onError`                      | 在 `submitTimeoutMs`（默认 15 秒）内未收到提交结果                                                                                                                                                                       |
| `FIELD_ERROR`               | 投递至 `onError`                      | 底层卡保管库字段报告了内部错误                                                                                                                                                                                            |
| `RATE_LIMITED`              | 投递至 `onError`                      | 针对此授权发生过多的提交尝试                                                                                                                                                                                             |

## 清理

```js theme={null}
inputs.destroy();
```

移除 frame 并解绑所有监听器。在卸载时调用，或在铸造新令牌以重试之前调用。

## 完整示例

```html theme={null}
<div id="card-fields"></div>
<input id="cardholder-name" placeholder="Name on card" />
<button id="submit-button">Add card</button>

<script src="https://secure-cdn.fluz.app/secure-elements/v0.3.0/index.global.js"></script>
<script>
  (async () => {
    const { renderFieldsForTokenization } = FluzSecureElements;

    const res = await fetch("/mint-tokenization-token", { method: "POST" });
    const { clientToken, loadToken } = await res.json();

    const inputs = renderFieldsForTokenization({
      clientToken,
      loadToken,
      frameHostOrigin: "https://staging.secure.fluz.app",
      excludedCardBrands: ["amex"],
      style: { fontFamily: "system-ui", fontSize: "16px", color: "#1a1a1a" },
    });

    inputs.onChange((field, state) => console.log(field, state));
    inputs.onDeclined((decline) => alert(decline.message));
    inputs.onError((error) => console.error(error.code, error.message));
    inputs.onSuccess((result) => console.log("card added", result.bankCardId));

    await inputs.mount(document.getElementById("card-fields"));

    const submitButton = document.getElementById("submit-button");
    submitButton.addEventListener("click", async () => {
      submitButton.disabled = true;
      try {
        await inputs.submit({
          cardholderName: document.getElementById("cardholder-name").value,
          billingAddress: { userAddressId: "<existing-address-uuid>" },
        });
      } catch (error) {
        console.error(error.code, error.message);
      } finally {
        submitButton.disabled = false;
      }
    });
  })();
</script>
```

`/mint-tokenization-token` 是你自己的后端路由 —— 它会使用你的 Fluz OAuth 访问令牌调用 `POST /v1/client-token`，并传入 `"purpose": "tokenization"`。

## 下一步

<CardGroup cols={2}>
  <Card title="Secure Elements 概览" icon="book-open" href="/build-a-platform/secure-elements-overview">
    令牌铸造、SDK 加载与 CSP。
  </Card>

  {" "}

  <Card title="Card Reveal" icon="eye" href="/build-a-platform/card-reveal">
    另一个 Secure Elements 能力 —— 向用户展示其自己的卡片详情。
  </Card>

  {" "}

  <Card title="在线演示" icon="play" href="https://demo.secure.fluz.app/collect/">
    试用在预发布环境运行的加卡表单。
  </Card>

  <Card title="示例集成" icon="github" href="https://github.com/fluz-app/secure-elements-examples">
    可运行的纯 HTML 与 React 安全卡片输入示例，附带令牌铸造服务器。
  </Card>
</CardGroup>
