dependencies {
    implementation(
        "com.remax:risk-control:0.1.1"
    )
}

Android SDK 0.1.1

接入文档

SDK 提供登录、设备风险采集和事件上报,负责会话缓存与上报重试。广告、提现和页面行为由宿主处理。

添加依赖

在 App 模块添加依赖,并配置下方的 GitHub Packages 仓库。

app/build.gradle.kts
dependencies {
    implementation("com.remax:risk-control:0.1.1")
}
Android 6.0 / API 23+JVM target 17发布构建 compileSdk 36 · Kotlin 2.2.21

仓库凭据

在用户目录的 ~/.gradle/gradle.properties 配置拥有该仓库 / 包读取权限、包含 read:packages 的 GitHub 令牌;CI 可用同名用途的环境变量。以下均为占位符。

~/.gradle/gradle.properties
gpr.user=YOUR_GITHUB_USERNAME
gpr.key=YOUR_GITHUB_PACKAGES_TOKEN

Maven 仓库

合并到宿主现有的 dependencyResolutionManagement。仓库凭据只参与 Gradle 下载;SDK 的 appSecret 是另一项业务配置。

settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        exclusiveContent {
            forRepository {
                maven {
                    url = uri("https://maven.pkg.github.com/toukaremax/risk-control")
                    credentials {
                        username = providers.gradleProperty("gpr.user")
                            .orElse(providers.environmentVariable("GITHUB_ACTOR")).orNull
                        password = providers.gradleProperty("gpr.key")
                            .orElse(providers.environmentVariable("GITHUB_TOKEN")).orNull
                    }
                }
            }
            filter { includeModule("com.remax", "risk-control") }
        }
    }
}
传递依赖、权限与混淆
依赖 声明版本 用途
kotlinx-coroutines-core 1.10.2 挂起函数、StateFlow 和队列
okhttp 4.12.0 HTTP 请求
play:integrity 1.6.0 可选 Play Integrity API
play-services-tasks 18.2.0 Play Integrity 的 Task 返回类型

以上依赖通过 Maven 元数据自动传递,最终版本由宿主 Gradle 冲突解析确定。AAR 合并 INTERNET、ACCESS_NETWORK_STATE 普通权限,无运行时授权弹窗。登录和事件上报无需接入内容签名插件或 Play Integrity。

SDK 发布 AAR 不预混淆,随宿主 Release 的 R8 一起处理;目前无需额外添加 SDK keep 规则。HTTP 明文地址是否允许由宿主网络安全配置决定。

初始化

每个服务 / 应用配置复用一个 RiskClient 和 ReportQueue。业务只提供身份、选择策略、在真实事件发生时调用。

下面工厂的入参均由宿主提供。示例显式沿用短剧的 UNKNOWN → 未发现风险策略;SDK 检测本身保留 UNKNOWN,其他业务应按自己的约定处理。

Kotlin · 初始化与参数覆盖
import android.content.Context
import com.remax.riskcontrol.sdk.Status
import com.remax.riskcontrol.sdk.client.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob

class AppRisk(
    context: Context,
    baseUrl: String,
    bundleId: String,
    appSecret: String,
    debugEnabled: Boolean,
    parameters: suspend (String) -> LoginParameters,
    onSession: (RiskSession) -> Unit = {},
) {
    private val appContext = context.applicationContext
    private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    val client = RiskClient(
        context = appContext,
        config = RiskClientConfig(
            baseUrl = baseUrl,
            bundleId = bundleId,
            appSecret = appSecret,
            debug = RiskDebugConfig(enabled = debugEnabled, tag = "CoreModule"),
            persistSession = true,
        ),
        loginParameters = parameters,
        deviceRisk = { collectDeviceRisk(appContext).withShortDramaUnknownPolicy() },
        onSession = onSession,
    )
    // 默认持久化;创建后自动恢复旧事件。
    val reports = ReportQueue(client, applicationScope)
}

