@seayoo-io/combo-sdk-node
TypeScript icon, indicating that this package has built-in type declarations

1.2.0 • Public • Published

Combo SDK for Node.js

@seayoo-io/combo-sdk-node 是世游核心系统 (Combo) 为 Node.js 提供的 SDK。

提供以下服务端功能,供游戏侧使用:

  • 请求 Server REST API 并解析响应
  • 接收 Server Notifications 并回复响应
  • 验证世游服务端签发的 Identity Token 和 Ads Token
  • 接收 Server GM Command 指令并回复响应

breaking change

v1 新增 gm 模块的 IdempotencyKey,主要改动包括:

  1. 修改了 commandHandler 的参数,插入 idempotencyKey 参数;当此参数为非空字符串时需要处理幂等性;
  2. 新增预设错误 IdempotencyConflict / IdempotencyMismatch
  3. gm 服务版本号升级到 2.0

安装

// 使用 npm 安装
npm install @seayoo-io/combo-sdk-node

// 使用 pnpm 安装
pnpm add @seayoo-io/combo-sdk-node

导入方式

请按照工程实际情况选择导入方式,后续示例采用 ES 方式导入。

// ES 
import { ApiClient } from "@seayoo-io/combo-sdk-node"

// CommonJS
const { ApiClient } = require("@seayoo-io/combo-sdk-node")

ApiClient

初始化

import { ApiClient, Endpoint } from "@seayoo-io/combo-sdk-node"

const client = new ApiClient({
    game: "<GameId>",
    secret: "<SecretKey>",
    endpoint: Endpoint.China,
    // 以下参数可选
    maxRetry: 1,                // 失败后自动重试次数,默认 1
    retryInterval: 1000,        // 重试间隔,默认是 1000,单位 ms,可以传递函数动态设置间隔
    logger: function(log) {},   // 请求日志函数,log 类型参见源码类型定义
    timeout: 5000,              // 超时等待时长,单位 ms,默认 5000
})

创建订单 CreateOrder

// 导入枚举变量
import { Platform } from "@seayoo-io/combo-sdk-node"

// 创建订单
const createOrderResult = await client.createOrder({
  /** 用于标识创建订单请求的唯一 ID */
  reference_id: "<游戏订单ID>",
  /** 发起购买的用户的唯一标识 */
  combo_id: "<ComboID>",
  /**
   * 要购买的商品 ID
   * 这里指的是在世游发行平台管理的商品 ID,其中重要的两个约定是:
   *  - 商品价格是正整数
   *  - 商品价格单位为 分 或 美分,即商品币种所支持的最小流通单位
   */
  product_id: "<ProductID>",
  /** 平台,支持类型见源码类型定义 Platform */
  platform: Platform.iOS, 
  /**
   * 游戏侧接收发货通知的服务端地址
   * 这个地址对应的服务端应该通过 Notify 模块实现路由处理
   */
  notify_url: "https://<YourSite>/<YourPath>"
  /** 要购买的商品的数量,最小为 1,必须为正整数 */
  quantity: 1,
  /** [可选]订单上下文,在发货通知中透传回游戏 */
  context: "",
  /**
   * 订单的元数据,详细定义参看 OrderMetaData
   *
   * 大部分元数据用于数据分析与查询,游戏侧应当尽量提供
   * 某些元数据在特定的支付场景下是必须的,例如微信小游戏的 iOS 支付场景
   */
  meta: { ... }
})

interface OrderMetaData {
  /** 游戏大区 ID */
  zone_id?: string
  /** 游戏服务器 ID */
  server_id?: string
  /** 游戏角色 ID */
  role_id?: string
  /** 游戏角色名 */
  role_name?: string
  /** 游戏角色的等级 */
  role_level?: number
  /** 微信小游戏的 App ID, 微信小游戏的 iOS 支付场景必须传入 */
  weixin_appid?: string
  /** 微信小游戏的玩家 OpenID, 微信小游戏的 iOS 支付场景必须传入 */
  weixin_openid?: string
}

