海外发行系统构建

海外发行其实和国内发行差不多, 主要核心是打通 “内容 / 产品适配 - 合规准入 - 渠道分发 - 运营变现 - 本地化服务” 的全链路.

主要问题点就是海外和国内合规有所不同:

  • 数据合规: 遵循 GDPR(欧盟)、CCPA(加州)、PIPL(中国跨境数据)等,做好数据本地化存储、用户授权、数据跨境传输备案规定

  • 内容合规: 规避宗教、政治、文化禁忌, 如中东市场避免暴露性内容, 欧美市场注意版权和商标, 主要还是设计暴露程度等

  • 行业合规: 游戏需获取各国评级(ESRB、PEGI、CERO), 影视需通过当地审查机构认证

  • 支付合规: 海外大部分都是采用信用卡在线支付, 需要利用 3DS 来做支付安全检测

不过这些不是开发者应该关注, 作为开发者我们需要处理的就是对接和构建 发行方研发方 的产品接入.

需要注意游戏也当作应用的一种, 所以会采用应用来代指发行

首先作为 发行方 需要提供以下后台系统来方便业务接入:

  • 核心后台(发行内部): 用于提供最高权限后台, 可以新增修改平台/渠道/应用, 也可以查看具体支付和上报信息

  • 渠道后台(发行-推广): 提供给 CPS 包的推广人员后台, 可以创建子账号生成推广子包, 也可以看到自己和成员推广注册/登陆用户和支付数据

  • 研发后台(发行-研发): 提供给 游戏研发 的后台, 可以查看自身所属应用的注册/登陆/支付/上报数据

这里需要区分 渠道 的意义, 这里的渠道广义上代表以下意义:

  • 平台渠道(platfrom): 类似于第三方平台渠道, 比如 抖音/谷歌/Facebook/小米应用商店/应用宝 之类开放平台, 对于平台方我们就是渠道

  • 投放渠道(channel): 由自己发行的推广人员关联渠道码, 比如 线上推广/线下推广/主播/游戏工会业务员 用于统计, 我们内部的区分渠道

有时候双方交流关于 渠道 的时候可能会出现对不上的问题, 所以一定要确定对应的渠道概念归属避免无意义的交流.

而且应用端推荐按照以下分类来区分:

  • H5: 对应 PWA 之类的 Web 应用, 大部分用于网页游戏/网页应用等

  • Android: 需要注意这部分的国内兴起的 鸿蒙 和常规安卓平台不一样, 需要另外打包

  • iOS: 这部分需要专门的苹果设备开发, 这部分上包最难处理, 要专门人员来维护

  • 微端/mobile: 专门采用 WebKit 将 H5 端打包成移动端应用层来使用, 本质就是打包成移动端浏览器应用

  • PC: 这部分需要用专门平台打包, 而且分支众多(windows/linux/mac)

  • Web: 这其实也和 H5 + PC, 只是通过采用桌面端包装的浏览器应用, 有的需要 H5 和 Web 区分

  • 鸿蒙: 和 Android 差不多的端, 但是需要他们自有打包并且上包也麻烦, 基本很少用到

这些端最好单独页面分开创建, 如果聚合在同一个页面的时候逻辑会很复杂, 不利于分开做权限分离.

SQL 设计

一般发行功能需要以下数据库来做起步:

  • app: 应用表, 用于后台来初始化创建应用信息, 也就是最底层核心的表

  • platform: 平台表, 用于记录上架的第三方平台标识, 比如 谷歌/Facebook/应用宝 之类