// 这是业务策略,不是 SDK 默认行为。
private fun DeviceRisk.withShortDramaUnknownPolicy(): DeviceRisk {
    fun Status.resolved() = if (this == Status.UNKNOWN) Status.NOT_DETECTED else this
    return copy(
        root = root.resolved(), adb = adb.resolved(), emulator = emulator.resolved(),
        hook = hook.resolved(), accessibility = accessibility.resolved(), vpn = vpn.resolved(),
    )
}
Kotlin · 宿主提供登录参数
// parameters 参数示例。countryByScope / stableDeviceId / attributionNetwork
// 来自宿主现有的身份与归因数据;不要每次生成新的 DeviceId。
val loginParameters: suspend (String) -> LoginParameters = { identityScope ->
    LoginParameters(
        deviceId = stableDeviceId,
        country = countryByScope.getValue(identityScope),
        network = attributionNetwork, // 可选:归因渠道
        userAgent = existingUserAgent, // 可选:覆盖 Android 默认值
        // deviceType / deviceVersion / isp / version 不传则使用 Android 默认值
        // gaid / adid 不传则省略
    )
}
identityScope 是会话和事件的业务身份键。应稳定地对应用户与地区,例如宿主维护的账户 / 设备身份加地区。切换身份使用新 scope,不要把旧 scope 复用于其他用户。队列恢复旧事件时,parameters 也必须能解析该事件的旧 scope。
Kotlin · 登录与上报调用
// 在宿主协程中获取登录态:内存 → 加密磁盘缓存 → /user/login。
val session = appRisk.client.ensureSession(identityScope)
// session.userId / session.token 提供给业务接口;使用前检查权限和凭据。

// 在真实事件发生时调用,无需先手动登录,也无需自己实现重试。
appRisk.reports.report(identityScope, ReportEventType.CONTENT_START)
appRisk.reports.report(identityScope, ReportEventType.OPEN_PAYOUT_PAGE)

// 开发工具可在协程内等待本次事件结果;普通页面无需等待。
val receipt = appRisk.reports.report(identityScope, ReportEventType.CONTENT_COMPLETE)
receipt.await()

创建 RiskClient 本身不请求登录。ReportQueue 创建时会恢复待发事件;有积压事件时可能随发送触发登录。队列 scope 使用 Application 生命周期,不使用退出页面即取消的生命周期。

配置参考

可选参数省略时使用默认实现;只覆盖业务已经维护的字段。

RiskClientConfig

参数 默认值 / 要求 作用
baseUrl: String 必填 后端根地址,拼接 /user/login 和 /user/report;支持根地址包含路径。
bundleId: String 必填 服务端应用标识并参与签名,可与开发 APK 的包名不同。
appSecret: String 必填 login 签名密钥,不是 GitHub Packages 令牌。
debug: RiskDebugConfig enabled=false
tag="RiskControl"
统一控制 login、report、队列日志,可覆盖 tag。
persistSession: Boolean true 加密保存登录态;false 时只使用内存,不影响事件持久化。

RiskClient 构造参数

参数 默认 / 类型 调用时机与用途
config 必填 · RiskClientConfig 服务配置。
context 必填 · Context 内部仅保留 applicationContext,用于设备默认值、采集、持久化。
loginParameters 必填 · suspend (String) → LoginParameters 向业务获取身份和登录字段。IO 上执行;首次校验磁盘缓存及每次真实登录调用,内存命中不调用。
deviceRisk 可选 · suspend () → DeviceRisk? 默认 collectDeviceRisk(context),真实登录时在 IO 采集。显式传 null 或返回 null 省略 RiskStat。
client 可选 · OkHttpClient 默认 connectTimeout=15 秒、readTimeout=30 秒。report 使用派生 client,关闭自动连接重试和重定向;login 保留传入配置。
nowEpochSeconds 可选 · () → Long 默认系统 Unix 秒。请求签名、Retry-After 计算、reportOnce 默认时间使用它。队列事件时间独立在入队时采集。
nonceSource 可选 · () → String 默认 UUID;每次带会话的请求产生新 Nonce。login 不使用 Nonce。
diagnostics 可选 · RiskDiagnostics 默认 Android Log.d;debug 开启时收到 tag 和文本,可接宿主日志系统。
onSession 可选 · (RiskSession) → Unit 默认空函数。恢复磁盘或新登录结果写入内存 / 状态后通知业务,包括等级 3;内存命中、登录失败不通知。

