NovaSDK 开发文档

最近接触到公司游戏聚合的项目, 突然想结合之前公司开发经验来考虑自己应该怎么从而完善设计, 从而方便其他人借鉴处理

UML

首先这里介绍下具体的字段参考表, 需要明确规则: 传递字段必须有默认值, 不允许为 null 值

类型 默认值 取值范围 说明
string ‘’ - 字符串类型, 默认只允许为空字符串, 一般从数据库性能考虑建议字符串控制在 varchar(255) 之中
boolean 0 / 1 - 布尔类型, 一般来说建议采用数值1和0来区分(数据库采用 tinyint(1) 类型), 主要部分数据清洗或者转化成字符串, 也就是 true"true" 的问题
number 0 int64 数值类型, 数值方面建议安装 int64 范围取值(数据库采用 bigint 类型), 部分开发语言最大值需要特定 BigInt 类型
time 0 int64 时间戳类型, 推荐采用 bigint 类型保存 UTC 时间的毫秒时间戳, 因为基于0时区开始的时间戳方便直接做全球化跨时区换算, 所有开发语言都内置支持
country ‘CN’ - 字符串类型, 全球化的来源地区码(数据库采用 char(2) 类型, 入库必须强制转大写), 基于 ISO 3166-1 的地区统一标准码, 海外部分支付渠道需要支付的地区来源标识
currency ‘CNY’ - 字符串类型, 全球化的地区货币码(数据库采用 char(3) 类型, 入库必须强制转大写), 基于 ISO 4217 的地区货币标准码, 海外部分支付渠道需要支付的地区货币标识
scale 2 uint8 数值类型, 全球币种小数位(数据库采用 unsigned tinyint 类型), 大部分国家采用以 为货币单位, 而部分海外国家是没有 单位(日本/韩国最小单位为 日元/韩元), 可以通过计算换算最小货币金额

日常规范差不多是这样, 实际上可以日常动态调整部分需求

  • 有的业务系统喜欢直接采用 秒级时间戳 从而节省性能, 但是后续面向全球化还是要扩展发起的地区时区字段

  • 有的业务系统不需要货币和地区码, 可能单一国家业务不需要通过美元/日元/韩元结算金额

应用表设计

注意: 我习惯将游戏统统归为 应用(app), 而不会直接将其受限于 游戏(game), 主要应对支持除游戏等所有应用对接方式

这部分没有什么具体定式, 都是可以按照各自业务需求来动态调整, 接下来就是表结构设计(这里是 MariaDB(MYSQL Fork的分支))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

# 首先是通用的单一用数据应用表
# 虽然工作大部分情况都在游戏公司, 但是我还是喜欢用 app 这个命令来包含游戏分类
# 这部分是我个人喜欢, 也可以按照自己需求改成 game_info 之类, 具体按照自身需求出发
create table if not exists nova_app_info
(
# 通用信息
app_id bigint not null auto_increment comment '数据主键, 用于标识递增',
app_ident varchar(32) not null comment '应用唯一标识字符串, 有时候应用不喜欢外部看到数值ID(会被爬虫遍历嗅探递增从而获取全部应用)',
app_key varchar(32) not null comment '客户端和服务端使用的授权等参数签名哈希值',
app_secret varchar(32) not null comment '服务端使用的支付等安全性高的签名哈希值',

# 基础信息
platform varchar(64) not null default '' comment '应用分配平台, android/ios/h5/pc 标识',
name varchar(64) not null comment '应用的名称, 提供给应用初始化接口的信息初始化',
title varchar(255) not null default '' comment '应用的长标题, 用于显示应用的具体标题内容',
content varchar(255) not null default '' comment '应用的详细内容, 可以用于整体应用说明, 需要内部支持富文本渲染',
language varchar(8) not null default 'en' comment '应用的默认 i18n 语言模板, 比如 en = 英文, zh-CN = 简体中文, zh-TW = 繁体中文',
orientation tinyint not null default '0' comment '屏幕方向, 0 = 默认, 1 = 横屏, 2 = 竖屏',
keyboard varchar(255) not null default '' comment '应用类型(以因为分割风格), 比如 "武侠,卡牌,MMO" 之类方便对外 SEO',


# 图标相关, 图片只需要保存相对路径即可, 方便后面上传 CDN
# 比如 https://site.example.com/static/images/test.png 就只需要保存 /static/images/test.png 路径部分
# 后面按照这部分路径保存到CDN的地址, 不建议采用过深的目录保存图片地址, 可能会导致数据长度不够被截断
icon varchar(255) not null default '' comment '小型图标图片地址, 使用 192x192 像素',
cover varchar(255) not null default '' comment '大型应用封面图片地址',
background varchar(255) not null default '' comment '大型应用背景图片地址',


# 其他创建|更新信息
status tinyint not null default '0' comment '应用状态, 默认为0代表刚创建',
create_time bigint not null comment '创建时间',
create_ip varchar(64) not null comment '创建IP',
update_time bigint not null default '0' comment '更新时间',
update_ip varchar(64) not null default '' comment '更新IP',


# 索引信息
unique key find_ident (app_ident),
primary key (app_id)
) comment '应用信息表, 这部分只需要简单的概述信息, 其他需要扩展的外部信息最好放置于大表'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci
auto_increment = 10000 # 从 10000 开始, 预留低于这个值的应用都是测试游戏应用
;