应用表基本要合并成一张单表来提供给后台合并查询, 分表的话不好合并成分页和统计:

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
# 应用信息表
# 我个人一般命名表名都喜欢 {项目标识符}_{主要表类型}_{info|其他扩展类型}
# 比如订单表会起为 pino_order_info, 然后后续相关表如第三方回调信息记录就会记录成 pino_order_callback
# 一般是前缀代表项目归属, 后缀代表业务表类型, info 就是默认列表区分
create table if not exists pino_app_info
(
# 应用基础信息
id bigint unsigned not null auto_increment comment '数据主键, 用于标识递增, 主键即是appid',
name varchar(64) not null comment '应用名称',
ident varchar(64) not null comment 'APP 唯一字符串标识(首字母必须是字母), 最好不要暴露 id 给外部, 会被人嗅探id递增值递归获取所有应用, 另外还有保证全局唯一的功能',
app_key varchar(64) not null default '' comment '用于客户端|前端做授权哈希加密的时候密钥, 也就是 app key',
app_secure varchar(64) not null default '' comment '用于服务端|游戏做授权|支付哈希加密的时候密钥, 也就是 app secure',
status tinyint unsigned not null default '0' comment '应用上线状态, 默认0代表刚创建, 参照具体的流程状态值处理',
uptime bigint unsigned not null comment '游戏上线时间, 毫秒级别的UTC时间戳, 一旦上线完成之后这个值就不会变, 注意: 上线之后很多操作都是需要做限制修改处理',

# 其他授权/支付所需信息
notify_url varchar(255) not null default '' comment '支付回调通知地址',
redirect_url varchar(255) not null default '' comment '支付完成跳转地址',

# 区分平台的配置
platform tinyint unsigned not null default '0' comment '用户创建时候的平台标识ID(0=未知,参照 platform_info 信息表), 一旦确定就无法更改, 应用类型也参照自定义的第三方平台选择',
language varchar(8) not null comment '默认游戏语言, 渲染登录窗体语言可选 en|zh-CN|zh-TW|ja 之类, 也就是默认功能响应的语言',
display tinyint unsigned not null default '0' comment '应用的显示类型, 0 - 默认 | 1 - portrait(竖屏)| 2- landscape(横屏), 实际上默认0即可, 备用字段',

# 应用运营展示信息
keyboards varchar(255) not null default '' comment 'XXX,YYY,ZZZ形式',
icon varchar(255) not null default '' comment '游戏图标链接, 图标标准尺寸推荐为 192*192',
title varchar(255) not null default '' comment '应用标题, 常用于展示如 "XXY第一款XXX传奇游戏 - ZZZ" 之类长标题',
description varchar(255) not null default '' comment '标题内容, 可能会有大量展示的应用信息',
company varchar(255) not null default '' comment '用于保存所属公司或者个人信息',
portrait_cover varchar(255) not null default '' comment '竖版背景图片地址, 推荐尺寸为 640*1380',
landscape_cover varchar(255) not null default '' comment '横版背景图片地址, 推荐尺寸为 1380*640',


# 创建|更新信息
create_time bigint unsigned not null comment '创建时间, 毫秒级别的UTC时间戳',
create_ip varchar(64) not null comment '应用创建IP地址',
create_uid bigint unsigned not null comment '应用创建后台管理员ID',
update_time bigint unsigned not null default '0' comment '数据更新时间, 毫秒级别的UTC时间戳',
update_ip varchar(64) not null default '' comment '更新IP地址',
update_uid bigint unsigned not null default '0' comment '应用更新后台管理员ID',


# 随着启动初始化传递的 KEY-VALUE 的 JSON数据, { "xxx": "yyy", "zzz":123 }
# 必须采用一元的对象组保存, 在初始化的时候会原样返回给客户端, 让客户端可以接收到对应属性值做调用
properties json not null default JSON_OBJECT() comment '可能需要设置的额外游戏配置项(JSON对象组形式)',


#=================================
## 对应不同平台需要记录的字段
#=================================

# H5的字段相对简单, 因为基本只需要处理成游戏启动路径即可(本质上就是网页游戏)
game_url varchar(255) not null default '' comment '游戏路径, 只有H5之类才需要用到',

# Android 的字段就比较多, 需要母包地址/签名证书/证书密钥, 这也是最复杂的配置之一
# 研发方提交母包之后需要服务端做 apktool 反编译大量数据
# 这部分可以后台自己帮助生成, 也可以用户提交上来的相关数据, 用于后续分包来对包重新签名
# 微端可以看做是是发行方自行维护 Android, 所以下面的信息也是可以自己处理, 唯一不同的是分包需要自行做包名唯一化
# 微端出分包的话, 母包是 'com.pino.game(这个包名可以随意定制)', 那么这里的微端包名必须为 'com.pino.game.{平台标识}.{应用字符串标识}'
# 比如想出 google 的微端包, 而数据库应用的 ident 字段为 'test123', 那么母包 'com.pino.hello', 提交上来之后会做重新反编译包名修改成 'com.pino.hello.google.test123'
# 这样会自动将包设置唯一避免出现所属的包已经被人使用的问题, 需要在此提醒必须保证 ident 字段的首位必须为字母来防止可能出现包名错误
package_url varchar(255) not null default '' comment '母包上传路径, 只有 Android/微端 之类才需要用到',
package_key varchar(255) not null default '' comment '证书保存地址, 只有 Android/微端 之类才需要用到',
package_pwd varchar(255) not null default '' comment '证书密钥信息, 只有 Android/微端 之类才需要用到',

# iOS 略, iOS 是另一个纬度的打包配置功能, 因为这边没有对应设备所以这里没办法细致说明

unique ident_unique (ident),
index status_idx (status),
primary key (id)
) comment '应用信息表'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci
;