LoginParameters

参数 → 协议字段 默认行为 覆盖规则
deviceId → DeviceId 必填非空 沿用宿主稳定设备 / 用户身份。
country → Country 必填非空 服务端约定的地区代码。
deviceType → DeviceType 厂商 + 型号 null 使用默认值;非 null 使用传入值。
deviceVersion → DeviceVersion Android OS 版本 / API / Build 同上。
isp → Isp 系统运营商名(尽力获取) null 尝试默认值;空串省略字段。
userAgent → UserAgent WebSettings 默认 UA / http.agent null 尝试默认值;空串省略字段。
version → Version 宿主 versionName null 使用默认值;非 null 使用传入值。
gaid → Gaid
adid → Adid
network → Network
不自动采集归因信息 由业务提供,null / 空串省略。

Network 是业务归因渠道,RiskStat.NetworkType 是当前设备网络类型,两者含义不同。

登录态与风控

ensureSession 表示确保有可复用结果;有缓存时不会重新请求登录。

先查找内存会话;未命中时尝试恢复磁盘缓存,仍未命中才请求登录。恢复缓存或取得新登录结果后,触发 onSession。

公开函数 返回值 行为
state(scope: String) StateFlow<SessionState> 观察 LoggedOut / LoggingIn / Authenticated(session) / Restricted(riskTolerance) / Failed(reason)。
currentSession(scope: String) RiskSession? 仅读内存;不登录、不恢复磁盘。
suspend ensureSession(scope: String) RiskSession 同 scope 并发合并;优先内存,再恢复磁盘,最后登录。
suspend invalidateSession(scope: String, expected: RiskSession) Unit 仅清除与 expected 相同实例的当前会话,同时清磁盘;旧请求不会误删较新的会话。
suspend refreshSession(scope: String) RiskSession 主动清除缓存,再获取新登录结果。

RiskSession 返回值

字段 含义
scope: String 对应的业务身份键。
userId: Long / token: String 业务接口所用登录凭据。等级 3 可能缺少凭据,不能拿空凭据调用业务接口。
riskTolerance: Int 原样保留服务端等级,见下表。
canLogin: Boolean riskTolerance 在 0..2 时为 true。
canWithdraw: Boolean riskTolerance 为 0 时为 true。
isRestored: Boolean true 表示从磁盘恢复,是旧决策,不是刚取得的服务端结果。
等级 SDK 语义 短剧目前采用的业务策略
0 正常 允许广告和提现,清除旧限制。
1 限制提现 提现入口提示,不限制广告。
2 限制广告和提现 调用广告屏蔽 API,提现入口提示。
3 本次不可登录;canLogin=false 短剧同样屏蔽广告和提现,SDK 仍保留等级 3。

SDK 不调用广告库、不弹 Toast、不关闭应用,也不代替业务判断提现。是否上报以是否有完整凭据为准;等级 1 / 2 仍可上报。等级 3 无 Token 时失败结束,不循环登录。

业务接口如何无感刷新

SDK 自动处理自己的 report 会话失效。提现等宿主接口继续由现有拦截逻辑识别失效,并调用以下公共函数:

Kotlin · 业务请求已确认会话失效后
// failedSession 必须是这次失败请求实际使用的会话对象。
appRisk.client.invalidateSession(identityScope, failedSession)
val refreshed = appRisk.client.ensureSession(identityScope)
// 由业务决定是否使用 refreshed 重发该业务请求,并限制恢复次数。
// 不要将所有业务错误都当成 Token 失效。

SDK 的会话失效兼容条件为 Result=201 / 406 或 HTTP 401。单独 HTTP 406 不算;这是当前客户端兼容规则。

缓存隔离与持久化细节