# 主要应用表生成之后, 就可以基于这部分应用来细分对应用户数据和渠道数据, 这里插入测试数据应用
# 这就是目前手动创建的最基础测试应用
INSERT INTO nova_app_info
VALUES (10000,
# 应用信息
'dq60xls02i',
'c33614a5d9a91580f56d09b23b4d0036',
'ebe150ff1f38d30a1cb26d956fa92b62',

# 基础信息
'h5',
'Nova游戏',
'Nova游戏 - 聚合测试应用',
'Nova游戏<br> 助力游戏聚合相关',
'zh-CN',
0,
'武侠,回合制',

# 图标相关
'/static/images/icon/bd498e9235d5a0aa15b6e7adfd43f556.png',
'/static/images/cover/a2e4119c419378698b6629ae4988d880.png',
'/static/images/background/55181431f164e657067faca4b50b8e5f.png',

# 其他创建|更新信息
1,
1780677501757,
'127.0.0.1',
0,
'');

这就是游戏应用主体表设计, 注意: 一个游戏是关联多个渠道(也包含官方服), 渠道游戏唯一关联标识 = ({渠道ID} + {游戏ID})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 聚合渠道部分也是共享表, 可以放在统一的数据库
create table if not exists nova_channel_info
(
# 通用信息
channel_id bigint not null auto_increment comment '数据主键, 用于标识递增',
channel_ident varchar(32) not null comment '渠道的唯一标识字符串, 这部分可以提供自定义, 但是只允许小写字母+数值等',

# 基础信息
name varchar(64) not null comment '渠道的展示名称, 默认设定和 channel_ident 保持一致, 后续如果想修改展示名称也方便',
client_params varchar(255) not null default '{}' comment '保存对应渠道客户端配置参数, 以 JSON 的 {} 对象组保存',
server_params varchar(255) not null default '{}' comment '保存对应渠道服务端配置参数, 以 JSON 的 {} 对象组保存',


# 其他创建|更新信息
status tinyint not null default '0' comment '数据状态, 默认为0代表可用, 渠道会出现多种禁用状态(后续大于 0 代表不同停用理由, 比如: 1 - 常规封禁, 100 - 合作结束停用)',
create_time bigint not null comment '创建时间',
create_ip varchar(64) not null comment '创建IP',
update_time bigint not null default '0' comment '更新时间',
update_ip varchar(64) not null default '' comment '更新IP',

unique key find_ident (channel_ident),
primary key (channel_id)
) comment '应用渠道表, 注意: 渠道表数据表绝对不能删除数据(按照 status 不展示即可), 强制删除会导致关联统计异常'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci
;

# 这里构建属于我们的特殊渠道
# 现在就可以准备使用我们自定义的 {APP_ID = {10000}, CHANNEL_IDENT = {novagame}} 的游戏应用
INSERT INTO nova_channel_info
VALUES (DEFAULT,
'novagame',
'novagame',
'{}',
'{}',
0,
1780677501757,
'127.0.0.1',
0,
'');

至此就获得自定义的 {APP_ID = {10000}, CHANNEL_IDENT = {novagame}} 的渠道专服游戏, 现在就是做进一步规划设计

接口设计

聚合的游戏应用常采用类似链路设计如下

https://{API_URL}/business/{3RD_CHANNEL}/{METHOD}/{APP_ID}/{CHANNEL_IDENT}

  • {API_URL}: 对外开放的API服务域名

  • {3RD_CHANNEL}: 对接的第三方渠道SDK, 比如 QQ腾讯大厅/QuickSDK/果盘游戏SDK/百度 等对接渠道

  • {METHOD}: 调用第三方渠道 SDK 方法, 主要对接 应用初始化/验证授权/发起支付/支付回调/信息上报

  • {APP_ID}: 渠道对应的游戏应用ID, 也就是聚合系统内部的游戏ID

  • {CHANNEL_IDENT}: 聚合系统当中自己划分的特殊渠道标识

聚合系统主要目标就是对接 QQ腾讯大厅/QuickSDK/果盘游戏SDK/百度 等 SDK 从而集成到自己的系统

这里提供 QuickSDK/果盘游戏SDK 文档, 可以先行查看对接流程

可以对照两者SDK对接时候接入方法不同, 所以请求和响应参数不一样, 这里以 果盘SDK 对接文档来扩展

  • 请求地址: https://{API_URL}/business/guopan/{METHOD}/{APP_ID}/{CHANNEL_IDENT}

请求 {METHOD} 的取值如下, 其实内部就是涵盖大部分应用的 生命周期 流程

方法名称 路径地址 说明
init /business/guopan/init/{APP_ID}/{CHANNEL_IDENT} 下发第三方SDK的配置参数, 比如果盘SDK要求客户端需要通过 AppId/ClientKey 参数签名, QuickSQK 是需要通过 ProductCode/ProductKey, 通过接口可以下发游戏这部分信息提供初始化SDK
login /business/guopan/login/{APP_ID}/{CHANNEL_IDENT} 第三方SDK唤起登录授权接受提交给我们聚合SDK的二次验证的接口, 比如果盘游戏SDK验证正确会响应 uid/token, 客户端需要再次将这部分信息提交给我们聚合SDK接口验证, 验证通过同步数据库创建用户实体
pay /business/guopan/pay/{APP_ID}/{CHANNEL_IDENT} 游戏应用内部预下单功能, 必须先提交到我们聚合SDK生成数据库中等待支付的订单, 然后按照第三方SDK的支付参数返回给客户端, 最后客户端再次去对应第三方SDK发起支付请求
notify /business/guopan/notify/{APP_ID}/{CHANNEL_IDENT} 游戏应用返回的回调入口, 大部分情况是配置在第三方后台支付回调或者配置这部分回调地址
order /business/guopan/order/{APP_ID}/{CHANNEL_IDENT} 游戏应用查询后台订单的接口, 并不是必须要实现的接口, 一般用于支付订单的二次检查查询
report /business/guopan/report/{APP_ID}/{CHANNEL_IDENT} 游戏应用内部数据上报功能, 实际上流程也也是和发起订单差不多, 必须先提交到我们聚合SDK上报日志, 然后聚合SDK生成对应第三方SDK参数再由客户端提交过去
quit /business/guopan/quit/{APP_ID}/{CHANNEL_IDENT} 游戏应用注销会话退出功能, 部分 H5 游戏可能不需要(可以不去实现), 主要是用于玩家用户手动退出注销, 有的系统需要用到记录玩家手动退出时间和IP