# 平台信息表
# 这里简略记录下就行, 这里的定义一般是开发提供处理的定义, 其他情况不会让业务管理员去手动创建
# 甚至直接写死本地文件配置更好, 可以避免管理人员去随意修改.
create table if not exists pino_app_platform
(
id bigint unsigned not null auto_increment comment '数据主键, 用于标识递增',
name varchar(64) not null comment '平台名称, 比如 Google/TikTop/Facebook',
ident varchar(64) not null comment '平台标识(首字母必须是字母), 要求字母全小写且不能带特殊符号',
unique ident_unique (ident),
primary key (id)
) comment '渠道信息表'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci
;

这张大表就是核心记录应用的内容, 涉及 iOS 因为接触不多所以这里面没有追加上, 不过上面基本上已经概括应用上架的大部分情况.

安卓和 iOS 出包流程很麻烦, 需要依赖动态来反编译解包之后修改成对应包名和配置, 这部分可能需要客户端来用 Python 脚本处理.

Python 好处是配合 Android Sdk 跨平台运行抹平系统脚本兼容问题, 否则就要自己去针对平台写 BashShell|PowerShell 脚本

对于应用来说初始化的时候只需要简单的信息即可, 而 cover(封面)icon(图标) 这类其实应该由 运营/研发相关人员 配置:

create

后面的细节配置暂时没有追加, 不过只需要专门针对所需配置添加上即可, 问题不是那么大.

授权与验证

可以看到 SQL 之中有关键的 app_keyapp_secure, 前者对应客户端参数签名, 后者则是对应服务端的参数验证.

作为 发行方, 对于研发的 客户端 需要提供以下接口(需要用到 app_key):

  • 初始化(init): 通过应用标识拉取应用基础信息用于应用启动的时候初始化

  • 授权(login): 用户请求登陆拉取对应账号信息和授权 token

  • 注销(logout): 将用户的授权取消不再允许操作

  • 上报(report): 按照行为对用户数据进行上报

  • 支付(payment): 用于发起内部应用第三方支付

作为 发行方, 对于研发的 服务端 需要提供以下接口(需要用到 app_key + app_secure, 只有支付相关需要用到 app_secure):

  • 授权验证(authority): 客户端登陆之后获取到的 token 上报到研发服务端之后需要二次校验 token 是否为发行方颁布的有效期

  • 订单查询(order): 支付完成发行方会提供支付回调到服务端, 服务端需要通过接口二次验证订单是否存在并且是否已经关闭订单

  • 实名认证(ident): 中国国内独有的认证身份信息, 国内涉及个人安全不需要处理, 一般需要第三方接口调用认证转发处理即可

