# 嵌入与会员卡核验

> 您网站上的入会表单、协会的公开资料，以及在线和离线的会员卡核验。

URL: https://developers.vimine.network/zh-hans/guides/embed-and-verify/

## 您网站上的入会表单

每个开放了公开入会的协会都有一个页面 `https://app.vimine.network/enroll/<slug>`，可以放进协会网站的 iframe 中：

```html
<iframe src="https://app.vimine.network/enroll/your-slug" style="width:100%;min-height:900px;border:0"></iframe>
```

协会必须在自己的设置中，把该网站的域名列入**允许嵌入（iframe）的域名**。而“使用 Vimine 登录”的登录和授权同意页面则**不能**放进 iframe：它始终是 vimine.network 上的一个完整页面。

`?locale=en` 会以英文打开表单（`<vimine-enroll>` 元素会根据页面的语言自动传入该参数）。

### 表单的外观

表单可以采用您网站的配色。iframe 中的页面看不到宿主页面的 CSS，因此样式要通过地址传递：

| 参数 | 取值 | |
|---|---|---|
| `scheme` | `light`、`dark` | 页面的明暗模式；不指定时，跟随背景或访客的设置 |
| `accent` | 十六进制颜色（`1e3a5f`） | 按钮、链接、获得焦点的字段以及协会名称 |
| `background` | 十六进制颜色，或 `transparent` | 卡片后面的底色；`transparent` 会透出您的页面 |
| `font` | `inter`、`roboto`、`open-sans`、`montserrat`、`nunito`、`lora`、`merriweather`、`roboto-slab`、`system` | 整个页面（包括字段）的字体，从 Vimine 自行托管的字体中选择（`system`：设备自带的字体） |

```html
<iframe src="https://app.vimine.network/enroll/your-slug?scheme=light&accent=1e3a5f&background=transparent"
        style="width:100%;min-height:900px;border:0"></iframe>
```

