VIO 小程序集成指南
本文面向需要在 VIO 平台上构建并接入第三方小程序(Mini App)的开发者,提供端到端说明。
目录
1. 概述
什么是 VIO 小程序?
VIO 小程序是指在 VIO 会员端(Member App)内通过 iframe 嵌入运行的第三方 Web 应用。它可以调用 VIO 提供的多种能力,例如:
- 用户信息(资料、钱包地址等)
- 代币余额与转账
- 礼券(Voucher)列表与发放
- 用户已领取的礼券
架构
┌─────────────────────────────────────────────────────────────────┐
│ VIO Member App │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ MiniAppPage │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ <iframe> │ │ │
│ │ │ │ │ │
│ │ │ YOUR MINI APP │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────────────────────────┐ │ │ │
│ │ │ │ 1. Parse URL params (refreshToken) │ │ │ │
│ │ │ │ 2. Exchange for accessToken │ │ │ │
│ │ │ │ 3. Call VIO Mini App API │ │ │ │
│ │ │ └──────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │ │
│ │ └───────────────────────│──────────────────────────────┘ │ │
│ └──────────────────────────│─────────────────────────────────┘ │
└─────────────────────────────│────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ VIO API Server │
│ /api/miniapp/* │
└─────────────────────┘要点
- 无需 API Key:小程序使用基于 JWT 的鉴权(refresh / access token),不依赖 API Key
- Iframe 嵌入:应用运行在会员端内的 iframe 中
- 直连 API:小程序前端直接与 VIO API 服务通信
- 用户上下文:当前登录用户的凭证通过 URL 查询参数传入你的应用
2. 小程序如何被打开
iframe 加载流程
当用户在 VIO 会员端点击你的小程序入口时,路由会跳转到:
/{tenantSlug}/mini-app/{itemId}随后会员端会在 iframe 中加载你的小程序 URL,并把上下文以查询参数追加在地址后。
传入你应用的 URL 参数
你的小程序地址会收到下列查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
tenant | string | 租户 slug(例如 acme) |
company | string | 与租户 slug 相同 |
userId | string | 当前用户的 ID(MongoDB ObjectId) |
refreshToken | string | 以 Bearer 为前缀的 JWT refresh token |
redirectUrl | string | 将用户带回 VIO 的基地址(例如 https://app.vio.com/acme) |
你的应用可能收到的示例 URL:
https://your-miniapp.com/?tenant=acme&company=acme&userId=65f1a2b3c4d5e6f7a8b9c0d1&refreshToken=Bearer%20eyJhbGci...&redirectUrl=https%3A%2F%2Fapp.vio.com%2Facmeiframe Sandbox 属性
你的小程序在下列沙箱权限下运行:
<iframe
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
allow="geolocation; camera; microphone"
/>含义说明:
| 权限 | 说明 |
|---|---|
allow-scripts | 允许执行 JavaScript |
allow-same-origin | 可访问同源资源与存储 |
allow-forms | 允许提交表单 |
allow-popups | 可打开新窗口/标签页 |
allow-popups-to-escape-sandbox | 新开的弹窗不受沙箱限制 |
geolocation | 可申请定位 |
camera | 可使用摄像头 |
microphone | 可使用麦克风 |
3. 鉴权流程
总览
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ VIO Member │ │ Your Mini │ │ VIO API │
│ App │ │ App │ │ Server │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ Load iframe with │ │
│ ?refreshToken=xxx │ │
│───────────────────────>│ │
│ │ │
│ │ GET /api/miniapp/api/auth/token
│ │ Authorization: Bearer {refreshToken}
│ │───────────────────────>│
│ │ │
│ │ { accessToken: "Bearer ..." }
│ │<───────────────────────│
│ │ │
│ │ GET /api/miniapp/api/user/info
│ │ Authorization: Bearer {accessToken}
│ │───────────────────────>│
│ │ │
│ │ { userId, username, email, ... }
│ │<───────────────────────│
│ │ │分步说明
步骤 1:从 URL 读取 refresh token
加载时从查询参数中取 refreshToken:
const urlParams = new URLSearchParams(window.location.search);
const refreshToken = urlParams.get('refreshToken');
const tenant = urlParams.get('tenant');
const userId = urlParams.get('userId');
const redirectUrl = urlParams.get('redirectUrl');步骤 2:将 refresh token 换为 access token
调用代币交换接口并携带 refresh token:
curl -X GET "https://api.vio.com/api/miniapp/api/auth/token" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."响应(成功):
{
"status": "Success",
"message": {
"accessToken": "Bearer eyJhbGciOiJIUzI1NiIs..."
}
}步骤 3:用 access token 调用 API
后续请求须在 Authorization 头中使用 access token:
curl -X GET "https://api.vio.com/api/miniapp/api/user/info" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."令牌有效期
| 令牌类型 | 有效期 | 用途 |
|---|---|---|
| Refresh Token | 7 天 | 换取 access token |
| Access Token | 15 分钟 | API 鉴权 |
提示
若 access token 过期,可再次使用 refresh token 换新。
4. API 参考
Base URL: https://api.vio.com/api/miniapp
成功响应通常为:
{
"status": "Success",
"message": { ... }
}4.1 交换令牌(Token)
使用 refresh token 换取 access token。
端点: GET /api/miniapp/api/auth/token
鉴权: 在 Authorization 头中携带 refresh token
请求:
GET /api/miniapp/api/auth/token HTTP/1.1
Host: api.vio.com
Authorization: Bearer {refreshToken}响应:
{
"status": "Success",
"message": {
"accessToken": "Bearer eyJhbGciOiJIUzI1NiIs..."
}
}错误:
| HTTP 状态 | 代码 | 说明 |
|---|---|---|
| 401 | UNAUTHORIZED | Refresh token is required |
| 401 | UNAUTHORIZED | Invalid refresh token |
| 401 | UNAUTHORIZED | Refresh token not found or revoked |
| 401 | UNAUTHORIZED | Refresh token expired |
| 401 | UNAUTHORIZED | User not found or inactive |
4.2 获取用户信息
返回当前已鉴权用户的信息。
端点: GET /api/miniapp/api/user/info
鉴权: 需要 access token
请求:
GET /api/miniapp/api/user/info HTTP/1.1
Host: api.vio.com
Authorization: Bearer {accessToken}响应:
{
"status": "Success",
"message": {
"userId": "65f1a2b3c4d5e6f7a8b9c0d1",
"username": "John Doe",
"email": "john@example.com",
"phoneNumber": "+66812345678",
"walletAddr": "0x1234567890abcdef1234567890abcdef12345678",
"tenantId": "65f1a2b3c4d5e6f7a8b9c0d0"
}
}错误:
| HTTP 状态 | 代码 | 说明 |
|---|---|---|
| 401 | UNAUTHORIZED | No token provided |
| 401 | UNAUTHORIZED | Invalid token |
| 404 | NOT_FOUND | User not found |
4.3 代币列表
获取租户内可用代币列表,并包含当前用户余额。
端点: GET /api/miniapp/api/token/list
鉴权: 需要 access token
查询参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
page | number | 1 | 页码 |
size | number | 10 | 每页条数 |
响应:
{
"status": "Success",
"message": {
"page": 1,
"size": 10,
"totalCount": 2,
"lastPage": 1,
"data": [
{
"company": "65f1a2b3c4d5e6f7a8b9c0d0",
"tokenName": "Acme Points",
"tokenSymbol": "ACME",
"balance": 1500,
"dbTokId": "65f1a2b3c4d5e6f7a8b9c0d2",
"decimal": 0,
"logo": {
"imageUrl": "https://cdn.vio.com/tokens/acme.png",
"imageName": "ACME.png"
}
}
]
}
}字段说明:
| 字段 | 说明 |
|---|---|
company | 租户 ID |
tokenName | 代币显示名称 |
tokenSymbol | 代币符号(如 "ACME") |
balance | 用户当前余额 |
dbTokId | 代币 ID(发放代币时使用) |
decimal | 小数位数 |
logo | 代币 Logo 信息(可为 null) |
4.4 已发布礼券列表
返回当前用户可见的、已发布礼券模板(voucher schema)列表。
重要: 仅返回已加入进行中活动的礼券;未关联任何活动的礼券不会出现,行为与会员端一致。
端点: GET /api/miniapp/api/campaign/voucherSchema/published/list
鉴权: 需要 access token
查询参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
page | number | 1 | 页码 |
size | number | 10 | 每页条数 |
筛选逻辑:
- 活动过滤: 仅返回通过
CampaignVoucher关联到进行中活动的礼券 - 组织范围: 用户仅看到其所属组织层级对应活动中的礼券:
- 租户级用户仅见租户级活动中的礼券
- 子公司用户仅见其子公司活动中的礼券
- 日期范围: 礼券与活动均需在各自有效期内
- 可见性: 公开礼券、用户所属组织的私有礼券,以及共享礼券
响应:
{
"status": "Success",
"message": {
"page": 1,
"size": 10,
"totalCount": 1,
"lastPage": 1,
"data": [
{
"voucherSchema": {
"voucherSchemaName": { "en": "50% Off Coffee" },
"walletAddr": "0xabcdef1234567890...",
"image": {
"imageUrl": "https://cdn.vio.com/vouchers/coffee.jpg",
"imageName": "50% Off Coffee.jpg"
},
"expiryStartDatetime": "2024-01-01T00:00:00.000Z",
"expiryEndDatetime": "2024-12-31T23:59:59.000Z",
"value": 50,
"price": { "$numberDecimal": "100" },
"isActive": true,
"accessType": "Public Voucher",
"maxPurchasePerUser": 5,
"status": "Published",
"isBookingRequired": false,
"isLimited": true,
"quantity": 1000,
"voucherSchemaId": "65f1a2b3c4d5e6f7a8b9c0d4"
},
"isMaxQuantity": false,
"status": "Published",
"isActive": true,
"campaignVoucherSchemaId": "65f1a2b3c4d5e6f7a8b9c0d4"
}
]
}
}关键字段:
| 字段 | 说明 |
|---|---|
campaignVoucherSchemaId | 向用户发放礼券时使用的 ID |
voucherSchemaName.en | 礼券英文名称 |
value | 礼券面值(折扣金额等) |
price.$numberDecimal | 领取该礼券需支付的代币成本(来自活动配置) |
isMaxQuantity | 为 true 表示已售罄 |
maxPurchasePerUser | 单用户最大可领取次数 |
isLimited | 是否限量 |
quantity | 总可发放数量 |
说明: 若列表为空,请确认:
- 礼券已加入至少一个进行中活动
- 活动在有效期内
- 用户对礼券具备可见权限
4.5 向用户发放代币(仅管理员)
可向一名或多名用户发放代币。需要管理员角色(tenant_admin、sub_company_admin 或 super_admin)。
端点: POST /api/miniapp/api/userManagement/token/send
鉴权: 需要 access token(管理员角色)
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
token | string | 是 | 代币 ID(来自代币列表中的 dbTokId) |
sendAmount | number | 是 | 向每位用户发放的数额 |
userList | string[] | 是 | 接收代币的用户 ID 数组 |
请求:
{
"token": "65f1a2b3c4d5e6f7a8b9c0d2",
"sendAmount": 100,
"userList": ["65f1a2b3c4d5e6f7a8b9c0d1"]
}响应:
{
"status": "Success",
"message": {
"totalUsers": 1,
"successCount": 1,
"failedCount": 0,
"results": [
{
"userId": "65f1a2b3c4d5e6f7a8b9c0d1",
"success": true,
"newBalance": "1600"
}
]
}
}错误:
| HTTP 状态 | 代码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR | Token ID (dbTokId) is required |
| 400 | VALIDATION_ERROR | Send amount must be greater than 0 |
| 400 | VALIDATION_ERROR | User list must be a non-empty array |
| 403 | FORBIDDEN | Not authorized (requires admin role) |
| 403 | FORBIDDEN | Token does not belong to this tenant |
| 404 | NOT_FOUND | Token not found |
4.6 向用户发放礼券(仅管理员)
向指定用户发放一张礼券。需要管理员角色(tenant_admin、sub_company_admin 或 super_admin)。
端点: POST /api/miniapp/api/campaign/voucherSchema/send
鉴权: 需要 access token(管理员角色)
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
campaignVoucherSchemaId | string | 是 | 礼券在活动中的 ID(来自礼券列表) |
userId | string | 是 | 接收礼券的用户 ID |
请求:
{
"campaignVoucherSchemaId": "65f1a2b3c4d5e6f7a8b9c0d4",
"userId": "65f1a2b3c4d5e6f7a8b9c0d1"
}响应:
{
"status": "Success",
"message": {
"voucherNftId": "65f1a2b3c4d5e6f7a8b9c0d6"
}
}TIP
获得 voucherNftId 后,可将用户跳转到礼券详情:
{redirectUrl}/voucher/{voucherNftId}错误:
| HTTP 状态 | 代码 | 说明 |
|---|---|---|
| 400 | VALIDATION_ERROR | Campaign voucher schema ID is required |
| 400 | VALIDATION_ERROR | User ID is required |
| 400 | VALIDATION_ERROR | Voucher is not active |
| 400 | VALIDATION_ERROR | Voucher is not yet available |
| 400 | VALIDATION_ERROR | Voucher has expired |
| 400 | VALIDATION_ERROR | Voucher is sold out |
| 400 | VALIDATION_ERROR | User has reached the maximum claim limit for this voucher |
| 403 | FORBIDDEN | Not authorized (requires admin role) |
| 404 | NOT_FOUND | Voucher not found |
| 404 | NOT_FOUND | User not found |
4.7 用户已领取礼券(NFT)列表
返回当前登录用户已领取的礼券列表。
端点: GET /api/miniapp/api/user/voucherNft/list
鉴权: 需要 access token
查询参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
page | number | 1 | 页码 |
size | number | 10 | 每页条数 |
响应:
{
"status": "Success",
"message": {
"page": 1,
"size": 10,
"totalCount": 1,
"lastPage": 1,
"data": [
{
"voucherNftId": "65f1a2b3c4d5e6f7a8b9c0d6",
"nftTokenId": "123",
"status": "active",
"claimedAt": "2024-03-15T10:30:00.000Z",
"redeemedAt": null,
"expiresAt": "2024-12-31T23:59:59.000Z",
"redemptionCode": "ABC12345",
"voucherSchema": {
"voucherSchemaName": { "en": "50% Off Coffee" },
"voucherSchemaId": "65f1a2b3c4d5e6f7a8b9c0d4",
"image": {
"imageUrl": "https://cdn.vio.com/vouchers/coffee.jpg",
"imageName": "50% Off Coffee.jpg"
},
"value": 50,
"valueType": "percentage",
"valueCurrency": "THB"
}
}
]
}
}礼券状态取值:
| 状态值 | 说明 |
|---|---|
active | 可使用 |
used | 已核销 |
expired | 已过期 |
transferred | 已转赠他人 |
4.8 健康检查
检查 Mini App API 是否可用。
端点: GET /api/miniapp/health
鉴权: 不需要
响应:
{
"status": "Success",
"message": {
"service": "VIO MiniApp API",
"version": "1.0.0",
"timestamp": "2024-03-15T10:30:00.000Z"
}
}4.9 公开接口(无需鉴权)
适用于在用户完成登录鉴权之前、需要在小程序侧做初始化步骤的场景(例如门店场景下校验兑换 PIN)。
Base URL: https://api.vio.com/api/public
4.9.1 校验 PIN
校验兑换 PIN 并返回所属租户等信息;当你需要先判定 PIN 属于哪个租户、再继续业务流程时很实用。
端点: POST /api/public/verify-pin
鉴权: 不需要
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
pin | string | 是 | 包含前缀的完整 PIN(例如 "VI1234") |
请求:
{
"pin": "VI1234"
}响应(成功):
{
"success": true,
"data": {
"tenantId": "65f1a2b3c4d5e6f7a8b9c0d0",
"tenantName": "Acme Corp",
"tenantSlug": "acme",
"permissions": ["voucher_redemption"],
"message": "PIN verified successfully"
}
}响应字段:
| 字段 | 类型 | 说明 |
|---|---|---|
tenantId | string | 租户唯一 ID |
tenantName | string | 租户显示名称 |
tenantSlug | string | 租户 URL slug |
permissions | string[] | 该 PIN 被授予的权限。可能取值:voucher_redemption、token_claim |
message | string | 成功提示信息 |
PIN 校验如何工作:
- PIN 格式为
{PREFIX}{4 位数字}(例如VI1234) - 每个租户有独立的 2 字母前缀(如
VI、AC、XY) - 接口凭前缀解析租户
- 4 位数字会在该租户有效的兑换 PIN 中校验
- 响应里的
permissions可用来决定是否展示代币领取等能力(例如仅当包含token_claim时再展示代币领取界面)
错误:
| HTTP 状态 | 说明 |
|---|---|
| 400 | Invalid PIN format |
| 400 | Invalid PIN prefix |
| 400 | Invalid PIN |
| 400 | No tenants configured with PIN prefixes |
4.9.2 按租户获取门店列表
返回指定租户下的门店列表;在 PIN 校验成功后用于展示可选门店很有用。
端点: GET /api/public/tenants/:tenantId/stores
鉴权: 不需要
路径参数:
| 参数 | 类型 | 说明 |
|---|---|---|
tenantId | string | 租户的 MongoDB ObjectId |
查询参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
page | number | 1 | 页码 |
limit | number | 20 | 每页条数(最大 100) |
isActive | string | - | 按是否启用过滤(true / false) |
请求:
GET /api/public/tenants/65f1a2b3c4d5e6f7a8b9c0d0/stores?page=1&limit=20&isActive=true响应:
{
"success": true,
"data": [
{
"id": "65f1a2b3c4d5e6f7a8b9c0e1",
"name": "Downtown Store",
"address": "123 Main Street, Bangkok 10110",
"storeCode": "DTS001",
"isActive": true,
"latitude": 13.7563,
"longitude": 100.5018,
"createdAt": "2024-01-15T10:30:00.000Z"
},
{
"id": "65f1a2b3c4d5e6f7a8b9c0e2",
"name": "Mall Branch",
"address": "456 Shopping Ave, Bangkok 10120",
"storeCode": "MLB002",
"isActive": true,
"latitude": 13.7465,
"longitude": 100.5392,
"createdAt": "2024-02-20T14:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 2,
"totalPages": 1
}
}错误:
| HTTP 状态 | 说明 |
|---|---|
| 404 | Tenant not found |
4.9.3 获取租户信息
返回租户的公开信息,含品牌配色等。
端点: GET /api/public/tenants/:tenantId/info
鉴权: 不需要
路径参数:
| 参数 | 类型 | 说明 |
|---|---|---|
tenantId | string | 租户的 MongoDB ObjectId |
请求:
GET /api/public/tenants/65f1a2b3c4d5e6f7a8b9c0d0/info响应:
{
"success": true,
"data": {
"id": "65f1a2b3c4d5e6f7a8b9c0d0",
"name": "Acme Corp",
"slug": "acme",
"branding": {
"logo": "https://cdn.vio.com/tenants/acme/logo.png",
"logoLight": "https://cdn.vio.com/tenants/acme/logo-light.png",
"primaryColor": "#7C3AED",
"secondaryColor": "#A78BFA"
}
}
}错误:
| HTTP 状态 | 说明 |
|---|---|
| 404 | Tenant not found |
示例:PIN 校验流程
典型用法如下:
const API_BASE = 'https://api.vio.com';
// Step 1: User enters PIN (e.g., from scanning QR code)
const pin = 'VI1234';
// Step 2: Verify PIN and get tenant info
const verifyResponse = await fetch(`${API_BASE}/api/public/verify-pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
if (!verifyResponse.ok) {
const error = await verifyResponse.json();
console.error('PIN verification failed:', error.error.message);
return;
}
const { data: pinData } = await verifyResponse.json();
console.log('Tenant identified:', pinData.tenantName);
console.log('PIN permissions:', pinData.permissions);
// Step 3: Get tenant's stores
const storesResponse = await fetch(
`${API_BASE}/api/public/tenants/${pinData.tenantId}/stores?isActive=true`
);
const { data: stores } = await storesResponse.json();
// Step 4: Display stores to user for selection
stores.forEach(store => {
console.log(`${store.name} - ${store.address}`);
});
// Step 5: Get tenant branding for UI customization
const infoResponse = await fetch(
`${API_BASE}/api/public/tenants/${pinData.tenantId}/info`
);
const { data: tenantInfo } = await infoResponse.json();
// Apply tenant branding
document.body.style.setProperty('--primary-color', tenantInfo.branding.primaryColor);5. 错误处理
错误响应格式
错误均返回如下结构:
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error message"
}
}HTTP 状态码
| 状态 | 说明 |
|---|---|
| 400 | Bad Request —— 校验失败或入参非法 |
| 401 | Unauthorized —— 缺少或无效令牌 |
| 403 | Forbidden —— 权限不足 |
| 404 | Not Found —— 资源不存在 |
| 409 | Conflict —— 资源冲突(如重复) |
| 500 | Internal Server Error —— 服务端错误 |
错误码对照
| 代码 | HTTP 状态 | 说明 |
|---|---|---|
VALIDATION_ERROR | 400 | 请求校验失败 |
UNAUTHORIZED | 401 | 需要鉴权或鉴权失败 |
INVALID_TOKEN | 401 | JWT 格式错误 |
TOKEN_EXPIRED | 401 | JWT 已过期 |
FORBIDDEN | 403 | 用户缺少所需权限 |
NOT_FOUND | 404 | 请求的资源不存在 |
CONFLICT | 409 | 资源冲突(如重复) |
INTERNAL_ERROR | 500 | 内部错误 |
处理 access token 过期
Access token 约 15 分钟过期。收到 TOKEN_EXPIRED 后,应使用 refresh token 再次换取新的 access token:
async function apiCall(url, options = {}) {
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': accessToken
}
});
if (response.status === 401) {
// Token might be expired, try to refresh
await refreshAccessToken();
// Retry the request
return apiCall(url, options);
}
return response.json();
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}6. 跳回 VIO 会员端
使用 redirectUrl 参数
redirectUrl 为会员端基地址(例如 https://app.vio.com/acme),用于将用户导航回 VIO。
常用跳转方式
返回首页:
window.top.location.href = redirectUrl;跳转到指定礼券:
const voucherNftId = "65f1a2b3c4d5e6f7a8b9c0d6";
window.top.location.href = `${redirectUrl}/voucher/${voucherNftId}`;跳转到礼券列表:
window.top.location.href = `${redirectUrl}/vouchers`;重要
务必使用 window.top.location.href(不要用 window.location.href),因为应用跑在 iframe 内。redirectUrl 通常已包含租户 slug。
7. 安全注意事项
令牌处理
- 不要将令牌写入日志或错误信息
// 避免
console.log('Token:', accessToken);
// 建议
console.log('Token received');- 安全存储令牌
// 建议使用 sessionStorage(随标签页关闭清除)
sessionStorage.setItem('vio_access_token', accessToken);
// 敏感令牌避免使用 localStorage- 登出或异常时清理令牌
function clearTokens() {
sessionStorage.removeItem('vio_access_token');
sessionStorage.removeItem('vio_refresh_token');
}CORS 说明
VIO API 允许来自小程序的跨域请求。发起请求时请带上适当头,例如:
fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});iframe 安全
小程序受沙箱限制:
- 无法访问父页面的 DOM
- 无法直接驱动父页面导航(除
window.top.location.href外) - 无法访问父域名下的 Cookie
输入校验
发往 API 前应对数据进行校验与清理:
function validateUserId(userId) {
// MongoDB ObjectId:24 位十六进制
return /^[0-9a-fA-F]{24}$/.test(userId);
}8. 快速上手
原生 JavaScript 示例
展开完整 HTML 示例
<!DOCTYPE html>
<html>
<head>
<title>VIO Mini App</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
.user-info { background: #f5f5f5; padding: 15px; border-radius: 8px; }
.token-card { border: 1px solid #ddd; padding: 10px; margin: 10px 0; border-radius: 4px; }
.loading { color: #666; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1>My VIO Mini App</h1>
<div id="content">
<p class="loading">Loading...</p>
</div>
<script>
const API_BASE = 'https://api.vio.com/api/miniapp';
let accessToken = null;
let refreshToken = null;
let redirectUrl = null;
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
tenant: params.get('tenant'),
userId: params.get('userId'),
refreshToken: params.get('refreshToken'),
redirectUrl: params.get('redirectUrl')
};
}
async function exchangeToken(refreshToken) {
const response = await fetch(`${API_BASE}/api/auth/token`, {
method: 'GET',
headers: { 'Authorization': refreshToken }
});
if (!response.ok) throw new Error('Failed to exchange token');
const data = await response.json();
return data.message.accessToken;
}
async function getUserInfo() {
const response = await fetch(`${API_BASE}/api/user/info`, {
headers: { 'Authorization': accessToken }
});
if (!response.ok) throw new Error('Failed to get user info');
const data = await response.json();
return data.message;
}
async function getTokenList() {
const response = await fetch(`${API_BASE}/api/token/list?page=1&size=10`, {
headers: { 'Authorization': accessToken }
});
if (!response.ok) throw new Error('Failed to get tokens');
const data = await response.json();
return data.message.data;
}
async function init() {
const content = document.getElementById('content');
try {
const params = getUrlParams();
if (!params.refreshToken) throw new Error('No refresh token provided');
refreshToken = params.refreshToken;
redirectUrl = params.redirectUrl;
accessToken = await exchangeToken(refreshToken);
const [userInfo, tokens] = await Promise.all([
getUserInfo(),
getTokenList()
]);
content.innerHTML = `
<div class="user-info">
<h3>Welcome, ${userInfo.username || 'User'}!</h3>
<p>Email: ${userInfo.email || 'N/A'}</p>
</div>
<h3>Your Tokens</h3>
${tokens.map(t => `
<div class="token-card">
<strong>${t.tokenName}</strong> (${t.tokenSymbol})<br>
Balance: ${t.balance}
</div>
`).join('')}
<button onclick="window.top.location.href='${redirectUrl}'">Back to VIO</button>
`;
} catch (error) {
content.innerHTML = `<p class="error">Error: ${error.message}</p>`;
}
}
init();
</script>
</body>
</html>React 示例
import { useState, useEffect } from 'react';
const API_BASE = 'https://api.vio.com/api/miniapp';
function App() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [user, setUser] = useState(null);
const [tokens, setTokens] = useState([]);
const [redirectUrl, setRedirectUrl] = useState(null);
useEffect(() => {
init();
}, []);
async function init() {
try {
const params = new URLSearchParams(window.location.search);
const refreshToken = params.get('refreshToken');
const redirect = params.get('redirectUrl');
setRedirectUrl(redirect);
if (!refreshToken) throw new Error('No refresh token provided');
// Exchange token
const tokenResponse = await fetch(`${API_BASE}/api/auth/token`, {
headers: { 'Authorization': refreshToken }
});
if (!tokenResponse.ok) throw new Error('Token exchange failed');
const tokenData = await tokenResponse.json();
const token = tokenData.message.accessToken;
setAccessToken(token);
// Fetch data
const [userRes, tokensRes] = await Promise.all([
fetch(`${API_BASE}/api/user/info`, { headers: { 'Authorization': token } }),
fetch(`${API_BASE}/api/token/list?page=1&size=10`, { headers: { 'Authorization': token } })
]);
if (!userRes.ok || !tokensRes.ok) throw new Error('Failed to fetch data');
const userData = await userRes.json();
const tokensData = await tokensRes.json();
setUser(userData.message);
setTokens(tokensData.message.data);
setLoading(false);
} catch (err) {
setError(err.message);
setLoading(false);
}
}
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="app">
<h1>My VIO Mini App</h1>
{user && (
<div>
<h2>Welcome, {user.username || 'User'}!</h2>
<p>Email: {user.email || 'N/A'}</p>
</div>
)}
<h3>Your Tokens</h3>
{tokens.map(token => (
<div key={token.dbTokId}>
<strong>{token.tokenName}</strong> ({token.tokenSymbol})
- Balance: {token.balance}
</div>
))}
<button onClick={() => window.top.location.href = redirectUrl}>
Back to VIO
</button>
</div>
);
}
export default App;import { useState, useCallback } from 'react';
const API_BASE = 'https://api.vio.com/api/miniapp';
export function useVioApi() {
const [accessToken, setAccessToken] = useState(null);
const exchangeToken = useCallback(async (refreshToken) => {
const response = await fetch(`${API_BASE}/api/auth/token`, {
headers: { 'Authorization': refreshToken }
});
if (!response.ok) throw new Error('Token exchange failed');
const data = await response.json();
const token = data.message.accessToken;
setAccessToken(token);
return token;
}, []);
const apiCall = useCallback(async (endpoint, options = {}) => {
if (!accessToken) throw new Error('Not authenticated');
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
...options.headers,
'Authorization': accessToken,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!response.ok) throw new Error(data.error?.message || 'API call failed');
return data.message;
}, [accessToken]);
return {
accessToken,
exchangeToken,
getUserInfo: () => apiCall('/api/user/info'),
getTokenList: (page = 1, size = 10) =>
apiCall(`/api/token/list?page=${page}&size=${size}`),
getVoucherList: (page = 1, size = 10) =>
apiCall(`/api/campaign/voucherSchema/published/list?page=${page}&size=${size}`),
getUserVouchers: (page = 1, size = 10) =>
apiCall(`/api/user/voucherNft/list?page=${page}&size=${size}`),
sendTokens: (tokenId, amount, userIds) =>
apiCall('/api/userManagement/token/send', {
method: 'POST',
body: JSON.stringify({ token: tokenId, sendAmount: amount, userList: userIds })
}),
sendVoucher: (voucherId, userId) =>
apiCall('/api/campaign/voucherSchema/send', {
method: 'POST',
body: JSON.stringify({ campaignVoucherSchemaId: voucherId, userId })
})
};
}9. 在 VIO 中登记小程序
如何出现在会员端
若要让自己开发的小程序显示在 VIO 会员端首页等位置,需与 VIO 平台管理员(超级管理员)协作,由其在租户首页配置中为你的小程序建档。
配置项说明
申请接入时请准备以下信息:
| 字段 | 必填 | 说明 |
|---|---|---|
label | 是 | 图标下方展示名称(最多约 20 字符) |
icon | 是 | Lucide 图标名称(如 "Gift"、"Star"、"Ticket") |
miniAppUrl | 是 | 面向会员端的小程序 URL |
miniAppAdminUrl | 否 | 若管理端入口不同,可提供单独的管理端 URL |
可用图标名称(示例)
常用 Lucide 图标:
Gift,Star,Ticket,Dices,Car,ShoppingCartCreditCard,Wallet,Coins,Trophy,MedalCalendar,Clock,Map,Navigation,CompassHeart,ThumbsUp,Sparkles,Zap,Flame
完整列表:https://lucide.dev/icons/
可选展示位置
小程序可配置在:
- 首页九宫格——最多 8 个图标,4 列栅格
- 底部导航——作为约 3~5 个主导航项之一
- 首页底部区域——全宽 iframe 区域
给 VIO 管理员的申请示例
Please add our Lucky Draw mini app to Tenant: ACME
Configuration:
- Label: Lucky Draw
- Icon: Dices
- Member App URL: https://luckydraw.example.com
- Admin Portal URL: https://admin.luckydraw.example.com
Placement: Home page grid上线前测试建议
上线前建议完成:
- 在 开发环境 本地或联调验证
- 确认 鉴权流程(换票、过期重试)可靠
- 覆盖你计划调用的 全部 API
- 验证 过期 token、网络异常 等错误处理
- 验证 跳回 VIO(
redirectUrl/window.top) - 在移动端检查 iframe 全宽与布局 表现
附录:接口速查
需鉴权的接口
| 方法 | Endpoint | 鉴权 | 说明 |
|---|---|---|---|
| GET | /api/miniapp/api/auth/token | Refresh Token | 用 refresh 换 access |
| GET | /api/miniapp/api/user/info | Access Token | 当前用户信息 |
| GET | /api/miniapp/api/token/list | Access Token | 租户代币与用户余额 |
| GET | /api/miniapp/api/campaign/voucherSchema/published/list | Access Token | 进行中活动下的礼券列表 |
| POST | /api/miniapp/api/userManagement/token/send | Access Token(管理员) | 向用户发代币 |
| POST | /api/miniapp/api/campaign/voucherSchema/send | Access Token(管理员) | 向用户发礼券 |
| GET | /api/miniapp/api/user/voucherNft/list | Access Token | 用户已领取礼券 |
| GET | /api/miniapp/health | 无 | 健康检查 |
公开接口(无需鉴权)
| 方法 | Endpoint | 鉴权 | 说明 |
|---|---|---|---|
| POST | /api/public/verify-pin | 无 | 校验 PIN 并取租户信息 |
| GET | /api/public/tenants/:tenantId/stores | 无 | 租户门店列表 |
| GET | /api/public/tenants/:tenantId/info | 无 | 租户公开信息与品牌 |
需要帮助?
小程序接入相关问题请联系 VIO 平台团队。