Skip to content

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 参数

你的小程序地址会收到下列查询参数:

参数类型说明
tenantstring租户 slug(例如 acme
companystring与租户 slug 相同
userIdstring当前用户的 ID(MongoDB ObjectId)
refreshTokenstringBearer 为前缀的 JWT refresh token
redirectUrlstring将用户带回 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%2Facme

iframe Sandbox 属性

你的小程序在下列沙箱权限下运行:

html
<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

javascript
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:

bash
curl -X GET "https://api.vio.com/api/miniapp/api/auth/token" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

响应(成功):

json
{
  "status": "Success",
  "message": {
    "accessToken": "Bearer eyJhbGciOiJIUzI1NiIs..."
  }
}

步骤 3:用 access token 调用 API

后续请求须在 Authorization 头中使用 access token:

bash
curl -X GET "https://api.vio.com/api/miniapp/api/user/info" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

令牌有效期

令牌类型有效期用途
Refresh Token7 天换取 access token
Access Token15 分钟API 鉴权

提示

若 access token 过期,可再次使用 refresh token 换新。


4. API 参考

Base URL: https://api.vio.com/api/miniapp

成功响应通常为:

json
{
  "status": "Success",
  "message": { ... }
}

4.1 交换令牌(Token)

使用 refresh token 换取 access token。

端点: GET /api/miniapp/api/auth/token

鉴权: 在 Authorization 头中携带 refresh token

请求:

http
GET /api/miniapp/api/auth/token HTTP/1.1
Host: api.vio.com
Authorization: Bearer {refreshToken}

响应:

json
{
  "status": "Success",
  "message": {
    "accessToken": "Bearer eyJhbGciOiJIUzI1NiIs..."
  }
}

错误:

HTTP 状态代码说明
401UNAUTHORIZEDRefresh token is required
401UNAUTHORIZEDInvalid refresh token
401UNAUTHORIZEDRefresh token not found or revoked
401UNAUTHORIZEDRefresh token expired
401UNAUTHORIZEDUser not found or inactive

4.2 获取用户信息

返回当前已鉴权用户的信息。

端点: GET /api/miniapp/api/user/info

鉴权: 需要 access token

请求:

http
GET /api/miniapp/api/user/info HTTP/1.1
Host: api.vio.com
Authorization: Bearer {accessToken}

响应:

json
{
  "status": "Success",
  "message": {
    "userId": "65f1a2b3c4d5e6f7a8b9c0d1",
    "username": "John Doe",
    "email": "john@example.com",
    "phoneNumber": "+66812345678",
    "walletAddr": "0x1234567890abcdef1234567890abcdef12345678",
    "tenantId": "65f1a2b3c4d5e6f7a8b9c0d0"
  }
}

错误:

HTTP 状态代码说明
401UNAUTHORIZEDNo token provided
401UNAUTHORIZEDInvalid token
404NOT_FOUNDUser not found

4.3 代币列表

获取租户内可用代币列表,并包含当前用户余额。

端点: GET /api/miniapp/api/token/list

鉴权: 需要 access token

查询参数:

参数类型默认值说明
pagenumber1页码
sizenumber10每页条数

响应:

json
{
  "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

查询参数:

参数类型默认值说明
pagenumber1页码
sizenumber10每页条数

筛选逻辑:

  1. 活动过滤: 仅返回通过 CampaignVoucher 关联到进行中活动的礼券
  2. 组织范围: 用户仅看到其所属组织层级对应活动中的礼券:
    • 租户级用户仅见租户级活动中的礼券
    • 子公司用户仅见其子公司活动中的礼券
  3. 日期范围: 礼券与活动均需在各自有效期内
  4. 可见性: 公开礼券、用户所属组织的私有礼券,以及共享礼券

响应:

json
{
  "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总可发放数量

说明: 若列表为空,请确认:

  1. 礼券已加入至少一个进行中活动
  2. 活动在有效期内
  3. 用户对礼券具备可见权限

4.5 向用户发放代币(仅管理员)

可向一名或多名用户发放代币。需要管理员角色(tenant_admin、sub_company_admin 或 super_admin)。

端点: POST /api/miniapp/api/userManagement/token/send

鉴权: 需要 access token(管理员角色)

请求体:

字段类型必填说明
tokenstring代币 ID(来自代币列表中的 dbTokId
sendAmountnumber向每位用户发放的数额
userListstring[]接收代币的用户 ID 数组

请求:

json
{
  "token": "65f1a2b3c4d5e6f7a8b9c0d2",
  "sendAmount": 100,
  "userList": ["65f1a2b3c4d5e6f7a8b9c0d1"]
}

响应:

json
{
  "status": "Success",
  "message": {
    "totalUsers": 1,
    "successCount": 1,
    "failedCount": 0,
    "results": [
      {
        "userId": "65f1a2b3c4d5e6f7a8b9c0d1",
        "success": true,
        "newBalance": "1600"
      }
    ]
  }
}

错误:

HTTP 状态代码说明
400VALIDATION_ERRORToken ID (dbTokId) is required
400VALIDATION_ERRORSend amount must be greater than 0
400VALIDATION_ERRORUser list must be a non-empty array
403FORBIDDENNot authorized (requires admin role)
403FORBIDDENToken does not belong to this tenant
404NOT_FOUNDToken not found

4.6 向用户发放礼券(仅管理员)

向指定用户发放一张礼券。需要管理员角色(tenant_admin、sub_company_admin 或 super_admin)。

端点: POST /api/miniapp/api/campaign/voucherSchema/send

鉴权: 需要 access token(管理员角色)

请求体:

字段类型必填说明
campaignVoucherSchemaIdstring礼券在活动中的 ID(来自礼券列表)
userIdstring接收礼券的用户 ID

请求:

json
{
  "campaignVoucherSchemaId": "65f1a2b3c4d5e6f7a8b9c0d4",
  "userId": "65f1a2b3c4d5e6f7a8b9c0d1"
}

响应:

json
{
  "status": "Success",
  "message": {
    "voucherNftId": "65f1a2b3c4d5e6f7a8b9c0d6"
  }
}

TIP

获得 voucherNftId 后,可将用户跳转到礼券详情:

{redirectUrl}/voucher/{voucherNftId}

错误:

HTTP 状态代码说明
400VALIDATION_ERRORCampaign voucher schema ID is required
400VALIDATION_ERRORUser ID is required
400VALIDATION_ERRORVoucher is not active
400VALIDATION_ERRORVoucher is not yet available
400VALIDATION_ERRORVoucher has expired
400VALIDATION_ERRORVoucher is sold out
400VALIDATION_ERRORUser has reached the maximum claim limit for this voucher
403FORBIDDENNot authorized (requires admin role)
404NOT_FOUNDVoucher not found
404NOT_FOUNDUser not found

4.7 用户已领取礼券(NFT)列表

返回当前登录用户已领取的礼券列表。

端点: GET /api/miniapp/api/user/voucherNft/list

鉴权: 需要 access token

查询参数:

参数类型默认值说明
pagenumber1页码
sizenumber10每页条数

响应:

json
{
  "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

鉴权: 不需要

响应:

json
{
  "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

鉴权: 不需要

请求体:

字段类型必填说明
pinstring包含前缀的完整 PIN(例如 "VI1234"

请求:

json
{
  "pin": "VI1234"
}

响应(成功):

json
{
  "success": true,
  "data": {
    "tenantId": "65f1a2b3c4d5e6f7a8b9c0d0",
    "tenantName": "Acme Corp",
    "tenantSlug": "acme",
    "permissions": ["voucher_redemption"],
    "message": "PIN verified successfully"
  }
}

响应字段:

字段类型说明
tenantIdstring租户唯一 ID
tenantNamestring租户显示名称
tenantSlugstring租户 URL slug
permissionsstring[]该 PIN 被授予的权限。可能取值:voucher_redemptiontoken_claim
messagestring成功提示信息

PIN 校验如何工作:

  1. PIN 格式为 {PREFIX}{4 位数字}(例如 VI1234
  2. 每个租户有独立的 2 字母前缀(如 VIACXY
  3. 接口凭前缀解析租户
  4. 4 位数字会在该租户有效的兑换 PIN 中校验
  5. 响应里的 permissions 可用来决定是否展示代币领取等能力(例如仅当包含 token_claim 时再展示代币领取界面)

错误:

HTTP 状态说明
400Invalid PIN format
400Invalid PIN prefix
400Invalid PIN
400No tenants configured with PIN prefixes

4.9.2 按租户获取门店列表

返回指定租户下的门店列表;在 PIN 校验成功后用于展示可选门店很有用。

端点: GET /api/public/tenants/:tenantId/stores

鉴权: 不需要

路径参数:

参数类型说明
tenantIdstring租户的 MongoDB ObjectId

查询参数:

参数类型默认值说明
pagenumber1页码
limitnumber20每页条数(最大 100)
isActivestring-按是否启用过滤(true / false

请求:

http
GET /api/public/tenants/65f1a2b3c4d5e6f7a8b9c0d0/stores?page=1&limit=20&isActive=true

响应:

json
{
  "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 状态说明
404Tenant not found

4.9.3 获取租户信息

返回租户的公开信息,含品牌配色等。

端点: GET /api/public/tenants/:tenantId/info

鉴权: 不需要

路径参数:

参数类型说明
tenantIdstring租户的 MongoDB ObjectId

请求:

http
GET /api/public/tenants/65f1a2b3c4d5e6f7a8b9c0d0/info

响应:

json
{
  "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 状态说明
404Tenant not found

示例:PIN 校验流程

典型用法如下:

javascript
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. 错误处理

错误响应格式

错误均返回如下结构:

json
{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message"
  }
}

HTTP 状态码

状态说明
400Bad Request —— 校验失败或入参非法
401Unauthorized —— 缺少或无效令牌
403Forbidden —— 权限不足
404Not Found —— 资源不存在
409Conflict —— 资源冲突(如重复)
500Internal Server Error —— 服务端错误

错误码对照

代码HTTP 状态说明
VALIDATION_ERROR400请求校验失败
UNAUTHORIZED401需要鉴权或鉴权失败
INVALID_TOKEN401JWT 格式错误
TOKEN_EXPIRED401JWT 已过期
FORBIDDEN403用户缺少所需权限
NOT_FOUND404请求的资源不存在
CONFLICT409资源冲突(如重复)
INTERNAL_ERROR500内部错误

处理 access token 过期

Access token 约 15 分钟过期。收到 TOKEN_EXPIRED 后,应使用 refresh token 再次换取新的 access token:

javascript
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。

常用跳转方式

返回首页:

javascript
window.top.location.href = redirectUrl;

跳转到指定礼券:

javascript
const voucherNftId = "65f1a2b3c4d5e6f7a8b9c0d6";
window.top.location.href = `${redirectUrl}/voucher/${voucherNftId}`;

跳转到礼券列表:

javascript
window.top.location.href = `${redirectUrl}/vouchers`;

重要

务必使用 window.top.location.href(不要用 window.location.href),因为应用跑在 iframe 内。redirectUrl 通常已包含租户 slug。


7. 安全注意事项

令牌处理

  1. 不要将令牌写入日志或错误信息
javascript
// 避免
console.log('Token:', accessToken);

// 建议
console.log('Token received');
  1. 安全存储令牌
javascript
// 建议使用 sessionStorage(随标签页关闭清除)
sessionStorage.setItem('vio_access_token', accessToken);

// 敏感令牌避免使用 localStorage
  1. 登出或异常时清理令牌
javascript
function clearTokens() {
  sessionStorage.removeItem('vio_access_token');
  sessionStorage.removeItem('vio_refresh_token');
}

CORS 说明

VIO API 允许来自小程序的跨域请求。发起请求时请带上适当头,例如:

javascript
fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  }
});

iframe 安全

小程序受沙箱限制:

  • 无法访问父页面的 DOM
  • 无法直接驱动父页面导航(除 window.top.location.href 外)
  • 无法访问父域名下的 Cookie

输入校验

发往 API 前应对数据进行校验与清理:

javascript
function validateUserId(userId) {
  // MongoDB ObjectId:24 位十六进制
  return /^[0-9a-fA-F]{24}$/.test(userId);
}

8. 快速上手

原生 JavaScript 示例

展开完整 HTML 示例
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 示例

jsx
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;
jsx
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 字符)
iconLucide 图标名称(如 "Gift""Star""Ticket"
miniAppUrl面向会员端的小程序 URL
miniAppAdminUrl若管理端入口不同,可提供单独的管理端 URL

可用图标名称(示例)

常用 Lucide 图标:

  • Gift, Star, Ticket, Dices, Car, ShoppingCart
  • CreditCard, Wallet, Coins, Trophy, Medal
  • Calendar, Clock, Map, Navigation, Compass
  • Heart, ThumbsUp, Sparkles, Zap, Flame

完整列表:https://lucide.dev/icons/

可选展示位置

小程序可配置在:

  1. 首页九宫格——最多 8 个图标,4 列栅格
  2. 底部导航——作为约 3~5 个主导航项之一
  3. 首页底部区域——全宽 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

上线前测试建议

上线前建议完成:

  1. 开发环境 本地或联调验证
  2. 确认 鉴权流程(换票、过期重试)可靠
  3. 覆盖你计划调用的 全部 API
  4. 验证 过期 token、网络异常 等错误处理
  5. 验证 跳回 VIOredirectUrl / window.top
  6. 在移动端检查 iframe 全宽与布局 表现

附录:接口速查

需鉴权的接口

方法Endpoint鉴权说明
GET/api/miniapp/api/auth/tokenRefresh Token用 refresh 换 access
GET/api/miniapp/api/user/infoAccess Token当前用户信息
GET/api/miniapp/api/token/listAccess Token租户代币与用户余额
GET/api/miniapp/api/campaign/voucherSchema/published/listAccess Token进行中活动下的礼券列表
POST/api/miniapp/api/userManagement/token/sendAccess Token(管理员)向用户发代币
POST/api/miniapp/api/campaign/voucherSchema/sendAccess Token(管理员)向用户发礼券
GET/api/miniapp/api/user/voucherNft/listAccess 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 平台团队。

VIO v4 平台文档