这里就是对于双方默认约定需要交互的接口, 而其中只需要做签名并传递, 以服务端授权验证 PHP 接口为例:

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
<?php
$appid = 10001;// 应用AppID - 应用分配的 id
$appKey = "345f83cea7fe4de056a6045a26645b2b"; // 后台配置的 AppKey
$uid = 100000001; // SDK获取的在发行账号唯一标识ID - 客户端获取用户 uid
$token = "82f69186f2c143f9ca4a1df8645baeb3"; // SDK获取的会话凭证 - 客户端获取的 token

// 初始化数据结构如下
$params = [
'appid' => $appid,
'uid' => $uid,
'token' => $token,
'time' => round(microtime(true) * 1000), // 获取发起的毫秒级UTC时间戳
];

// 必须要对数组的 key 做正序排列
ksort($params);
// 以下是重排之后的顺序结构
// {"appid":10001,"time":1753173343944,"token":"82f69186f2c143f9ca4a1df8645baeb3","uid":100000001}


// 最后进行签名生成哈希
$data = [];
foreach ($params as $k=>$v){
$data []= "$k=$v";
}
$paramsString = implode('&',$data);
// 以下是最后生成等待哈希签名的数据
// appid=10001&time=1753173343944&token=82f69186f2c143f9ca4a1df8645baeb3&uid=100000001

// 最后附加上 App Key 合并md5处理
$sign = md5("$paramsString$appKey");
// 以下是最后生成的签名哈希
// b15491c5db24f82db545155f68e5d202

// 最终提交的数据结构, 发起接口请求
$form = $params;
$form['sign'] = $sign;

签名的 sign 目前主流有以下方法提交:

  • 随着参数提交上来: 这部分是最简单处理, 范用性最广

  • 追加 Header 的 Authorization 提交, 比如 Authorization: Bearer {签名哈希值} 这样提交认证, 好处就是不会干扰到提交的请求结构体

这两者按需求选择即可, 甚至也可以两种都支持(如果提交请求没有签名参数就查询 header), 这是最基本的交互手法.

注意: 所有对外接口都要采用这种参数签名方式验证一次再进行内部逻辑处理, 能够有效避免非法接口嗅探和网络请求攻击

上面的 PHP 例子就是最开始的初版实现, appid 最开始采用 int 形式, 后来感觉风险太高所以采用唯一字符串标识:

  • 可递增直接容易被脚本直接导出发行公司所有的游戏, 只需要 app_id={xxx} 这样不断递增获取

  • 本质上最后传递的还是字符串, int 和 string 性能差距其实不是那么大

  • 随机字符串标识追加上唯一索引不比主键索引性能差

基于这种情况才会推荐生成应用的同时构建出唯一字符串标识, 而对内还是保持用 appid 方式做记录处理.

实际上发行和研发运营对接的时候, 还是用 appid 最直观一点; 不过当量级上来两者差距不大,
比如最开始说那 ‘id=1001’ 可以直接简单念出来就知道, 后面 ‘id=34153’ 估计太拗口也懒得区分直接视为字符串.

所以一般提供给研发方都会移交以下数据来对接处理:

  • app_id: 应用的ID

  • app_ident: 应用唯一标识字符串

  • app_key: 客户端参与的签名凭据

  • app_secure: 服务端参与的签名验证

还有关于应用研发方的后台地址/账号/密码这些, 这部分是属于上线运营相关, 这里不做太多说明

后续交互基本上就是很简单请求流程, 因为基本上就是服务端的打包和分包功能部署设置.