这里用在我们在自己内聚系统当中创建应用来说明, 架设目前我们有款游戏要接入果盘SDK游戏渠道:

  • APP_ID: 10000

  • CHANNEL_IDENT: novagame

  • 3RD_CHANNEL: guopan

那么这里的对应的渠道游戏应用请求方法如下, 这里以 CURL 来做模拟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# - 应用初始化, 从聚合SDK拉取第三方参数
curl 127.0.0.1:8080/business/guopan/init/10000/novagame
# 如果聚合系统当中存在该渠道游戏, 那么就可以拉取如下响应, 接收到之后客户端对第三方SDK再次初始化
# {"status":0,"message":"OK","params":{"appid":800018,"appkey":"bd6e50186755e27113f5716818b01d4b"}, "app": {....}}
# - params 参数其实也就是渠道表内部对应客户端参数字段, 也就是参考数据库表 nova_channel_info.client_params 直接返回过来就行了
# - app 参数则是游戏内部通用名称 icon/background/cover 等信息, 部分游戏研发需要在初始化设置某些封面和背景图片, 其实就是就是取该接口信息
# 失败也是直接确认状态即可
# {"status":1,"message":"ERR","params":{}}

# - 后续应用拉取接口也是类型
curl 127.0.0.1:8080/business/guopan/init/10000/novagame
curl 127.0.0.1:8080/business/guopan/login/10000/novagame
curl 127.0.0.1:8080/business/guopan/pay/10000/novagame
curl 127.0.0.1:8080/business/guopan/notify/10000/novagame
curl 127.0.0.1:8080/business/guopan/order/10000/novagame
curl 127.0.0.1:8080/business/guopan/report/10000/novagame
curl 127.0.0.1:8080/business/guopan/quit/10000/novagame

目前开发部分测试 demo 的 接口,可以通过请求以下地址来查看具体接口配置:

对照内部的 params 参数就是下发给第三方SDK的配置参数, 后续流程基本上就是拿这些客户端参数按照常规对接

因为这部分方案这是我个人开发服务器, 这个服务器国内可能会抽风所以直接附带参考的响应数据:

1
2
3
4
5
6
7
8
# 目前支持 GET 和 POST 参数混合提交
curl https://dev-sdk.meteorcat.me/business/quick/init/800018/novagame
# 响应数据如下
# {"status":200,"data":{"time":1780751420262,"app":{"app_id":10000,"app_ident":"dq60xls02i","app_name":"Nova游戏","platform":"h5","name":"","title":"","content":"","language":"zh-CN","orientation":0,"keyboard":["武侠","卡牌","MMO"],"icon":"","cover":"","background":""},"params":{"ProductKey":74209704,"ProductCode":"6002cb722fc0f03ec24279ea4bb16319"}}}

curl https://dev-sdk.meteorcat.me/business/guo-pan/init/800018/novagame
# 响应参数如下
# {"status":200,"data":{"time":1780751465542,"app":{"app_id":10000,"app_ident":"dq60xls02i","app_name":"Nova游戏","platform":"h5","name":"","title":"","content":"","language":"zh-CN","orientation":0,"keyboard":["武侠","卡牌","MMO"],"icon":"","cover":"","background":""},"params":{"appid":800018,"appkey":"bd6e50186755e27113f5716818b01d4b"}}}

这就是聚合SDK响应给第三方 SDK 所需初始化的参数, 接下来就是关于用户相关的结构设计

用户结构

注意: 对于多应用来说单表已经很难支撑这部分的数据量, 所以需要采用数据分表处理(需要做全局ID, 后续复制数据合并到其他表)

这里的表推荐做好分表处理(时候分库看后续需求, 但是如果用户量大的情况分表是必须的), 表结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 这里需要做数据库分表, 以 游戏应用ID 做区分
CREATE TABLE IF NOT EXISTS nova_user_${mod_num}
(
-- 通用主键与关联维度
uid BIGINT NOT NULL COMMENT '用户主键,由Redis按APP_ID分组全局自增生成',
app_id BIGINT NOT NULL COMMENT '应用ID,关联 nova_app_info.app_id',
channel_id BIGINT NOT NULL DEFAULT 0 COMMENT '渠道ID,关联 nova_channel_info.channel_id',

-- 账号基础信息
username VARCHAR(255) NOT NULL COMMENT '账号/第三方用户唯一标识',
password VARCHAR(255) NOT NULL COMMENT '密码密文(Bcrypt) / 第三方渠道凭据Token',
nickname VARCHAR(255) NOT NULL COMMENT '用户展示昵称, 默认同 username',
status TINYINT NOT NULL DEFAULT 0 COMMENT '账号状态:0=正常可用,非0=封禁/禁用',
token VARCHAR(255) NOT NULL DEFAULT '' COMMENT '当前有效授权令牌(JWT/哈希串)',

-- 设备系统信息
device VARCHAR(16) NOT NULL DEFAULT '' COMMENT '设备平台(win32/Android/iOS等)',
os VARCHAR(16) NOT NULL DEFAULT '' COMMENT '系统类型(Windows/Linux/Mac/iOS/Android)',

-- 登录登出记录
login_time BIGINT NOT NULL DEFAULT 0 COMMENT '最近登录时间,UTC 毫秒时间戳',
login_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近登录IP',
logout_time BIGINT NOT NULL DEFAULT 0 COMMENT '最近登出时间,UTC 毫秒时间戳',
logout_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近登出IP',

-- 数据创建&更新
create_time BIGINT NOT NULL COMMENT '账号创建时间,UTC 毫秒时间戳',
create_ip VARCHAR(64) NOT NULL COMMENT '账号创建IP',
update_time BIGINT NOT NULL DEFAULT 0 COMMENT '资料最后更新时间,UTC 毫秒时间戳',
update_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '资料最后更新IP',

-- 索引
KEY idx_app_id (app_id),
KEY idx_app_username (app_id, username),
KEY idx_status (status),
KEY idx_login_time (login_time),

PRIMARY KEY (uid)
) COMMENT = '应用用户主表,按uid取模分表(0-9),uid由Redis全局自增'
ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci;