interface CreateOrderResponse {
  /** 世游服务端创建的,标识订单的唯一 ID。 */
  order_id: string
  /** 世游服务端创建的订单 token,用于后续支付流程。 */
  order_token: string
  /** 订单失效时间。Unix timestamp in seconds。 */
  expires_at: number
}

进入/离开游戏

下述两个接口对应的功能是中宣部防沉迷系统的上报功能,这里的 SessionID 是一次游戏会话的标识,也就是每次玩家进入游戏+离开游戏,算一个 Game Session。单次游戏会话的上下线动作必须使用同一个 SessionID 上报。

// 进入游戏
await client.enterGame("<ComboID>", "<SessionID>");

// 离开游戏
await client.leaveGame("<ComboID>", "<SessionID>");

注意,SessionID 不可重复使用,可以使用 SDK 提供的工具函数生成 SessionID

import { genSessionID } from "@seayoo-io/combo-sdk-node"

// genSessionID 接受唯一参数 comboId 作为输入,返回一个 32 位固定长度的不重复字符串
const userSessionID = genSessionID("<ComboID>")

Notify

Step 1 准备参数

import { NotificationType, Endpoint } from "@seayoo-io/combo-sdk-node"

// 1.1 定义消息处理函数,类型定义参见源码 NotificationHandler
function notificationHandler(type, payload) {
    switch(type) {
        case NotificationType.ShipOrder:
            // Do your work with payload:定义见 ShipOrderNotification
            break;
        case NotificationType.Refund:
            // Do your work with payload:定义见 RefundNotification
            break;
    }
}

/**  ShipOrderNotification 是订单发货通知的数据结构,包含了订单的详细信息 */
interface ShipOrderNotification {
  /** 世游服务端创建的,标识订单的唯一 ID */
  order_id: string
  /** 游戏侧用于标识创建订单请求的唯一 ID */
  reference_id: string
  /** 发起购买的用户的唯一标识 */
  combo_id: string
  /** 购买的商品 ID,指的是在世游发行平台管理的商品 ID */
  product_id: string
  /** 购买的商品的数量 */
  quantity: number
  /** 订单币种代码。例如 USD CNY */
  currency: string
  /** 订单金额,单位为分,如果币种为美元,则单位为美分。 */
  amount: number
  /** 游戏侧创建订单时提供的订单上下文,透传回游戏 */
  context?: string
}
  
interface RefundNotification {
    // 结构同 ShipOrderNotification
}

// 1.2 创建配置,类型定义参见源码 SDKBaseConfig
const config = {
    game: "<GameId>",
    secret: "<SecretKey>",
    endpoint: Endpoint.China
}

Step 2 使用 RequestHandler 处理通知

// 使用 http 模块的处理函数
import { getNotificationHandler } from "@seayoo-io/combo-sdk-node"
const notifyHandler = getNotificationHandler(config, notificationHandler)
http.createServer(async function(req, res){
    if(req.path === "<YourNotifyUrl>" && req.method === "POST") {
       await notifyHandler(req, res)
    }
})

// 或,使用 Express 处理函数
import { getNotificationHandlerForExpress } from "@seayoo-io/combo-sdk-node"
const expressHandler = getNotificationHandlerForExpress(config, notificationHandler)
app.post("/path/to/your/notify/url", expressHandler)

// 或,使用 Koa 处理函数
import { getNotificationHandlerForKoa } from "@seayoo-io/combo-sdk-node"
const koaHandler = getNotificationHandlerForKoa(config, notificationHandler)
app.post("/path/to/your/notify/url", koaHandler)

⚠️ 注意事项

由于 http request 的 body 只能被消费(读取)一次,如果您使用了 express 或 koa 框架并优先加载了 bodyParse 类似的插件,那么 NotificationHandler 将无法再次读取 request 内容用于签名计算,此时需要提供请求的原始 rawBody 才可以继续。

网上有一些方法可以在 express 或 koa 框架下获取 rawBody 内容,但都具有一定的破坏性风险。

推荐以下解决方案:

Step 2 使用 RequestMiddleware 处理通知

针对 express 或 koa 框架,可以创建一个高优先级的插件来处理回调通知,其原理如下

// 插件需要先于 bodyParse 类似的插件执行,以确保可以获取原始请求的内容
app.use(function(req, res, next){
    // 在其他插件处理之前,检查是否为通知请求的 url,方法是否为 POST
    if(req.method === "POST" && req.path === "/path/to/your/notify/url") {
        // 如果是,则交由插件处理后续响应
        notifyMiddleware(req, res)
        return
    }
    // 否则其他一切正常
    next()
})

SDK 提供了两个插件来处理:

// express 插件
import { getNotificationMiddlewareForExpress } from "@seayoo-io/combo-sdk-node"
const notifyMiddleware = getNotificationMiddlewareForExpress(
    "/path/to/your/notify/url",
    config,
    notificationHandler
);
app.use(notifyMiddleware);

// koa 插件
import { getNotificationMiddlewareForKoa } from "@seayoo-io/combo-sdk-node"
const notifyMiddleware = getNotificationMiddlewareForKoa(
    "/path/to/your/notify/url",
    config,
    notificationHandler
);
app.use(notifyMiddleware);

Verify

初始化

import { TokenVerifier, Endpoint } from "@seayoo-io/combo-sdk-node"

const verifier = new TokenVerifier({
    game: "<GameId>",
    secret: "<SecretKey>",
    endpoint: Endpoint.China
})

IdentityToken 验证

import { IdP } from "@seayoo-io/combo-sdk-node"
// verifyIdentityToken 对 IdentityToken 进行验证
// 返回 IdentityPayload 数据,如果解析出错则返回 Error 对象
const identityPayload = verifier.verifyIdentityToken(token)
if(identityPayload instanceof Error) {
    console.error(result.message)
} else {
    // 微信登录判断
    if(identityPayload.idp === IdP.Weixin) {
        // 微信登录会提供 weixin_unionid
        // do something
    }
    // 游客登录判断
    if(identityPayload.idp === IdP.Device) {
        // do something
    }
    // 更多 IdP 枚举可以查看源码定义 src/const.ts
}

// IdentityPayload 数据定义
interface IdentityPayload {
  /** combo_id 是世游分配的聚合用户 ID 游戏侧应当使用 combo_id 作为用户的唯一标识。*/
  combo_id: string
  /** IdP (Identity Provider) 是用户身份的提供者 */
  idp: IdP
  /**
   * external_id 是用户在外部 IdP 中的唯一标识
   *
   * 例如:
   *  - 如果用户使用世游通行证登录,那么 external_id 就是用户的世游通行证 ID。
   *  - 如果用户使用 Google Account 登录,那么 external_id 就是用户在 Google 中的账号标识。
   *  - 如果用户使用微信登录,那么 external_id 就是用户在微信中的 OpenId。
   *
   * 注意:
   * 游戏侧不应当使用 external_id 作为用户标识,但可以将 external_id 用于特定的业务逻辑。
   */
  external_id: string
  /** external_name 是用户在外部 IdP 中的名称,通常是用户的昵称 */
  external_name: string
  /**
   * weixin_unionid 是用户在微信中的 UnionId
   * 游戏侧可以使用 weixin_unionid 实现多端互通
   * 注意:weixin_unionid 只在 IdP 为 weixin 时才会有值。
   */
  weixin_unionid: string
  /**
   * distro 是游戏客户端的发行版本标识。
   * 游戏侧可将 distro 用于服务端数据埋点,以及特定的业务逻辑判断。
   */
  distro: string
  /**
   * variant 是游戏客户端的分包标识。
   * 游戏侧可将 variant 用于服务端数据埋点,以及特定的业务逻辑判断。
   * 注意:Variant 只在客户端是分包时才会有值。当客户端不是分包的情况下,variant 为空字符串。
   */
  variant: string
}

AdToken 验证

