跳转到内容

嵌入与会员卡核验

查看 Markdown

您网站上的入会表单

章节“您网站上的入会表单”

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

<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:设备自带的字体)
<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 即可(元素)。使用 transparent 时,请选择在您的页面上清晰可读的明暗模式:表单的卡片有自己的底色,卡片之外的文字则直接落在您的页面上。

已有 Vimine 账户的人

章节“已有 Vimine 账户的人”

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

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

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

import { publicApi } from '@vimine/sdk';
const { aps, membership_types, statuto } = await publicApi().association('your-slug');

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

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(...) 完善个人资料后重试(参见没有意大利税号的会员)。

数字会员卡是一个签名令牌(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)也带有同一时刻,因此离线扫码器自己就能得出相同的结果。

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:机构可以决定会员卡在获准入会几小时后才可使用,这一时刻就记录在那里。

税号是可选的:没有税号的人可以凭出生日期、出生地和出生国入会(参见没有意大利税号的会员)。有税号时,POST /v1/validate/fiscal-code(或 publicApi().validateFiscalCode(...))会检查意大利税号(codice fiscale)的结构和校验码;如果您同时传入个人信息,还会检查它们与税号是否一致——并说明税号本身包含什么:

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(城市)不参与比对。