关于分表有个注意点, 就是目前主流如果玩家信息分表都有两种处理方式:

  • 直接标识: 之前创建的游戏应用 {APP_ID = 10000}, 这部分数据直接将用户分表命名为 nova_user_10000

  • 取模标识: 通过将 {APP_ID = 10000} 按照 APP_ID % 10 = {TABLE_ID} 计算出散列在 nova_user_{0-9} 之中

其实比较推荐采用 取模分表 算法, 主要是如果游戏应用比较多的话会导致单库当中散列大量的数据表

后续聚合应用的规模上来, 有时候打开数据库刷出上万个游戏数据分表也不好检索和处理

所以这里采用取模算法, 将之前得出的 {APP_ID = 10000} 散布在 {10000} % {10} = nova_user_{0}

只是日常使用的时候, 需要每次都额外加上 where app_id=10000, 但是这部分可以加个单独索引即可, 问题也不是那么严重

处理完用户信息的数据库表设计之后, 现在就是准备接口方面设计, 也就是需要以上面第三方对接的上报授权请求之后的请求和响应:

  • QuickSDK授权认证: https://dev-sdk.meteorcat.me/business/quick/login/10000/novagame

  • 果盘SDK授权认证: https://dev-sdk.meteorcat.me/business/guo-pan/login/10000/novagame

这里客户端把对应渠道参数请求上报给我们的聚合SDK, 服务器内部和远程第三方SDK验证授权正确之后就响应 聚合系统数据库的玩家实体对象

也就是统一响应核心的数据结构必须包含以下内容:

lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
// 聚合系统内部应用ID
"app_id": 10000,
// 聚合系统内部用户ID, 建议 uid 保持字符串, 因为 Redis 全局递增的 ID 后续增长可能会出现 BigInteger 问题
"uid": "100000",
// 聚合系统内部渠道ID, 渠道其实是有待商榷的
// 建议返回 channel_ident 标识而不对外采用数值类型的 channel_id, 避免被爬虫递增嗅探
"channel_id": 1000,
// 聚合系统内部账户名
"username": "meteorcat",
// 聚合系统内部展示昵称, 默认为 username 一致, 该字段就是客户端显示的名称, 后续如果系统更新修改昵称功能才好方便处理
"nickname": "meteorcat",
// 聚合系统下发的 token, 可以更换为 JWT 响应也可以
"token": "e4d6f30a301f36a1721d3e518d9d1ec7"
}

无论授权验证响应的数据结构再怎么变, 最后核心关键依旧还是提供以上这些参数

注销登录这部分就没什么好说的, 直接提供单一的登出接口就行了, 所以跳过这部分说明

数据上报

目前数据上报的格式经过多年演变基本上固定成型了, 大致的表结构设计只需要提供特定的字段即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# 这里需要做数据库分表, 以 游戏应用ID 做取模分表
# 注意: 后续这种以APP_ID 玩家为单位的表都需要采用取模分表处理
CREATE TABLE IF NOT EXISTS nova_report_${取模ID}
(
-- 通用信息
id BIGINT AUTO_INCREMENT NOT NULL COMMENT '自增主键',
app_id BIGINT NOT NULL COMMENT '应用ID,关联 nova_app_info.app_id',
uid BIGINT NOT NULL COMMENT '用户ID,关联 nova_user_info.uid',
channel_id BIGINT NOT NULL DEFAULT 0 COMMENT '渠道ID,关联 nova_channel_info.channel_id',

-- 设备&系统信息
device VARCHAR(16) NOT NULL DEFAULT '' COMMENT '设备平台(win32/Android/iOS等)',
os VARCHAR(16) NOT NULL DEFAULT '' COMMENT '系统类型(Windows/Linux/Mac/iOS/Android)',
imei VARCHAR(255) NOT NULL DEFAULT '' COMMENT '用户提交IMEI,敏感隐私字段',

-- 上报行为标识
action INT NOT NULL COMMENT '行为ID:101=进入应用,102=创建角色,103=进入游戏,104=等级升级,105=退出游戏等',

-- 服务器信息
sid VARCHAR(255) NOT NULL DEFAULT '' COMMENT '服务器ID,字符串格式',
sname VARCHAR(255) NOT NULL DEFAULT '' COMMENT '服务器名称',

-- 角色信息
role_id VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色ID',
role_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色名称',
role_balance VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色当前游戏币数量',
role_level VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色等级',
role_power VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色战力值',
role_gender TINYINT NOT NULL DEFAULT 0 COMMENT '角色性别:0=未设置,1=男,2=女',
role_vip VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色VIP等级',
role_create_time BIGINT NOT NULL COMMENT '角色创建时间,UTC 毫秒时间戳',
role_level_up_time BIGINT NOT NULL COMMENT '角色等级变更时间,UTC 毫秒时间戳',

-- 职业信息
profession_id VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色职业ID,无则为空',
profession_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色职业名称,无则为空',

-- 公会/帮派信息
guild_id VARCHAR(255) NOT NULL DEFAULT '' COMMENT '公会ID,无则为空',
guild_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '公会名称,无则为空',
guild_master_id VARCHAR(255) NOT NULL DEFAULT '' COMMENT '公会会长角色ID,无则为空',
guild_master_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '公会会长角色名称,无则为空',

-- 记录创建信息
create_time BIGINT NOT NULL COMMENT '本条上报记录创建时间,UTC 毫秒时间戳',
create_ip VARCHAR(64) NOT NULL COMMENT '上报端IP地址',

-- 常规索引
KEY idx_uid (uid),
KEY idx_app_id (app_id),
KEY idx_app_uid (app_id, uid),
KEY idx_create_time (create_time),
KEY idx_action (action),

PRIMARY KEY (id)
) COMMENT = '应用行为上报表,按uid取模分表(0-9),高写入日志型表'
ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci;