// verifyAdToken 对 AdToken 进行验证
// 返回 AdPayload 数据,如果解析出错则返回 Error 对象
const adInfo = vierfier.verifyAdToken(token)
if(result instanceof Error) {
    console.error(result.message)
} else {
    // got ad info: { combo_id, placement_id, impression_id }
}

// AdPayload 定义
interface AdPayload {
  /** combo_id 是世游分配的聚合用户 ID,游戏侧应当使用 combo_id 作为用户的唯一标识。*/
  combo_id: string
  /** placement_id 是广告位 ID,游戏侧用它确定发放什么样的广告激励。*/
  placement_id: string
  /** impression_id 是世游服务端创建的,标识单次广告播放的唯一 ID。*/
  impression_id: string
}

GM

GM 模块使用方式类似于 Notify 模块。

Step 1 准备

import { GMError } from "@seayoo-io/combo-sdk-node"

// 1.1 定义 GM 处理函数
function gmCommandHandler(command, args, requestId, idempotencyKey, version) {
    // requestId 本次 GM 请求的唯一 ID。游戏侧可用此值来对请求进行去重。
    // idempotencyKey 本次 GM 请求的 Idempotency Key。如果有非空值则应当执行幂等处理逻辑。
    // SDK 已经内置了 idempotencyKey 的处理工具和配置,详细见下描述
    // version 对应的是世游 GM 服务的版本号,目前固定是 2.0
    // command 对应的是 GM 协议中定义的方法名,区分大小写
    switch(command) {
        case "SomeCmdName":
            // 参数 args 由协议定义,在使用前需要做严格的格式和逻辑校验
            // 返回值需要根据协议定义进行响应,通常是一个包含了多个字段的 object
            return await execSomeCommand(args);
        case "OtherCmdName":
            // 抛出自定义错误有三种方式:
            // 1. 返回一个 Error 对象,最终响应以 500(internalServerError)作为 http status
            return new Error("自定义错误信息")
            // 2. 返回一个自定义消息体 (预设的消息类型见下描述),推荐使用此方式
            return { error: GMError.ThrottlingError, message: "请求过于频繁,请稍后再试" }
            // 3. 直接抛出错误,用于处理异常场景,最终响应以 500 作为 http status
            //    相比于方式1,此流程会打印一条错误日志
            throw new Error("some unknown error")
        ...
        default: // 不识别的指令进行返回
            return { error: GMError.InvalidCommand, message: "不识别的指令"+ command }
    }
}

/** GM 预设错误类型枚举 */
const enum GMError {
  /** 请求中的 HTTP method 不正确,没有按照预期使用 POST。*/
  InvalidHttpMethod = "invalid_http_method",
  /** 请求中的 Content-Type 不是 application/json。*/
  InvalidContentType = "invalid_content_type",
  /** 对 HTTP 请求的签名验证不通过。这意味着 HTTP 请求不可信。 */
  InvalidSignature = "invalid_signature",
  /** 请求的结构不正确。例如,缺少必要的字段,或字段类型不正确。 */
  InvalidRequest = "invalid_request",
  /** 游戏侧不认识请求中的 GM 命令。 */
  InvalidCommand = "invalid_command",
  /** GM 命令发送频率过高,被游戏侧限流,命令未被处理。 */
  ThrottlingError = "throttling_error",
  /** 幂等处理重试请求时,idempotency_key 所对应的原始请求尚未处理完毕。*/
  IdempotencyConflict = "idempotency_conflict",
  /** 幂等处理重试请求时,请求内容和 idempotency_key 所对应的原始请求内容不一致。*/
  IdempotencyMismatch = "idempotency_mismatch",
  /** GM 命令的参数不正确。例如,参数缺少必要的字段,或参数的字段类型不正确。 */
  InvalidArgs = "invalid_args",

  /** 游戏当前处于停服维护状态,无法处理收到的 GM 命令。*/
  MaintenanceError = "maintenance_error",
  /** 网络通信错误。 */
  NetworkError = "network_error",
  /** 数据库操作异常导致 GM 命令执行失败。 */
  DatabaseError = "database_error",
  /** GM 命令处理超时。 */
  TimeoutError = "timeout_error",
  /** 处理 GM 命令时内部出错。可作为兜底的通用错误类型。*/
  InternalError = "internal_error",
}