默认以 Android Keystore AES-GCM 加密 Token、UserId、RiskTolerance 等会话数据,存入私有 SharedPreferences。按宿主包名、规范化 baseUrl(含路径)、bundleId、appSecret 和 scope 隔离,恢复时校验 DeviceId / Country。加密不可用时只用内存,不降级明文。

SDK 不猜测 Token 有效期;久未使用的缓存可先复用,服务端返回失效后再刷新。等级 3 不保存成可恢复会话,但进程内保留决策,防止循环重登。新登录时才能拿到新风控结果。

业务广告 / 提现策略的持久化由宿主维护。短剧会单独保存限制;磁盘恢复的旧会话不会覆盖更新的业务限制。新登录结果为 0 时清除限制,失败 / 离线时不自动放行。

事件上报

普通业务全部使用 ReportQueue。SDK 负责登录、签名、持久化、串行发送和失败重试。

Kotlin · 公开上报入口
// scope 是应用级 CoroutineScope,store 通常省略。
val reports = ReportQueue(client = riskClient, scope = applicationScope)

val receipt: ReportReceipt = reports.report(
    identityScope = identityScope,
    type = ReportEventType.AD_COMPLETE,
    value = revenue, // Double;默认 0.0,必须非负且有限
)
// receipt.id 是本地事件 ID;不会作为幂等键发送给后端。
// 在协程中 receipt.await() 等待确认或失败;取消等待不取消事件。
枚举 EventType 业务调用位置 / Value
NOTHING 0 空事件,按服务端用途使用。
CONTENT_COMPLETE 1 内容完成的既有业务回调,通常 Value=0。
AD_COMPLETE 2 短剧沿用广告收益回调,Value=收益,单位沿用服务端约定;不是广告关闭回调。
CONTENT_START 4 播放器实际开始播放的回调;宿主避免缓冲 / 暂停恢复重复计数。
OPEN_PAYOUT_PAGE 5 进入提现页面后,Value=0;入口被拦截未进页面不报。

当前不提供广告开始 AD_START / 3。开发工具可以直接调用相同公共队列,模拟内容开始、提现页打开等事件。

JSON · report 实际请求正文示例
{
  "EventType": 5,
  "Value": 0,
  "Timestamp": 1793318400,
  "Retry": 0
}

Timestamp 是入队时记录的事件 UTC Unix 秒,重试保持原值。Retry 首次即传 0;同一事件重发递增。每次请求头 Timestamp / Nonce / Sign 会重新生成。仅登录阶段失败时,尚未发出 report,不增加该事件的 Retry。

提现页打开上报不阻塞提现。调用 report 后业务正常显示页面 / 请求提现,不等待 receipt.await()。等待回执主要用于开发工具或确实需要确认的场景。
单次调用:reportOnce
Kotlin · 不进入队列的适配入口
riskClient.reportOnce(
    scope = identityScope,
    type = ReportEventType.CONTENT_START,
    value = 0.0,
    timestampSeconds = occurredAtUtcSeconds, // 可省略,默认当前 UTC 秒
)

挂起函数,返回 Unit;不持久化,不做网络 / 429 定时重试,仅支持一次失效会话恢复重发。常规业务和短剧开发工具均应使用 ReportQueue。

队列与重试

同一队列按入队顺序发送。网络恢复由定时尝试发现,没有主动注册网络恢复监听。

  1. 第一次重试:等待 30 秒。
  2. 第二次重试:等待 2 分钟。
  3. 第三次重试:等待 5 分钟。
  4. 之后每 30 分钟重试一次。

前三档最多追加 20% 随机延迟;之后每 30 分钟一次。与服务端 Retry-After 取较长时间。网络失败与 HTTP 429 共用退避次数。