应用上报也是通过将 {APP_ID = 10000} 按照 APP_ID % 10 = {TABLE_ID} 计算出散列在 nova_report_{0-9} 之中

一般情况下只需要关注以下声明周期来做上报提交, 可以自行按照 action 字段来调度不同上报行为

  • 选择服务器: 部分应用/游戏是单账号多服务器, 在授权完成之后就需要选择服务器的情况, 需要上报周期进度

  • 创建角色: 部分应用/游戏支持单账号多角色, 也就是进入游戏之后会经历 选择服务器 → 选择角色 → 进入游戏 过程

  • 进入应用: 也就是应用/游戏选中之后就执行上报, 留意好 创建角色说明时候的 '进入游戏的过程(选择服务器 → 选择角色 → 进入游戏)'

  • 等级升级: 在游戏当中涉及等级提升的时候做出上报行为

  • 退出游戏: 在游戏当中涉及用户退出应用/游戏的时候做出上报行为

这里上报格式一般是发送到我们聚合系统都是统一格式, 比如下方示例(这里采用 JSON 演示, 实际环境可以用 form 提交)

lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
{
// 聚合系统内部应用ID
"app_id": 10000,
// 聚合系统内部用户ID, 建议 uid 保持字符串, 因为 Redis 全局递增的 ID 后续增长可能会出现 BigInteger 问题
"uid": "100000",
// 聚合系统下发的 token, 可以更换为 JWT 响应也可以
"token": "e4d6f30a301f36a1721d3e518d9d1ec7",
// 提交参数的签名标识, 一般是提交参数排除 sign 之后按照 key 排序生成 xxx=yyy&aaa=bbb 字符串加上游戏密钥得出 md5 哈希
"sign": "098f6bcd4621d373cade4e832627b4f6",
// 这个字段可提交可不提交, 用于标识设备平台(部分设备检测该值也不太准确)
"device": "android",
// 这个字段可提交可不提交, 用于系统信息采集到 Windows/Linux/Mac/iOS 情况
"os": "Windows10",
// 设备唯一码, 目前受限于安全规定没办法采集设备唯一码, 这部分提交需要让不同客户端(Android/iOS/PC)去自行处理采集提交
"imei": "",
// 行为标识id, 用于对生命周期行为做上报提交
"action": 100,
// 服务器ID, 不要将服务器ID直接视为 number 类型, 部分特殊服务器并不是直接采用数值类型
"sid": "101",
// 服务器名称, 注意这部分字符串可能会在后台渲染, 需要做好安全过滤
"sname": "不灭战神101服",
// 角色在应用/游戏当中的唯一ID, 部分生命周期不需要就留空即可
// 在选择服务器情况下, 是没有角色信息和职业/工会等信息, 所以直接留空提交
"role_id": "",
// 角色在应用/游戏当中的名称, 可以视为账号或者昵称
// 注意: 该值也需要做好安全过滤, 部分会注入特殊字符来上报
"role_name": "",
// 当前角色身上拥有的游戏币数量, 这部分提交不推荐采用数值类型, 因为某些游戏不规范类型需要做好数量类型的兜底
"role_balance": "",
// 当前角色的游戏等级, 这部分建议直接采用字符串保存做兜底, 避免游戏等级并不是正统 1/2/3 数值, 而是 LV1/LV3/LV3 之类
"role_level": "",
// 当前角色的战力值, 之所以采用数值是因为部分游戏战力膨胀特别疯狂, 可能会传入非常庞大不可控的数值(也可能带小数)
"role_power": "",
// 当前角色性别, 部分游戏带有角色性别要素的时候需要提交, 否则默认0即可
"role_gender": 0,
// 当前角色的VIP等级, VIP等级类型定义是不可控的, 所以需要采用字符串兜底
"role_vip": "",
// 当前角色的创建时间, 适应全球化采用 UTC 毫秒时间戳, 方便面向全球化推出
"role_create_time": 0,
// 角色等级变化时间, 适应全球化采用 UTC 毫秒时间戳, 方便面向全球化推出
"role_level_up_time": 0,
// 角色职业ID, 如果游戏内部有职业系统尽量提交字段
"profession_id": "HERO_100",
// 角色职业名称, 如果游戏内部有职业系统尽量提交字段
"profession_name": "二转弓箭手",
// 工会/帮派的ID标识, 如果游戏内部有工会帮派系统尽量提交字段
"guild_id": "10001",
// 工会/帮派的名称, 如果游戏内部有工会帮派系统尽量提交字段
"guild_name": "不灭战神第一工会",
// 所属工会会长在服务器的ID标识, 如果游戏内部有工会帮派系统尽量提交字段
"guild_master_id": "2001",
// 所属工会会长在服务器的名称, 如果游戏内部有工会帮派系统尽量提交字段
"guild_master_name": "狂·神",
}

