docs(sdk): 添加 React Native SDK 文档和 Android/HarmonyOS 发版脚本
- 新增 XuqmGroup React Native SDK 使用文档,包含安装、初始化、HTTP客户端、IM模块、推送模块、版本管理等功能说明 - 添加 Android Gradle 发版任务脚本,支持构建发布 APK 并上传到更新服务 - 添加 HarmonyOS hvigorw 发版任务脚本,支持 HAP 包构建和上传功能 - 实现多平台版本检查、自动重连、灰度发布等发版流程自动化 - 集成商店提交、定时发布、Webhook 回调等发布后处理功能
这个提交包含在:
父节点
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()) {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户