结果 / 情况 队列处理
断网、连接失败、读写超时、HTTP 429 保留事件、持久化下次尝试时间;队首等待期间,后续事件排队。
report 会话过期 清除失效会话、重新登录,原事件最多立即恢复重试一次;网络 / 限流重试不重置该次数。
服务端成功 移除持久化记录,完成回执并记录 ack;不另外保存成功历史。
业务错误、其他 HTTP 错误(含普通 5xx)、无效 JSON、明确证书 / 主机名校验失败 完成失败并移出队列,继续后续事件。
风控拒绝且缺少凭据 失败结束,不自动循环登录。
App 进程退出 下次初始化队列恢复;上次仍标记发送中的事件按不确定尝试保留,增加 Retry 并退避。
持久化读写失败 停止本轮发送并通知当前等待方,不盲目继续发请求。
成功口径、重复发送和生命周期边界
  • 当前成功判断是 HTTP 2xx + 可解析 JSON + 解析后的 Result=0。兼容解析中缺失 / 非数字 Result 会按 0 处理,并非只接受显式 HTTP 200 / Result=0。
  • 本地删除发生在成功响应后;终止类错误也会删除,但回执失败、日志为 failed。调用 report 返回回执不等于已经成功,也不等于已写盘。
  • 超时可能发生在服务端已接收之后;目前后端没有事件幂等键,因此重发有重复计数可能。receipt.id 只用于本地跟踪。
  • 重试由循环和挂起 delay 实现,不递归增加调用栈。没有队列容量上限或自动过期清理,长期离线持续入队会增加存储占用。
  • 进程存活时按计时器重试,进程结束后等待下次启动;不创建系统后台任务。重启 / 新事件不会重置已经持久化的退避时间。
  • 默认 SharedPreferences 存储按宿主包名、baseUrl(含路径)、bundleId 隔离;密钥轮换不会切换事件队列。每份存储使用一个队列实例。

RiskStat

RiskClient 默认自动拼接 RiskStat;显式关闭时才省略。检测结果保留 UNKNOWN,业务明确选择协议映射。

Kotlin · 三种配置方式
// 1. 省略 deviceRisk:默认 collectDeviceRisk(applicationContext)。
//    若检测结果包含 UNKNOWN,直接序列化会失败,需要业务明确处理策略。

// 2. 自定义采集 / 映射(短剧例子见快速接入)。
// deviceRisk = { collectDeviceRisk(applicationContext).withShortDramaUnknownPolicy() }

// 3. 明确不发送 RiskStat。
// deviceRisk = null
// 或 deviceRisk = { null }
DeviceRisk 字段 RiskStat 字段 含义
networkType / timezone / locale NetworkType / Timezone / Locale 当前网络类型、时区、系统语言标记。
root Milan Root 检测。
adb Como ADB 开关;与调试器检测分开。
emulator Lazio 模拟器检测。
hook Roma Hook 检测。
accessibility Genoa 无障碍服务检测。
vpn Verona 默认网络 VPN;不把单纯代理当 VPN。

collectDeviceRisk(context: Context): DeviceRisk 是挂起函数,内部切换 IO。DeviceRisk.toJson(): JSONObject 将 DETECTED 编码为 0、NOT_DETECTED 编码为 1;遇到 UNKNOWN 拒绝序列化。它只采集登录需要的设备项,不进行完整 APK 扫描,也不请求 Play Integrity Token。

需要独立检测时可用 RiskScanner.scan() 或直接检测函数,详见 设备检测与可选插件指南 ↗。这部分是 SDK 的独立能力。

调试日志

所有 login / report 请求及响应、RiskStat 明细和队列状态,统一使用 RiskClient 的 debug 配置。

Kotlin · 与业务统一 tag
RiskClientConfig(
    baseUrl = baseUrl,
    bundleId = bundleId,
    appSecret = appSecret,
    debug = RiskDebugConfig(enabled = BuildConfig.DEBUG, tag = "CoreModule"),
)
// 可选:传给 RiskClient 的 diagnostics 参数。
val diagnostics = RiskDiagnostics { tag, message ->
    android.util.Log.d(tag, "MultiCountryPayout: " + message)
}
Logcat · 结构示例(正文为示例值)
request /user/report headers=
{ ... } body=
{
  "EventType": 4,
  "Value": 0,
  "Timestamp": 1793318400,
  "Retry": 0
}
response /user/report http=200 result=0 body=
{
  "Result": 0
}
report_queue ack type=CONTENT_START retry=0
report_queue retry type=CONTENT_START reason=network login_only=false delay_ms=32000