这部分统一的结构将会上报我们标准聚合SDK接口, 之后将会按照哪部分第三方SDK来下发特定的数据结构:

  • QuickSDK上报行为: https://dev-sdk.meteorcat.me/business/quick/report/10000/novagame

  • 果盘SDK上报行为 : https://dev-sdk.meteorcat.me/business/guo-pan/report/10000/novagame

上报之后数据保存在数据库之后, 可以按照第三方SDK渠道参数来返回客户端, 比如下面的格式

lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// QuickSDK角色上报需要以下数据格式
// https://www.quicksdk.com/doc-982.html#catlog11
{
// QuickSDK账号UID
"uid": "",
// 游戏区服ID
"serverId": "",
// 其他字段....
}

// 果盘SDK角色上报需要以下数据格式
// https://gpdoc.guopan.cn/project-3/doc-14/
{
// 上报行为ID, 需要在自己的聚合系统做好关联
"info_type": 100,
// 其他字段....
}

这些都是请求到聚合系统之后响应回来, 由客户端再次调用第三方SDK同步推送过去即可

支付系统

支付系统其实涉及到以下方面流程, 具体可以在脑中试着模拟这部分调用和执行链路:

  • 预下单: 应用/游戏研发方在自己服务器生成订单, 并将系统订单信息发送给聚合SDK响应聚合自己的订单数据, 响应给客户端第三方SDK支付参数调用

  • 支付回调: 第三方支付完成异步通知第三方SDK, 第三方SDK再回调到聚合SDK系统, 聚合系统则再次回调给应用游戏内部回调接口

  • 二次验证: 聚合SDK回调给游戏应用方会对参数做参数签名用于验证, 但还是推荐采用提供查询订单接口用于更加细致的订单查询功能

数据库订单表其实设计都大差不差, 但是如果是面向海外的时候情况就比较复杂, 因为海外对接支付方式和国内有很大差别

而且订单表支付表不太适合设计成分表, 因为面向支付订单这部分需要做大量统计, 所以还是推荐单表处理配合读写分离处理

但是预下单机制也是有很大弊端, 如果不做限制会疯狂生成大量待支付订单, 不过如果考虑到支付成功的转化率其实也是可以接受

毕竟能够成交的订单也就不在意这点数据库空间, 几百条数据对于云数据库来说可能就几块钱, 但是成功支付的收益大部分能够覆盖其成本

另外还需要区分发起订单的类型:

  • 官方渠道: 自身对接的应用/游戏(不走第三方渠道直接官方下载运行), 主体聚合SDK自己就是官方渠道

  • 第三方渠道: 比较典型的就是上面说的 QuickSDK/果盘SDK/腾讯游戏大厅 等, 这部分具体支付通道由第三方控制

结合上面提出的, 最终的数据库表设计如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# 基础应用表数据, 不推荐设计为分表, 整合单表才方便后续统计, 否则要核算整体付费用户还得去分表内部分开统计最后整合
CREATE TABLE IF NOT EXISTS nova_order_info
(
-- 基础信息
order_id BINARY(16) NOT NULL COMMENT '有序UUID标识, 必须采用 UUIDv7 规划生成',
app_id BIGINT NOT NULL COMMENT '对应的应用ID, 对应 nova_app_info.app_id',
uid BIGINT NOT NULL COMMENT '对应的用户ID, 对应 nova_user_info.uid',
status INT NOT NULL DEFAULT 0 COMMENT '订单状态, 0 = 预下单, 1 = 支付成功等待回调, 2 = 支付成功且回调完成, status > 100 为错误',

-- 本次上报的设备和系统等信息
device VARCHAR(16) NOT NULL DEFAULT '' COMMENT '设备平台信息, 比如 win32 等, 浏览器通过 navigator.platform 可以采集',
os VARCHAR(16) NOT NULL DEFAULT '' COMMENT '系统信息, 用于可以采集到 Windows/Linux/Mac/iOS, 浏览器通过 navigator.userAgent 可以采集',
imei VARCHAR(255) NOT NULL DEFAULT '' COMMENT '用户提交的IMEI',

-- CP订单信息
source_id INT NOT NULL DEFAULT 0 COMMENT '对应的第三方支付渠道标识, 比如 1=官方微信支付, 2=官方支付宝支付, 其他第三方渠道情况为0',
reference_id VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'CP的订单信息标识',
return_url VARCHAR(255) NOT NULL DEFAULT '' COMMENT '支付完成客户端界面跳转地址, 默认空可不跳等待应用/游戏内部自己等待确认',
notify_url VARCHAR(255) NOT NULL DEFAULT '' COMMENT '支付完成服务端异步通知地址, 优先采用客户端传递地址, 如果该地址没设置会使用后台配置的地址',
notify_time BIGINT NOT NULL DEFAULT 0 COMMENT '首次回调通知成功的时间戳, 毫秒级别的UTC时间戳',
extension VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'CP 传递的扩展信息',

-- 发起支付的时候应用/游戏的服务器信息
sid VARCHAR(255) NOT NULL DEFAULT '' COMMENT '服务器ID, 部分服务器可能采用字符串标识, 所有涉及到需要处理成字符串形式',
sname VARCHAR(255) NOT NULL DEFAULT '' COMMENT '服务器名称',

-- 角色信息
-- 这里可以看到这里 CP 对于游戏应用提交字段都采用 varchar(255) 很冗余的情况, 这是因为没办法对 CP 做规范限制
-- 直接采用 varchar(32/64) 可以压缩占用空间, 但是没办法保证 CP 提交的信息一定能够容纳, 所以采用 varchar(255) 来做兜底(请求强限制不允许超过var_char(255)长度)
-- 最终采用空间换取容错的思路, 宁愿浪费单表空间占用也要保持数据完整性
role_id VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色ID',
role_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色名称',
role_balance VARCHAR(255) NOT NULL DEFAULT '' COMMENT '当前角色身上拥有的游戏币数量',
role_level VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色等级',
role_power VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色战力值',
role_vip VARCHAR(255) NOT NULL DEFAULT '' COMMENT '角色VIP等级',

-- 道具信息
item_id VARCHAR(255) NOT NULL DEFAULT '' COMMENT '商品道具ID',
item_title VARCHAR(255) NOT NULL DEFAULT '' COMMENT '商品道具名称',
item_desc VARCHAR(255) NOT NULL DEFAULT '' COMMENT '商品整体描述',
item_price BIGINT NOT NULL COMMENT '充值金额: 单位需要匹配全球不同化, 折算出最小货币单位的金额',
item_total BIGINT NOT NULL DEFAULT 1 COMMENT '道具数量: 最后的金额等于 item_price * item_total',

-- 支付信息
region VARCHAR(3) NOT NULL COMMENT '发起的支付地区, 如 US|HK|CN|JP 等',
currency VARCHAR(4) NOT NULL COMMENT '发起的支付货币, 如 USD|CNY 等',
scale TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT '支付订单国际化最小单位(金额小数点后几位), 比如中国最小单位为分 = 1.00元(设为2), 而日本最小单位为元 = 1日元(设为0)',

-- 其他信息
create_time BIGINT UNSIGNED NOT NULL COMMENT '创建时间, 毫秒级别的UTC时间戳',
create_ip VARCHAR(64) NOT NULL COMMENT '订单创建IP地址',
update_time BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订单数据更新时间, 毫秒级别的UTC时间戳',
update_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '订单更新IP地址',

-- 业务索引
KEY idx_source_id (source_id),
KEY idx_app_id (app_id),
KEY idx_uid (uid),
KEY idx_reference_id (reference_id),
KEY idx_status (status),
KEY idx_create_time (create_time),
KEY idx_region_currency (region, currency),
-- 主键索引
PRIMARY KEY (order_id)
) COMMENT = '支付订单表, 采用 UUIDv7 来避免频繁页分裂和索引碎片的问题'
ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci;


