docs(sdk): 添加 React Native SDK 文档和 Android/HarmonyOS 发版脚本

- 新增 XuqmGroup React Native SDK 使用文档,包含安装、初始化、HTTP客户端、IM模块、推送模块、版本管理等功能说明
- 添加 Android Gradle 发版任务脚本,支持构建发布 APK 并上传到更新服务
- 添加 HarmonyOS hvigorw 发版任务脚本,支持 HAP 包构建和上传功能
- 实现多平台版本检查、自动重连、灰度发布等发版流程自动化
- 集成商店提交、定时发布、Webhook 回调等发布后处理功能
这个提交包含在:
XuqmGroup 2026-04-29 17:35:52 +08:00
父节点 19b389856f
当前提交 6f5ce42e50

查看文件

@ -55,9 +55,11 @@ fun loadXuqmConfig(projectDir: File): Properties {
fun httpGet(url: String, token: String): String {
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer $token")
.GET().build()
val builder = HttpRequest.newBuilder(URI.create(url))
if (token.isNotBlank()) {
builder.header("Authorization", "Bearer $token")
}
val req = builder.GET().build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
@ -92,11 +94,12 @@ fun httpMultipartPost(url: String, token: String, parts: Map<String, Any>): Stri
val body = baos.toByteArray()
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer $token")
val builder = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "multipart/form-data; boundary=$boundary")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build()
if (token.isNotBlank()) {
builder.header("Authorization", "Bearer $token")
}
val req = builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build()
return client.send(req, HttpResponse.BodyHandlers.ofString()).body()
}
@ -105,6 +108,12 @@ fun parseJson(json: String, key: String): String? =
fun parseJsonInt(json: String, key: String): Int? =
Regex("\"$key\"\\s*:\\s*(\\d+)").find(json)?.groupValues?.get(1)?.toIntOrNull()
fun parseJsonBool(json: String, key: String, default: Boolean = false): Boolean =
Regex("\"$key\"\\s*:\\s*(true|false)").find(json)?.groupValues?.get(1)?.toBooleanStrictOrNull() ?: default
fun parseCsv(value: String?): List<String> =
value?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
fun promptLine(message: String): String? {
val console = System.console() ?: return null
print(message)
@ -124,16 +133,55 @@ fun promptYesNo(message: String, default: Boolean = false): Boolean {
fun promptReleaseMode(): String {
val answer = promptLine(
"Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review]: "
"Publish mode? [1=manual, 2=publish now, 3=scheduled, 4=auto after review, 5=dry-run]: "
)?.trim().orEmpty()
return when (answer) {
"2" -> "NOW"
"3" -> "SCHEDULED"
"4" -> "AUTO_REVIEW"
"5" -> "DRY_RUN"
else -> "MANUAL"
}
}
data class ReleaseDefaults(
val enabled: Boolean = true,
val defaultStoreTargets: List<String> = emptyList(),
val defaultPublishMode: String = "MANUAL",
val defaultPublishImmediately: Boolean = false,
val defaultScheduledPublishAt: String = "",
val defaultAutoPublishAfterReview: Boolean = false,
val defaultWebhookUrl: String = "",
val defaultForceUpdate: Boolean = false,
val defaultGrayEnabled: Boolean = false,
val defaultGrayPercent: Int = 0,
val defaultPackageName: String = "",
val defaultAppStoreUrl: String = "",
val defaultMarketUrl: String = "",
)
fun fetchReleaseDefaults(tenantUrl: String, appKey: String): ReleaseDefaults? {
if (tenantUrl.isBlank()) return null
val url = "$tenantUrl/api/sdk/config?appId=$appKey&platform=ANDROID"
val json = runCatching { httpGet(url, "") }.getOrNull() ?: return null
if (!parseJsonBool(json, "updateEnabled", false)) return ReleaseDefaults(enabled = false)
return ReleaseDefaults(
enabled = true,
defaultStoreTargets = parseCsv(parseJson(json, "updateDefaultStoreTargets")),
defaultPublishMode = parseJson(json, "updateDefaultPublishMode") ?: "MANUAL",
defaultPublishImmediately = parseJsonBool(json, "updateDefaultPublishImmediately"),
defaultScheduledPublishAt = parseJson(json, "updateDefaultScheduledPublishAt").orEmpty(),
defaultAutoPublishAfterReview = parseJsonBool(json, "updateDefaultAutoPublishAfterReview"),
defaultWebhookUrl = parseJson(json, "updateDefaultWebhookUrl").orEmpty(),
defaultForceUpdate = parseJsonBool(json, "updateDefaultForceUpdate"),
defaultGrayEnabled = parseJsonBool(json, "updateDefaultGrayEnabled"),
defaultGrayPercent = parseJsonInt(json, "updateDefaultGrayPercent") ?: 0,
defaultPackageName = parseJson(json, "updateDefaultPackageName").orEmpty(),
defaultAppStoreUrl = parseJson(json, "updateDefaultAppStoreUrl").orEmpty(),
defaultMarketUrl = parseJson(json, "updateDefaultMarketUrl").orEmpty(),
)
}
// ── Task registration ─────────────────────────────────────────────────────
tasks.register("xuqmRelease") {
@ -148,18 +196,54 @@ tasks.register("xuqmRelease") {
val serverUrl = cfg.getProperty("xuqm.serverUrl") ?: throw GradleException("xuqm.serverUrl missing")
val appKey = cfg.getProperty("xuqm.appKey") ?: throw GradleException("xuqm.appKey missing")
val apiToken = cfg.getProperty("xuqm.apiToken") ?: throw GradleException("xuqm.apiToken missing")
val storeTargets = cfg.getProperty("xuqm.storeTargets", "") // e.g. "HUAWEI,MI,OPPO"
val autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", "false").toBoolean()
val publishImmediately = cfg.getProperty("xuqm.publishImmediately", "false").toBoolean()
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", "")
val webhookUrl = cfg.getProperty("xuqm.webhookUrl", "")
var publishMode = cfg.getProperty("xuqm.publishMode", "").trim().uppercase()
val tenantUrl = cfg.getProperty("xuqm.tenantUrl", "").trim()
val dryRunMode = cfg.getProperty("xuqm.dryRun", "false").toBoolean()
val tenantDefaults = fetchReleaseDefaults(tenantUrl, appKey)
val updateEnabled = tenantDefaults?.enabled ?: true
val defaultStoreTargets = tenantDefaults?.defaultStoreTargets?.joinToString(",").orEmpty()
val defaultPublishMode = tenantDefaults?.defaultPublishMode ?: "MANUAL"
val defaultPublishImmediately = tenantDefaults?.defaultPublishImmediately ?: false
val defaultScheduledAt = tenantDefaults?.defaultScheduledPublishAt.orEmpty()
val defaultAutoPublishAfterReview = tenantDefaults?.defaultAutoPublishAfterReview ?: false
val defaultWebhookUrl = tenantDefaults?.defaultWebhookUrl.orEmpty()
val defaultForceUpdate = tenantDefaults?.defaultForceUpdate ?: false
val defaultGrayEnabled = tenantDefaults?.defaultGrayEnabled ?: false
val defaultGrayPercent = tenantDefaults?.defaultGrayPercent ?: 0
var storeTargets = cfg.getProperty("xuqm.storeTargets", defaultStoreTargets)
var autoPublish = cfg.getProperty("xuqm.autoPublishAfterReview", defaultAutoPublishAfterReview.toString()).toBoolean()
var publishImmediately = cfg.getProperty("xuqm.publishImmediately", defaultPublishImmediately.toString()).toBoolean()
var scheduledAt = cfg.getProperty("xuqm.scheduledPublishAt", defaultScheduledAt)
var webhookUrl = cfg.getProperty("xuqm.webhookUrl", defaultWebhookUrl)
var publishMode = cfg.getProperty("xuqm.publishMode", defaultPublishMode).trim().uppercase()
val forceUpdate = cfg.getProperty("xuqm.forceUpdate", defaultForceUpdate.toString()).toBoolean()
val grayEnabled = cfg.getProperty("xuqm.grayEnabled", defaultGrayEnabled.toString()).toBoolean()
val grayPercent = cfg.getProperty("xuqm.grayPercent", defaultGrayPercent.toString()).toIntOrNull()?.coerceIn(1, 100) ?: defaultGrayPercent
val allowVersionMismatch = cfg.getProperty("xuqm.allowVersionMismatch", "false").toBoolean()
var dryRun = dryRunMode || publishMode == "DRY_RUN" || !updateEnabled
if (publishMode.isBlank() && !publishImmediately && scheduledAt.isBlank() && !autoPublish && System.console() != null) {
publishMode = promptReleaseMode()
if (publishMode == "SCHEDULED" && scheduledAt.isBlank()) {
scheduledAt = promptLine("Scheduled publish time (ISO datetime): ")?.trim().orEmpty()
}
}
if (publishMode == "DRY_RUN") {
dryRun = true
println("[xuqm] Dry-run mode enabled: release will stop after validation and packaging.")
}
if (scheduledAt.isNotBlank()) {
publishImmediately = false
autoPublish = false
}
if (publishMode == "NOW") {
publishImmediately = true
autoPublish = false
} else if (publishMode == "AUTO_REVIEW") {
autoPublish = true
publishImmediately = false
}
// ── 1. Read local version ──────────────────────────────────────────
val android = project.extensions.findByName("android")
@ -180,12 +264,34 @@ tasks.register("xuqmRelease") {
.maxOrNull() ?: 0
println("[xuqm] Server latest versionCode: $serverVersionCode")
if (versionCode <= serverVersionCode) {
throw GradleException(
"[xuqm] Local versionCode ($versionCode) must be greater than server ($serverVersionCode). " +
"Please bump versionCode in build.gradle.kts before releasing."
)
var releaseVersionName = versionName
var releaseVersionCode = versionCode
if (releaseVersionCode <= serverVersionCode) {
if (System.console() != null && !allowVersionMismatch) {
println("[xuqm] Local version is not greater than server latest. Please enter corrected release version info.")
val inputVersionName = promptLine("Release versionName [$versionName]: ")?.trim().orEmpty()
val inputVersionCode = promptLine("Release versionCode [$versionCode]: ")?.trim().orEmpty()
if (inputVersionName.isNotBlank()) releaseVersionName = inputVersionName
if (inputVersionCode.isNotBlank()) releaseVersionCode = inputVersionCode.toInt()
}
if (releaseVersionCode <= serverVersionCode) {
throw GradleException(
"[xuqm] Release versionCode ($releaseVersionCode) must be greater than server ($serverVersionCode). " +
"Bump versionCode in build.gradle.kts or pass xuqm.allowVersionMismatch=true if you deliberately override."
)
}
}
if (releaseVersionName != versionName || releaseVersionCode != versionCode) {
println("[xuqm] Using release override: $releaseVersionName ($releaseVersionCode)")
}
if (storeTargets.isBlank() && System.console() != null && !dryRun) {
val inputTargets = promptLine("Store targets (comma separated, blank for none): ")?.trim().orEmpty()
if (inputTargets.isNotBlank()) {
storeTargets = inputTargets
}
}
val storeTargetList = parseCsv(storeTargets)
// ── 3. Locate APK ─────────────────────────────────────────────────
val apkDir = File(buildDir, "outputs/apk/release")
@ -193,21 +299,39 @@ tasks.register("xuqmRelease") {
?: throw GradleException("No APK found in ${apkDir.absolutePath}. Did assembleRelease succeed?")
println("[xuqm] APK: ${apkFile.absolutePath}")
if (dryRun) {
println("[xuqm] Dry-run summary:")
println(" updateEnabled=$updateEnabled")
println(" releaseVersion=$releaseVersionName ($releaseVersionCode)")
println(" publishMode=$publishMode")
println(" publishImmediately=$publishImmediately")
println(" autoPublishAfterReview=$autoPublish")
println(" scheduledPublishAt=${scheduledAt.ifBlank { "-" }}")
println(" webhookUrl=${webhookUrl.ifBlank { "-" }}")
println(" forceUpdate=$forceUpdate")
println(" grayEnabled=$grayEnabled, grayPercent=$grayPercent")
println(" storeTargets=${storeTargetList.joinToString(",").ifBlank { "-" }}")
println(" packageName=${applicationId.ifBlank { "-" }}")
println("[xuqm] Dry-run completed. No upload performed.")
return@doLast
}
// ── 4. Upload to update service ───────────────────────────────────
val parts = mutableMapOf<String, Any>(
"appId" to appKey,
"platform" to "ANDROID",
"versionName" to versionName,
"versionCode" to versionCode,
"forceUpdate" to "false",
"versionName" to releaseVersionName,
"versionCode" to releaseVersionCode,
"forceUpdate" to forceUpdate.toString(),
"autoPublishAfterReview" to autoPublish.toString(),
"apkFile" to apkFile,
)
if (applicationId.isNotBlank()) parts["packageName"] = applicationId
if (storeTargets.isNotBlank()) parts["storeSubmitTargets"] = "[\"${storeTargets.split(",").joinToString("\",\"")}\"]"
if (storeTargetList.isNotEmpty()) parts["storeSubmitTargets"] = "[\"${storeTargetList.joinToString("\",\"")}\"]"
if (scheduledAt.isNotBlank()) parts["scheduledPublishAt"] = scheduledAt
if (webhookUrl.isNotBlank()) parts["webhookUrl"] = webhookUrl
if (publishMode == "NOW") parts["publishImmediately"] = "true"
if (publishMode == "AUTO_REVIEW") parts["autoPublishAfterReview"] = "true"
println("[xuqm] Uploading APK...")
val uploadResp = httpMultipartPost("$serverUrl/api/v1/updates/app/upload", apiToken, parts)
@ -216,9 +340,9 @@ tasks.register("xuqmRelease") {
println("[xuqm] Uploaded, version ID: $versionId")
// ── 5. Trigger server-side store submission ────────────────────────
if (storeTargets.isNotBlank()) {
println("[xuqm] Triggering store submission: $storeTargets ...")
val storeBody = """{"storeTypes":[${storeTargets.split(",").joinToString(",") { "\"$it\"" }}]}"""
if (storeTargetList.isNotEmpty()) {
println("[xuqm] Triggering store submission: ${storeTargetList.joinToString(",")} ...")
val storeBody = """{"storeTypes":[${storeTargetList.joinToString(",") { "\"$it\"" }}]}"""
val client = HttpClient.newHttpClient()
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/store/app/$versionId/execute-submit"))
.header("Authorization", "Bearer $apiToken")
@ -229,6 +353,18 @@ tasks.register("xuqmRelease") {
println("[xuqm] Store submission triggered (HTTP ${storeResp.statusCode()})")
}
if (grayEnabled && (publishImmediately || publishMode == "NOW")) {
val client = HttpClient.newHttpClient()
val grayBody = """{"enabled":true,"percent":$grayPercent}"""
val req = HttpRequest.newBuilder(URI.create("$serverUrl/api/v1/updates/app/$versionId/gray"))
.header("Authorization", "Bearer $apiToken")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(grayBody))
.build()
val grayResp = client.send(req, HttpResponse.BodyHandlers.ofString())
println("[xuqm] Gray release configured (HTTP ${grayResp.statusCode()})")
}
if (publishImmediately || publishMode == "NOW") {
println("[xuqm] Published immediately in update service.")
} else if (publishMode == "SCHEDULED" || scheduledAt.isNotBlank()) {