其他取值会被忽略。使用 `<vimine-enroll>` 时无需编写地址：在您的 CSS 中设置 `--vimine-scheme`、`--vimine-accent`、`--vimine-background` 和 `--vimine-typeface` 即可（[元素](https://developers.vimine.network/zh-hans/sdk/elements/)）。使用 `transparent` 时，请选择在您的页面上清晰可读的明暗模式：表单的卡片有自己的底色，卡片之外的文字则直接落在您的页面上。

### 已有 Vimine 账户的人

一个身份适用于所有协会：已有账户的人无需再次填写表单。直接打开时，页面 `…/enroll/<slug>` 会识别会话，并只显示**一个页面**——*本*协会的会员卡类型、章程和隐私声明，以及本协会额外要求的内容（地址，如果协会声明了要收集地址）。章程和隐私声明每次都需要接受：任何内容都不会从一个协会带到另一个协会。

在 iframe 中无法读取会话：浏览器会把嵌入框架中的页面与直接打开的同一网站隔离开来。因此，嵌入的表单会提供“已有 Vimine 账户？前往 Vimine 继续”，在新标签页中打开该页面。如果您手写 iframe 并使用了 `sandbox`，请加上 `allow-popups` 和 `allow-popups-to-escape-sandbox`，否则那个标签页仍会被困在沙箱中；`<vimine-enroll>` 已经这样做了。

## 公开资料

如果您想构建自己的体验，`GET /v1/aps/{slug}` 会返回协会的公开资料——品牌形象、会员卡类型及其会费、现行章程：

```js
import { publicApi } from '@vimine/sdk';

const { aps, membership_types, statuto } = await publicApi().association('your-slug');
```

无需任何凭据。之后，在会员已通过“使用 Vimine 登录”登录的页面上，您就可以收集入会申请：

```js
const { aps, membership_types, statuto, privacy_notice } = await publicApi().association('your-slug');

await vimine.membership.request({
    membership_type,
    accept_statuto: { version: statuto.version },
    // 协会的隐私声明，即您的页面所展示的那一份
    privacy_notice: privacy_notice && { version: privacy_notice.version, sha256: privacy_notice.sha256, locale: privacy_notice.locale },
    // 地址，在协会收集地址时传入（aps.member_address: 'optional' | 'required'）
    address: { line: 'Via Roma 1', postal_code: '20100', city: 'Milano', province: 'MI' }
});
```

协会对申请人的要求，与申请从哪个入口提交无关：

- **隐私声明**：只要协会有一份当事人尚未阅读的现行隐私声明，`privacy_notice` 就是必填的：缺少时，`privacy_notice` 字段上返回 `422 validation_failed`；版本已不再现行时，返回 `422 privacy_notice_outdated`。`vimine.legal.acceptances()` 会告诉您还有哪些需要确认；文本在 `GET /v1/aps/{slug}/privacy-notice`。阅读确认会作为一条与该会员资格关联的证明记录保存。
- **地址**：`aps.member_address` 说明协会是否收集地址：为 `required` 时，不带 `address` 的申请会在 `address` 字段上返回 `422 validation_failed`；为 `none` 时，地址会被忽略。
- **当事人的身份**：当个人资料尚未识别身份时——既没有税号，也没有出生日期、出生地和出生国——申请返回 `422 identification_required`：请用 `vimine.me.update(...)` 完善个人资料后重试（参见[没有意大利税号的会员](https://developers.vimine.network/zh-hans/guides/members-without-cf/#identification_required)）。

:::caution[2026 年 10 月 2 日起的变化]
在 API 的 `2026-09-27` 版本之前，此请求既不要求隐私声明，也不要求地址。如果某个集成在协会有现行隐私声明的情况下调用它而不带 `privacy_notice`，现在会收到 `422`。
:::

## 会员卡核验

数字会员卡是一个**签名令牌**（`vmt_…`），会员以二维码的形式出示。有两种核验方式。

### 在线

无需凭据，响应中不含任何个人数据：

```
GET https://api.vimine.network/v1/verify/vmt_…
→ { "status": "active", "valid_until": "2027-03-31T22:59:59.000Z" }
   或
→ { "status": "inactive", "reason": "expired" }
   或
→ { "status": "inactive", "reason": "not_yet_valid", "valid_from": "2026-09-19T19:00:00.000Z" }
```

`not_yet_valid`：会员卡是真实的，会员资格也已生效，但机构设定了从获准入会到会员卡可以使用之间的间隔。`valid_from` 说明它从何时开始算数；在此之前，在入场时它是一张不算数的会员卡。令牌中（`nbf`）也带有同一时刻，因此离线扫码器自己就能得出相同的结果。

```js
import { publicApi } from '@vimine/sdk';

const { status } = await publicApi().verifyCard(tokenReadFromQr);
```

### 离线

令牌使用 **Ed25519** 签名；公钥位于 `https://api.vimine.network/.well-known/vimine-card-keys.json`（也可通过 `GET /v1/card-keys` 或 `publicApi().cardKeys()` 获取）。扫码器使用缓存的公钥核验**三件事**：签名、有效时间窗口（`nbf`/`exp`，最长 90 天，之后令牌会轮换），以及载荷中的状态为有效——**`s === "active"`**。网络恢复后再在线重新同步。遇到未知的 `kid`，说明密钥已轮换：请重新加载公钥。

令牌在每次状态变化、每次续期时都会轮换，而且无论如何每 90 天轮换一次：一张旧截图不可能永远有效。

只有**有效**的会员资格才有会员卡：申请仍在等待（入会审批或会费）期间，以及会员资格被暂停或撤销时，API 不签发任何令牌——`GET /v1/me/membership` 中的 `card` 为 `null`，而 `GET /v1/me/membership/card`、会员卡图片、电子邮件和 Google Wallet 链接都返回 `409 not_active`。检查 `s` 仍是必须的：它会拒绝在此规则之前签发的令牌，以及任何未声明有效状态的令牌。也请遵守 `nbf`：机构可以决定会员卡在获准入会几小时后才可使用，这一时刻就记录在那里。

:::caution
离线核验只能说明令牌在签发时**真实且处于有效时间窗口内**，并不能说明会员卡在此刻仍然有效：之后发生的暂停只有在线才能看到。请尽快重新同步。
:::

## 税号

税号是**可选的**：没有税号的人可以凭出生日期、出生地和出生国入会（参见[没有意大利税号的会员](https://developers.vimine.network/zh-hans/guides/members-without-cf/)）。有税号时，`POST /v1/validate/fiscal-code`（或 `publicApi().validateFiscalCode(...)`）会检查意大利税号（codice fiscale）的结构和校验码；如果您同时传入个人信息，还会检查它们与税号是否一致——并说明税号本身包含什么：

```js
const r = await publicApi().validateFiscalCode({ code: 'RSSMRA80A01H501U', first_name: 'Mario', last_name: 'Rossi', date: '1980-01-01', place: 'Roma', country: 'IT' });
// { valid: true, error: null, mismatches: [], hints: [], birth_date: '1980-01-01', birth_country: 'IT' }
//   或
// { valid: false, error: 'mismatch', mismatches: ['date'], hints: [], birth_date: '1980-01-01', birth_country: 'IT' }
```

`error` 为 `invalid`（结构或校验码有误）、`mismatch`（格式正确但与所发送信息不一致的税号）或 `null`；当姓和名互换后税号能对上时，`hints` 中包含 `names_swapped`。对于在国外出生的人，请发送 `country`（ISO 3166-1 alpha-2）：他们的税号中只包含国家，`place`（城市）不参与比对。