# 回调记录表, 推荐回调的时候做数据库日志保存, 可以有效避免中间回调丢失没办法复现处理请求
CREATE TABLE IF NOT EXISTS nova_notify_info
(
id BIGINT AUTO_INCREMENT NOT NULL COMMENT '自增主键',
order_id BINARY(16) NOT NULL COMMENT '有序UUID标识, 对应 nova_order_info.order_id',
extension TEXT NOT NULL DEFAULT '' COMMENT '第三方回调过来的数据, 其实更加推荐采用比较新的 JSON 类型, 但是为了兼容性最后降级成文本保存',


-- 创建时候的信息
create_time BIGINT UNSIGNED NOT NULL COMMENT '创建时间, 毫秒级别的UTC时间戳',
create_ip VARCHAR(64) NOT NULL COMMENT '订单创建IP地址',

PRIMARY KEY (id)
) COMMENT = '支付回调表, 用于还原支付现场或者查看回调记录日志信息'
ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci;

这样差不多可以搞定支付业务的情况, 这里需要科普下国际最小单位的情况(这种最小单位算法比较简单且泛用)

  • scale=2: 货币最小单位为 分(分/美分)

  • scale=0: 货币最小单位为 元(日元/韩元)

  • scale=3: 极其冷门特殊的货币单位(科威特第纳尔), 单位为 科威特第纳尔

接下来提供几个具体的复杂聚合查询, 这里在创建表之后会插入几条实验数据用来做模拟测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 生成测试数据
INSERT INTO nova_order_info
(order_id, app_id, uid, status, device, os, imei,
source_id, reference_id, return_url, notify_url, notify_time, extension,
sid, sname, role_id, role_name, role_balance, role_level, role_power, role_vip,
item_id, item_title, item_desc, item_price, item_total,
region, currency, scale,
create_time, create_ip, update_time, update_ip)
VALUES
-- CNY 1000分 = 10元
(0x018F9A2B3C4D5E6F708192A3B4C5D6E7, 1001, 10001, 2, 'win32', 'Windows', 'IMEI001',
1, 'CP_ORD_001', '', 'https://xxx/notify', 1781234000000, '{"pay_type":"wx"}',
'S01', '一区', 'R001', '玩家A', '10000', '99', '88888', 'VIP10',
'GOOD001', '月卡', '月度会员', 1000, 1,
'CN', 'CNY', 2,
1781234000000, '192.168.1.10', 1781234100000, '192.168.1.10'),

-- USD 500分 *2 = 10美元
(0x018F9A2B3C4D5E6F708192A3B4C5D6E8, 1001, 10002, 2, 'Android', 'Android', 'IMEI002',
2, 'CP_ORD_002', '', 'https://xxx/notify', 1781234100000, '{"pay_type":"alipay"}',
'S02', '美服一区', 'R002', '玩家B', '20000', '88', '77777', 'VIP8',
'GOOD002', '礼包', '新手礼包', 500, 2,
'US', 'USD', 2,
1781234100000, '8.8.8.8', 1781234200000, '8.8.8.8'),

-- JPY 3000日元(无小数 scale=0)
(0x018F9A2B3C4D5E6F708192A3B4C5D6E9, 1002, 10003, 1, 'iOS', 'iOS', 'IMEI003',
0, 'CP_ORD_003', '', 'https://xxx/notify', 1781234200000, '{"pay_type":"other"}',
'S03', '日服一区', 'R003', '玩家C', '15000', '77', '66666', 'VIP5',
'GOOD003', '钻石', '充值钻石', 3000, 1,
'JP', 'JPY', 0,
1781234200000, '1.1.1.1', 0, ''),