默认 enabled=false,不传 tag 时为 RiskControl。开启后按原值打印请求头 / 正文 / 响应,包括 Token、DeviceId、UserId、Sign 等,不脱敏。JSON 两空格缩进,长消息分段编号;不改变实际请求正文和签名。appSecret 本身不是请求字段,不单独输出。

操作 线程行为
HTTP、默认采集、会话与队列磁盘 IO SDK 内部在 IO 执行,report 可由主线程触发。
参数校验、读内存、事件时间记录 在调用线程执行。
loginParameters / deviceRisk IO 上执行。
onSession 调用协程的上下文,不保证 Main;UI 更新自行切主线程或观察 StateFlow。
RiskDiagnostics.log 可能后台调用;日志回调异常不会破坏业务。
独立 checkRoot 等同步检测函数 调用方负责放后台;RiskScanner 使用内部工作线程。

辅助 API

标准接入省略 store 即可。仅兼容旧队列或有独立存储需求时覆盖。

Kotlin · 自定义存储
// 显式纯内存:进程结束即丢失,不是默认行为。
val memoryOnly = ReportStore()

// 指定持久化名称;不同环境应使用不同名称。
val persistent = ReportStore.preferences(
    context = applicationContext,
    name = "my_app_risk_events",
    legacyScope = { oldLanguageTag -> scopeByOldLanguageTag[oldLanguageTag] },
)
val reports = ReportQueue(riskClient, applicationScope, store = persistent)

// 自定义串行 JSON 数组读写:read / write 在队列 IO 中执行。
// 不要将无关 JSON 或业务对象作为队列数据写入。
val custom = ReportStore(
    read = { readStoredEventsJson() },
    write = { json -> persistEventsJson(json) },
    legacyScope = { oldLanguageTag -> scopeByOldLanguageTag[oldLanguageTag] },
)

这些示例中的 readStoredEventsJson / persistEventsJson / scopeByOldLanguageTag 由宿主实现。每个 ReportStore 实例首次读取后在内存维护列表,不在每次 report 前重复导入。旧记录缺少 scope 才调用 legacyScope;返回 null 跳过该记录。保存后的新格式包含 scope,重启后不会再按旧字段重复迁移。旧事件没有 Timestamp 时保留缺失,不伪造发生时间。

辅助 API / 类型 用途
RiskProtocol.sign(content: String, key: String): String HMAC-SHA256,输出小写十六进制;供确需自行适配业务签名的宿主使用,login/report 已自动签名。
riskRetryAfterMillis(value: String?, nowMillis: Long): Long? 解析 Retry-After 秒数或 HTTP 日期,返回等待毫秒;无效 / 不支持返回 null。
RiskServiceException 提供 httpCode、result、serverMessage、retryAfterMillis、requestPath,以及 isExpiredSession()。
RiskRestrictedException 提供 riskTolerance,表示缺少可用凭据的风控结果。
ReportReceipt id: String;suspend await(): Unit。成功确认时完成,终止失败抛异常;只是服务确认,不代表业务收益到账。
RiskDiagnostics 函数接口 log(tag: String, message: String)。
ReportStore(read, write, legacyScope) read: () → String?;write: (String) → Unit;legacyScope: (String?) → String?。省略三者为纯内存存储。

普通 IOException、参数校验异常也可能返回给调用方;协程取消继续传播。SDK 没有对外提供手动 flush、成功历史查询、重试次数配置或队列清空函数。

更新记录

本次保持业务调用接口,升级登录 / 上报可观测性和事件投递行为。

  • 统一 HTTP 日志:login / report 的请求、响应和 RiskStat 明细;JSON 格式化,长日志分段。
  • 修复主线程发请求:开发工具由 Main 触发事件时,队列显式调度至 IO。
  • 持久化网络重试:断网 / 超时保留事件,使用 30 秒 → 2 分钟 → 5 分钟 → 30 分钟退避。
  • 恢复未确认事件:重启保留事件时间、Retry 和退避进度,继续尝试未确认的发送。