重构上传

This commit is contained in:
2026-10-29 14:39:14 +08:00
parent 5739f7f8e7
commit 0cf352cabb
19 changed files with 1398 additions and 190 deletions
@@ -1,101 +1,105 @@
package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.App
import net.bluemangoo.indpjsk.data.CaptureResult
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.util.*
import kotlin.random.Random
/**
* 把抓到的响应体上传到工具箱:
*
* POST https://toolbox-api-direct.haruki.seiunx.com/ios/script/{upload_code}/upload
* Content-Type: application/octet-stream (原始二进制,每片最大 1MB)
* X-Script-Version: 1.0.1
* X-Upload-Code: {upload_code}
* X-Upload-Id: 9 位随机字母数字(同一文件的所有分片共用)
* X-Chunk-Index: "0", "1", ...
* X-Total-Chunks: 分片总数(ceil(size / 1MB))
* X-Original-Url: 抓到的原始游戏 URL(后端用它识别区服 / UID / 数据类型)
*
* 分片串行发送,每一片都必须返回 HTTP 200。
*/
object Uploader {
private const val BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
private const val CHUNK_SIZE = 1024 * 1024
private const val SCRIPT_VERSION = "1.0.1"
suspend fun upload(
uploadCode: String,
suspend fun tryUpload(result: CaptureResult) {
val entries = UploadStore.load().filter { it.enabled }
if (entries.isEmpty()) return
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)")
for ((_, profile) in entries) {
try {
val uploaded = uploadChunked(profile, result.url, result.file)
if (uploaded) {
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
} else {
VpnLog.info("[${profile.id}] ${result.step.name} 没有对应的上传地址,跳过")
}
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
VpnLog.error("[${profile.id}] ${result.step.name} 上传失败: ${t.message}")
}
}
}
suspend fun uploadChunked(
profile: UploadProfile,
originalUrl: String,
file: File,
) = withContext(Dispatchers.IO) {
): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val type = when (file.name) {
"suite" -> "suite"
"mysekai" -> "mysekai"
"mysekai_birthday" -> "birthday"
else -> {
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
}
val url = profile.getUploadUrl(type)?.takeIf { it.isNotBlank() }
?: return@withContext false
val uploadId = randomUploadId()
RandomAccessFile(file, "r").use { raf ->
val size = raf.length()
val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
val buf = ByteArray(CHUNK_SIZE)
VpnLog.info("${file.name} 上传开始")
VpnLog.info("[${profile.id}] ${file.name} 上传开始 (${size} bytes)")
for (index in 0 until total) {
val remaining = size - raf.filePointer
val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt()
if (len > 0) raf.readFully(buf, 0, len)
val code = postChunk(uploadCode, uploadId, index, total, originalUrl, buf, len)
val code = postChunk(profile, url, uploadId, index, total, originalUrl, buf, file, len)
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
}
VpnLog.info("${file.name} 上传完成")
}
}
suspend fun tryUpload(result: CaptureResult) {
val context = App.appContext
// 复选框没勾就不传(没填上传码时复选框本身不可用)
if (!SettingsStore.loadUseUpload(context)) return
val uploadCode = SettingsStore.loadUploadCode(context).trim()
if (uploadCode.isEmpty()) {
VpnLog.warn("未填写上传码,跳过上传")
return
}
VpnLog.info("${result.profile.name}${result.step.name} 开始上传")
upload(uploadCode, result.url, result.file)
VpnLog.success("${result.profile.name}${result.step.name} 上传完成")
true
}
private fun postChunk(
uploadCode: String,
profile: UploadProfile,
url: String,
uploadId: String,
index: Int,
total: Int,
originalUrl: String,
data: ByteArray,
file: File,
length: Int,
): Int {
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection
val conn = URL(url).openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.instanceFollowRedirects = false
conn.instanceFollowRedirects = true
conn.connectTimeout = 15_000
conn.readTimeout = 60_000
conn.setRequestProperty("Content-Type", "application/octet-stream")
conn.setRequestProperty("X-Script-Version", SCRIPT_VERSION)
conn.setRequestProperty("X-Upload-Code", uploadCode)
conn.setRequestProperty("X-Upload-Id", uploadId)
conn.setRequestProperty("X-Chunk-Index", index.toString())
conn.setRequestProperty("X-Total-Chunks", total.toString())
conn.setRequestProperty("X-Original-Url", originalUrl)
conn.setFixedLengthStreamingMode(length)
profile.preRequest(conn, originalUrl, file)
conn.outputStream.use { it.write(data, 0, length) }
val code = conn.responseCode
@@ -106,7 +110,7 @@ object Uploader {
?.use { it.readText() }
?.take(300)
}.getOrNull().orEmpty()
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body")
throw IOException("${profile.id} 分片 ${index + 1}/$total 上传失败: HTTP $code $body")
}
return code
} finally {
@@ -114,7 +118,6 @@ object Uploader {
}
}
/** 9 位随机字母数字,同一文件所有分片共用一个 */
private fun randomUploadId(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
return buildString(9) {
@@ -122,3 +125,182 @@ object Uploader {
}
}
}
sealed interface UploadUrl {
fun getUrl(type: String): String?
}
data class UploadUrlGeneral(
val url: String
) : UploadUrl {
override fun getUrl(type: String): String {
return url
}
}
data class UploadUrlEach(
val suite: String?,
val mysekai: String?,
val birthday: String?,
) : UploadUrl {
override fun getUrl(type: String): String? {
return when (type) {
"suite" -> suite
"mysekai" -> mysekai
"birthday" -> birthday
else -> null
}
}
}
enum class UploadProfileType(val displayName: String) {
HARUKI("Haruki"),
REASONA("Reasona"),
_8823("8823"),
CUSTOM("自定义")
}
sealed interface UploadProfile {
val id: String
val type: UploadProfileType
val helpText: String?
fun getUploadUrl(type: String): String?
fun preRequest(
conn: HttpURLConnection, originalUrl: String, file: File,
)
val extraInfo: Map<String, String>
}
class UploadProfileHaruki(override val id: String, val uploadCode: String) : UploadProfile {
companion object {
private const val API_BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
}
override val extraInfo: Map<String, String>
get() = emptyMap()
override val helpText: String =
"在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 的「iOS模块」里复制上传码。"
override val type: UploadProfileType = UploadProfileType.HARUKI
override fun getUploadUrl(type: String): String {
return "$API_BASE/$uploadCode/upload"
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "1.0.1")
conn.setRequestProperty("X-Upload-Code", uploadCode)
}
}
class UploadProfileReasona(override val id: String, val uploadCode: String) : UploadProfile {
companion object {
private const val API_BASE = "https://api.resona.cn/scripts/"
}
override val type: UploadProfileType = UploadProfileType.REASONA
override val helpText: String =
"在 [Reasona 工具箱](https://web.resona.cn/tools/upload) 的 Haruki Proxy 里复制 token"
private val _uploadUrl = UploadUrlEach(
suite = "$API_BASE/suite/upload?jwt=$uploadCode",
mysekai = "$API_BASE/mysekai/upload?jwt=$uploadCode",
birthday = "$API_BASE/mysekai_birthday_party/upload?jwt=$uploadCode"
)
override fun getUploadUrl(type: String): String? {
return _uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "0.4.2")
val userId = originalUrl.tryParseUserId()
if (userId != null) {
conn.setRequestProperty("X-User-Id", userId)
}
}
private fun parseJwt(): JSONObject? {
val parts = uploadCode.split(".")
if (parts.size < 2) return null
val payload =
try {
val payloadBytes = Base64.getUrlDecoder().decode(parts[1])
String(payloadBytes, Charsets.UTF_8)
} catch (_: IllegalArgumentException) {
return null
}
return runCatching { JSONObject(payload) }.getOrNull()
}
override val extraInfo: Map<String, String>
get() {
val payload = parseJwt() ?: return emptyMap()
val map = mutableMapOf<String, String>()
if (payload.has("qqnum")) {
map["QQ 号"] = payload.optString("qqnum")
}
if (payload.has("sync_8823")) {
map["同步给 8823"] = payload.optBoolean("sync_8823").toString()
}
return map
}
}
class UploadProfile8823(override val id: String) : UploadProfile {
override val type: UploadProfileType = UploadProfileType._8823
override val helpText: String? = null
private val _uploadUrl = UploadUrlGeneral("https://api-8823.resona.cn/api/public/V1/upload")
override fun getUploadUrl(type: String): String {
return _uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "0.3.0")
val userId = originalUrl.tryParseUserId()
if (userId != null) {
conn.setRequestProperty("X-User-Id", userId)
}
}
override val extraInfo: Map<String, String> = emptyMap()
}
class UploadProfileCustom(override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map<String, String>) :
UploadProfile {
override val type: UploadProfileType = UploadProfileType.CUSTOM
override val helpText: String? = null
override fun getUploadUrl(type: String): String? {
return uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
customHeaders.forEach { (key, value) ->
when (value) {
$$"$original_url" -> conn.setRequestProperty(key, originalUrl)
$$"$file_name" -> conn.setRequestProperty(key, file.name)
$$"$user_id" -> originalUrl.tryParseUserId()?.let { conn.setRequestProperty(key, it) }
else -> conn.setRequestProperty(key, value)
}
}
}
override val extraInfo: Map<String, String> = emptyMap()
}
private fun String.tryParseUserId(): String? {
val regex = Regex("""/user/(\d+)|suite/user/(\d+)""")
val match = regex.find(this) ?: return null
return match.groupValues[1].ifEmpty { match.groupValues[2] }
.ifEmpty { null }
}