也就是 核心/渠道后台 需要针对研发方的母包来动态执行分包, 也就是需要按照以下流程:

  1. 发行方需要核心后台创建基础的应用信息, 并且生成研发后台账号/密码和应用对接信息 - 目前应用状态为 等待接入

  2. 发行方提供开发相关信息和研发后台帐号密码给研发方, 让研发方可以登陆发行提供应用管理后台(也可以算作开发者中心)

  3. 研发方需要在后台补充基础应用添加参数, 提交对接好的母包 - 应用状态为 接入中(只允许后台生成的特定账号授权,上报日志等行为不保存数据库)

  4. 对接完成并且提交母包之后将应用状态切换 - 目前应用状态为 等待上线(这时候准备核心后台上线游戏)

  5. 发行方和研发方运营确认沟通审核之后, 将应用上线 - 应用最终状态切换为 上线完成(这时候开放外部注册|登陆, 并且接受数据上报行为日志)

  6. 发行方安排创建推广人员(主播/游戏工会/地推人员)的渠道后台账号密码, 这部分可能限制账号是否可以创建子账号

  7. 这部分推广人员的后台需要获取到自己安装下载的 子包, 这里就需要点击之后手动触发云端打包机制, 让服务器对母包反编译附加自身渠道标识

  8. 云端打包完成之后就可以在后台直接下载对应属于自己渠道的应用包, 推广人员以自己安装包来计算业务

这里就是发行 SDK 的主要对接流程, 内部还有些细节需要微调, 但是具体大方向流程就是这样处理, 细分出来发行内部如下分配:

  • 后端: 负责设计和对接第三方接口, 并且还需要构建后台系统提供使用, 编写后台脚本统计数据

  • 客户端: 编写对应 AAR 安卓包等, 需要利用 Python 处理动态出包逻辑, 之后方便在云端出 CPS 包

  • 前端: 负责界面和美术素材等相关处理, 不过一般都是有后端直接UI框架或者AI处理

  • 运维: 大型公司才有专门服务器管理和部署职业, 一般公司都是后端兼顾这部分工作

现实情况就是大部分需求都是后端来处理, 所以前期攻关大头压力比较大

另外强烈推荐支付系统额外开成独立后端服务, 让 SDK 支付相关功能内部转发到远程的 Payment 功能, 可以有效对第三方支付业务解耦.

还有集成支付方面的说明, 因为大部分平台渠道是各自自留地, 类似 Google/iOS 的应用商店上架游戏应用内购必须使用自身商店支付,
私自集成第三方支付通道如果被审核抓到会直接下架应用, 甚至是对开发者账号进行封号处理.

切记后台必须可以支持动态配置不同平台和渠道切换不同的支付渠道, 避免官方商店上架包的时候弹出第三方支付渠道直接被下架

订单支付

海外的支付系统也是国内比较容易忽视的问题, 和国内相比作为发行方提供的对象是全球性的:

  • 不同国家地区: CN/HK/US/JP…

  • 不同货币单位: CNY/HKD/USD/JPY…

  • 不同国家时区: Asia/Shanghai, America/Los_Angeles, Asia/Tokyo…

地区代码: ISO-4217

你需要知道你的运营服务不会提供给单一地区, 可能日本也有推广人员负责推广并且最后以日元结算, 而美国也有推广人员采用美元结算诸如此类.

而在国内基本统一都是认证为 UTC+8(东八区) 时间的秒级时间戳, 很多时候数据库单纯保存 int 值压根不清楚基于哪个时区来源.

所以这种情况下, 就需要采用默认的 UTC毫秒时间, 也就是 绝对时间 1970-01-01 00:00:00 UTC 到现在时刻的毫秒数.

UTC 时间无关任何时区, 无论你在东八区(中国)、西五区(美国纽约)还是零时区(伦敦), 调用该方法得到的数值完全相同.

然后按照这个时间就能转化成不同地区的本地时间, 消除了不同地区的时间差异.

而支付货币就比较麻烦, 因为有 最小货币单位 概念.

最小货币单位: 指代货币计量的 “最小粒度”, 如中国|美元有(美)元/(美)分, 最小单位为(美)分; 而韩国|日元则是以(日|韩)元最小单位

这里摘取日常会用到最小货币单元表格:

货币代码 最小单位(小数点后位数) 金额中的值示例
AUD 分(2) 1.00 AUD 需设置为 “value:100”
BDT 分(2) 1.00 BDT 需设置为 “value:100”
BRL 分(2) 1.00 BRL 需设置为 “value:100”
CAD 分(2) 1.00 CAD 需设置为 “value:100”
CLP 分(0) 1 CLP 需设置为 “value:1”
CNY 分(2) 1.00 CNY 需设置为 “value:100”
EUR 分(2) 1.00 EUR 需设置为 “value:100”
GBP 分(2) 1.00 GBP 需设置为 “value:100”
HKD 分(2) 1.00 HKD 需设置为 “value:100”
IDR 美分(2) 1.00 IDR 需设置为 “value:100”
JPY 元(0) 1 JPY 需设置为 “value:1”
KRW 元(0) 1 KRW 需设置为 “value:1”
MXN 分(2) 1.00 MXN 需设置为 “value:100”
MYR 分(2) 1.00 MYR 需设置为 “value:100”
NZD 分(2) 1.00 NZD 需设置为 “value:100”
PEN 分(2) 1.00 PEN 需设置为 “value:100”
PHP 美分(2) 1.00 PHP 需设置为 “value:100”
PKR 分(2) 1.00 PKR 需设置为 “value:100”
PLN 分(2) 1.00 PLN 需设置为 “value:100”
SGD 分(2) 1.00 SGD 需设置为 “value:100”
THB 分(2) 1.00 THB 需设置为 “value:100”
TWD 分(2) 1.00 TWD 需设置为 “value:100”
USD 美分(2) 1.00 USD 需设置为 “value:100”
VND 分(0) 1 VND 需设置为 “value:1”

而日常数据库就是需要采用以下字段来记录订单的详细信息:

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
# 基础的支付信息, 支持跨境支付
create table if not exists order_info
(
order_id varchar(64) not null comment '订单标识',

# 订单支付信息
order_region varchar(3) not null comment '订单发起支付的地区, 如 CN|US|HK 等',
order_currency varchar(3) not null comment '订单发起支付的货币, 如 CNY|USD|HKD 等',
order_unit tinyint unsigned not null default '2' comment '订单发起的最小单位, 即小数点后位数',
order_amount bigint unsigned not null comment '订单发起金额, 需要采用最小货币单位',

# 订单结算信息, 默认结算信息和支付信息一致
# 但是有的时候会出现某些第三方支付完成之后的结算单位和地区不一致, 这种情况下最好将 支付|回调 之后的订单状态分开保存
settlement_region varchar(3) not null default '' comment '订单结算支付的地区, 如 CN|US 等',
settlement_currency varchar(3) not null default '' comment '订单结算支付的货币, 如 CNY|USD 等',
settlement_unit tinyint unsigned not null default '2' comment '订单结算货币的最小单位, 即小数点后位数',
settlement_amount bigint unsigned not null comment '订单结算金额, 需要采用最小货币单位',

# 其他略
primary key (order_id)
) comment '支付订单信息表'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci;

# 假设目前用户支付 100 日元, 获取用日元最小货币金额
# 计算公式为 最小货币数值 = 结算金额 * (10 ^ 最小单位)
SELECT (settlement_amount * POW(10, settlement_unit)) as amount
FROM order_info
WHERE settlement_currency = 'JPY';


# 如果先直接转化为元单位, 比如人民币的 分 -> 元 方式就可以按照下面处理
SELECT (settlement_amount / POW(10, settlement_unit)) as amount
FROM order_info
WHERE settlement_currency = 'CNY';


# 如果要对其不同地区支付的货币分组, 比如统计 JPY|CNY|USD 每个货币支付金额分组排列
SELECT settlement_currency AS currency_code,
SUM(settlement_amount / POW(10, settlement_unit)) AS total_amount,
COUNT(order_id) AS order_count
FROM order_info
WHERE settlement_currency IN ('JPY', 'CNY', 'USD')
GROUP BY settlement_currency # 按货币分组
ORDER BY total_amount DESC, # 总金额从高到低
currency_code ASC; # 货币代码从低到高

这套支付系统的定义基本上足够应对海外大部分支付情况, 只需要在这部分数据表结构上额外扩充即可.