// 1.2 创建配置,相比于 Notify 模块,此处不需要 endpoint 配置
const config = {
   game: "<GameId>",
   secret: "<SecretKey>",
}

Step 2 创建 Store Helper 以启用 idempotencyKey 处理逻辑

// SDK 内置 idempotencyKey 处理逻辑,其需要相关存储来实现,比如 Redis。
// 创建 Store Helper 用于启用内置的 idempotencyKey 处理逻辑
// store helper 需要实现以下方法即可:
interface IdempotencyKeyStoreHelper {
  /**
   * setNX 用于原子性地存储幂等记录并返回旧值。
   * value 仅在 key 不存在时才会被存储 (Only set the key if it does not already exist)。
   * 返回值是 key 存在时的旧值。如果 key 不存在则返回空字符串。
   */
  setNX: (key: string, value: string) => Promise<string>

  /**
   * setXX 用于原子性地更新已存在的幂等记录。
   * value 仅在 key 存在时才会被存储 (Only set the key if it already exists)。
   */
  setXX: (key: string, value: string) => Promise<void>
}
  
// SDK 内置实现了两个工具类
import { MemoryIdempotencyStore, RedisIdempotencyStore } from "@seayoo-io/combo-sdk-node"

// MemoryIdempotencyStore 仅仅用于本地调试
const storeHelper = new MemoryIdempotencyStore()
// RedisIdempotencyStore 基于 ioredis 实现
const storeHelper = new RedisIdempotencyStore({
   /** 超时设定,单位秒,推荐不低于24小时 */
   ttl?: number
   /** ioredis 客户端 */
   client: Redis
   /** key 前缀 */
   prefix?: string
})

// 当然可以直接自行实现上述 store helper 方法

Step 3 绑定处理函数

绑定方式同 Notify 类似,提供三种不同的方式来对接 http 服务。推荐使用方式 3。

// 方式 1. 使用 http 模块处理函数
import { getGMCommandHandler } from "@seayoo-io/combo-sdk-node"
// storeHelper 类型为 IdempotencyKeyStoreHelper
// 可不传递,如果不提供则不会启用内置的 idempotencyKey 处理逻辑,下同
const handler = getGMCommandHandler(config, gmCommandHandler, storeHelper)
http.createServer(async function(req, res){
    if(req.path === "<YourNotifyUrl>" && req.method === "POST") {
       await handler(req, res)
    }
})

// 方式 2. 使用 express / koa 的 handler
import { getGMHandlerForExpress } from "@seayoo-io/combo-sdk-node"
const expressHandler = getGMHandlerForExpress(config, gmCommandHandler, storeHelper)
app.post("/path/to/your/gm/url", expressHandler)

import { getGMHandlerForKoa } from "@seayoo-io/combo-sdk-node"
const koaHandler = getGMHandlerForKoa(config, gmCommandHandler, storeHelper)
app.post("/path/to/your/gm/url", koaHandler)

// 方式 3. 使用 expres / koa 的中间件,推荐
import { getGMMiddlewareForExpress } from "@seayoo-io/combo-sdk-node"
const gmMiddleware = getGMMiddlewareForExpress("/path/to/your/gm/url", config, gmCommandHandler, storeHelper);
app.use(gmMiddleware);

import { getGMMiddlewareForKoa } from "@seayoo-io/combo-sdk-node"
const gmMiddleware = getGMMiddlewareForKoa("/path/to/your/gm/url", config, gmCommandHandler, storeHelper);
app.use(gmMiddleware);

/@seayoo-io/combo-sdk-node/

    Package Sidebar

    Install

    npm i @seayoo-io/combo-sdk-node

    Weekly Downloads

    3

    Version

    1.2.0

    License

    MIT

    Unpacked Size

    176 kB

    Total Files

    38

    Last publish

    Collaborators

    • zjcn5205
    • zhiguangwang