-- EUR 异常订单 200分*3 = 6欧元
(0x018F9A2B3C4D5E6F708192A3B4C5D6EA, 1002, 10004, 101, 'Mac', 'MacOS', 'IMEI004',
1, 'CP_ORD_004', '', '', 0, '',
'S04', '欧服一区', 'R004', '玩家D', '5000', '66', '55555', 'VIP3',
'GOOD004', '周卡', '周会员', 200, 3,
'EU', 'EUR', 2,
1781234300000, '2.2.2.2', 0, ''),

-- HKD 800分*2 = 16港币
(0x018F9A2B3C4D5E6F708192A3B4C5D6EB, 1001, 10005, 2, 'win32', 'Windows', 'IMEI005',
2, 'CP_ORD_005', '', 'https://xxx/notify', 1781234400000, '{}',
'S05', '港服一区', 'R005', '玩家E', '8000', '55', '44444', 'VIP1',
'GOOD001', '月卡', '月度会员', 800, 2,
'HK', 'HKD', 2,
1781234400000, '3.3.3.3', 1781234500000, '3.3.3.3');


然后就开始提供一些日常比较常用的数据聚合查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
-- 这里后续会用到核心换算公式: ROUND(item_price * item_total / POW(10, scale), 2)
-- 这样就能换算出不同国家货币的最小单位金额

-- 统计不同地区和订单金额数据, real_amount 就是得出最终最小货币单位值
-- 比如 JPY 最小单位是 0, 那么 raw_total = 3000, 而最终得出的最小单位数额 real_amount = 3000.0 日元
-- 而如果是 CNY 最小单位这是 2, 那么 raw_total = 3000, 而最终得出的最小单位数额 real_amount = 10.0 元
-- 这种换算流程就是将不同最小单位的金额归并到元单位
SELECT order_id,
currency,
scale,
item_price,
item_total,
item_price * item_total AS raw_total,
ROUND(item_price * item_total / POW(10, scale), 2) AS real_amount
FROM nova_order_info;

-- 按币种分组统计, 核算 status IN(1,2) 的订单数据
-- 分析出不同结算货币当中的订单量、总流水、单笔极值
-- order_count = 支付订单数, total_raw = 支付总金额, total_amount = 最小单位换算金额, min_order = 单笔支付最小的金额, max_order = 单笔支付最高的金额
SELECT currency,
scale,
COUNT(*) AS order_count,
SUM(item_price * item_total) AS total_raw,
ROUND(SUM(item_price * item_total) / POW(10, scale), 2) AS total_amount,
ROUND(MIN(item_price * item_total) / POW(10, scale), 2) AS min_order,
ROUND(MAX(item_price * item_total) / POW(10, scale), 2) AS max_order
FROM nova_order_info
WHERE status IN (1, 2)
GROUP BY currency, scale
ORDER BY total_raw DESC;

-- 地区(region) + 币种(currency) 的二维聚合, 用于国际化运营聚合查询
SELECT region,
currency,
scale,
COUNT(*) AS order_cnt,
SUM(item_price * item_total) AS region_raw,
ROUND(SUM(item_price * item_total) / POW(10, scale), 2) AS region_amount
FROM nova_order_info
WHERE status IN (1, 2)
GROUP BY region, currency, scale
ORDER BY region_raw DESC;

-- 按照应用和货币类型分组统计
SELECT app_id,
currency,
scale,
COUNT(*) AS order_num,
SUM(item_price * item_total) AS app_raw,
ROUND(SUM(item_price * item_total) / POW(10, scale), 2) AS app_amount
FROM nova_order_info
GROUP BY app_id, currency, scale
ORDER BY app_id, app_raw DESC;

-- 订单状态全量对账(区分正常 / 预下单 / 异常)
SELECT CASE
WHEN status = 0 THEN '预下单'
WHEN status IN (1, 2) THEN '支付正常'
WHEN status > 100 THEN '订单异常'
ELSE '其他'
END AS status_name,
status,
currency,
scale,
COUNT(*) AS order_cnt,
SUM(item_price * item_total) AS raw_sum,
ROUND(SUM(item_price * item_total) / POW(10, scale), 2) AS amount_sum
FROM nova_order_info
GROUP BY status_name, status, currency, scale
ORDER BY status;

-- 按币种统计累计消费, 用户付费榜单
SELECT uid,
currency,
scale,
COUNT(*) AS pay_times,
SUM(item_price * item_total) AS user_raw,
ROUND(SUM(item_price * item_total) / POW(10, scale), 2) AS user_total
FROM nova_order_info
WHERE status IN (1, 2)
GROUP BY uid, currency, scale
ORDER BY user_raw DESC
LIMIT 20;

-- 时间维度日报(每日营收趋势)
SELECT FROM_UNIXTIME(create_time / 1000, '%Y-%m-%d') AS stat_date,
currency,
scale,
COUNT(*) AS daily_order,
SUM(item_price * item_total) AS daily_raw,
ROUND(SUM(item_price * item_total) / POW(10, scale), 2) AS daily_amount
FROM nova_order_info
WHERE status IN (1, 2)
GROUP BY stat_date, currency, scale
ORDER BY stat_date;



这里的关键核心就是最小货币算法: ROUND(SUM(item_price * item_total) / POW(10, scale), 2)

以此得出的就是我们日常使用的 单位, 一般日常使用不会以 作为衡量单位

对于后台系统来说, 这样查询出来的值可以直接显示给运营查看, 避免还要不同金额换算处理