Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ RABBITMQ_DLQ=sync_queue.dlq
RABBITMQ_NF_QUEUE=not_found_parse_queue
RABBITMQ_NF_DLQ=not_found_parse_queue.dlq

# ===== 投稿数据迁移队列 =====
RABBITMQ_MIGRATION_EXCHANGE=ttml.migration
RABBITMQ_MIGRATION_QUEUE=migration.user
RABBITMQ_MIGRATION_DLQ=migration.user.dlq
RABBITMQ_MIGRATION_CLOSE_QUEUE=migration.close_pr
RABBITMQ_MIGRATION_CLOSE_DLQ=migration.close_pr.dlq
# 迁移起始 PR 号(含),该号之前的 PR 不迁移
MIGRATION_PR_START_NUMBER=7390
# 迁移时 GitHub API 请求并发上限(≤10)
MIGRATION_CONCURRENCY=10
# 每处理多少条 PR 刷新一次任务进度
MIGRATION_BATCH_SIZE=20
# 迁移完成后是否自动关闭已迁移的 GitHub PR(默认关闭)
MIGRATION_CLOSE_PR_ENABLED=false

# ===== MeiliSearch =====
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_API_KEY=meili-master-key
Expand All @@ -49,6 +64,11 @@ GITHUB_APP_REPO_OWNER=amll-dev
GITHUB_APP_REPO_NAME=amll-ttml-db
GITHUB_APP_UPLOAD_FOLDER=raw-lyrics

# ===== GitHub OAuth=====
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GITHUB_OAUTH_REDIRECT_URI=http://localhost:8080/api/v1/auth/github/callback

# ===== Submission 投稿模块 =====
SUBMISSION_AUTO_REJECT_INTERVAL=1h
SUBMISSION_AUTO_REJECT_AFTER=96h
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,8 @@ pnpm-debug.log*
# Environment
.env
.env.local

# Secrets / keys
*.pem
*.key
*private-key*
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package dev.amll.hub.android.data.remote
import dev.amll.hub.android.data.local.AuthStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/** 解析错误响应体用的轻量 Json */
private val errorJson = Json { ignoreUnknownKeys = true }

/**
* 认证仓库:统一处理后端 {code, message, data} 包装与错误映射。
* 对应网页端 lib/api.ts 的 request() 管道。
Expand All @@ -31,7 +35,7 @@ class AuthRepository @Inject constructor(
}

suspend fun sendCode(checkType: String, dest: String) {
call { api.sendCode(SendCodeRequest(checkType = checkType, dest = dest.trim())) }
request { api.sendCode(SendCodeRequest(checkType = checkType, dest = dest.trim())) }
}

suspend fun profile(): UserProfile = call { api.profile() }
Expand All @@ -44,24 +48,41 @@ class AuthRepository @Inject constructor(

/** 确认把当前账号授权给该票据 */
suspend fun confirmQrLogin(ticket: String) {
call { api.confirmQr(TicketRequest(ticket)) }
request { api.confirmQr(TicketRequest(ticket)) }
}

/** 取消本次扫码登录 */
suspend fun cancelQrLogin(ticket: String) {
call { api.cancelQr(TicketRequest(ticket)) }
request { api.cancelQr(TicketRequest(ticket)) }
}

/**
* 调用后端接口并解包统一响应。
* code !== 200 时抛出携带后端 message 的 [ApiException],UI 直接展示即可。
* 需要数据体的接口:`data` 缺失视为异常。
*/
private suspend fun <T> call(block: suspend () -> ApiResponse<T>): T =
private suspend fun <T : Any> call(block: suspend () -> ApiResponse<T>): T =
request(block) ?: throw ApiException(200, "服务端返回空数据")

/**
* 统一请求管道:解包 {code, message, data} 并映射错误。
*
* - `data` 允许为空:后端 `OKWithMsg(c, nil, ...)`(发码、扫码确认/取消等)因
* `json:"data,omitempty"` 会直接省略 data 字段。网页端 request() 同样只校验 code,
* 不要求 data 非空 —— 必须保持一致,否则发码接口永远报「服务端返回空数据」。
* - HTTP 非 2xx 时尽量取出响应体里的后端 message(如「验证码错误」),
* 而不是统一映射成「服务异常」,否则用户无法判断错在哪。
*/
private suspend fun <T> request(block: suspend () -> ApiResponse<T>): T? =
withContext(Dispatchers.IO) {
val resp = try {
block()
} catch (e: HttpException) {
throw if (e.code() == 401) UnauthorizedException() else NetworkException("服务异常(HTTP ${e.code()})")
val backendMsg = errorMessage(e)
if (e.code() == 401) {
// 401 既可能是 token 失效,也可能是「用户名或密码错误」,
// 用后端文案覆盖默认提示;类型仍是 UnauthorizedException。
throw if (backendMsg == null) UnauthorizedException() else UnauthorizedException(backendMsg)
}
throw ApiException(e.code(), backendMsg ?: "服务异常(HTTP ${e.code()})")
} catch (e: IOException) {
throw NetworkException()
}
Expand All @@ -70,6 +91,13 @@ class AuthRepository @Inject constructor(
if (resp.code == 401) throw UnauthorizedException(resp.message)
throw ApiException(resp.code, resp.message.ifBlank { "请求失败(${resp.code})" })
}
resp.data ?: throw ApiException(resp.code, "服务端返回空数据")
resp.data
}

/** 后端错误响应体形如 {"code":400,"message":"验证码错误"},取出 message 供 UI 展示 */
private fun errorMessage(e: HttpException): String? = runCatching {
e.response()?.errorBody()?.string()
?.let { errorJson.decodeFromString<ApiResponse<Unit>>(it).message }
?.takeIf { it.isNotBlank() }
}.getOrNull()
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ data class SendCodeRequest(
val checkType: String,
val dest: String,
val method: String = "login",
val captchaType: String = "none",
/** 空串表示交由后端按 Casdoor 配置自动补齐人机验证(勿传 "none") */
val captchaType: String = "",
val captchaToken: String = "",
)

Expand Down
102 changes: 74 additions & 28 deletions android/app/src/main/java/dev/amll/hub/android/nav/AmllNavGraph.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
package dev.amll.hub.android.nav

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.NavHostController
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import dev.amll.hub.android.data.remote.UserProfile
import dev.amll.hub.android.ui.screen.HomeScreen
import dev.amll.hub.android.ui.screen.LoginScreen
import dev.amll.hub.android.ui.screen.ScanScreen
import dev.amll.hub.android.ui.session.SessionState
import dev.amll.hub.android.ui.session.SessionViewModel

object Routes {
const val LOGIN = "login"
const val HOME = "home"

/** 扫码页,ticket 可为空(手动进入扫码器)或由 deep link 带入 */
Expand All @@ -22,42 +33,65 @@ object Routes {
if (ticket.isNullOrBlank()) "scan" else "scan?ticket=$ticket"
}

/**
* 顶层导航,完全由 [SessionViewModel] 的持久化登录态驱动:
* - Loading:读本地 token 期间的启动占位,避免登录页一闪而过
* - LoggedIn:首页 + 扫码授权页
* - LoggedOut:仅登录页,未登录不提供「扫码登录网页端」入口
*
* 登录 / 登出只更新登录态,分区切换由状态完成,无需手动推栈。
*/
@Composable
fun AmllNavGraph(
navController: NavHostController = rememberNavController(),
/** deep link 带进来的 ticket,非空时直接跳扫码确认页 */
/** deep link 带进来的 ticket,非空时登录后直接弹扫码确认页 */
pendingTicket: String? = null,
onTicketConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
) {
// deep link 优先于当前页面:用户点二维码就是为了来授权的
val session by sessionViewModel.state.collectAsStateWithLifecycle()

when (val state = session) {
SessionState.Loading -> SplashPane()

is SessionState.LoggedIn -> LoggedInNav(
profile = state.profile,
pendingTicket = pendingTicket,
onTicketConsumed = onTicketConsumed,
onLoggedOut = { sessionViewModel.logout() },
onRefreshProfile = { sessionViewModel.refreshProfile() },
)

SessionState.LoggedOut -> LoginScreen(
onLoggedIn = { sessionViewModel.onLoggedIn(it) },
)
}
}

/** 登录后的导航分区:首页 + 扫码授权页 */
@Composable
private fun LoggedInNav(
profile: UserProfile?,
pendingTicket: String?,
onTicketConsumed: () -> Unit,
onLoggedOut: () -> Unit,
onRefreshProfile: () -> Unit,
) {
val navController = rememberNavController()

// 已登录时 deep link 直接进扫码确认页;未登录时 ticket 会被保留,登录后再处理
LaunchedEffect(pendingTicket) {
val ticket = pendingTicket ?: return@LaunchedEffect
val ticket = pendingTicket?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
onTicketConsumed()
navController.navigate(Routes.scan(ticket)) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
navController.navigate(Routes.scan(ticket))
}

NavHost(navController = navController, startDestination = Routes.LOGIN) {
composable(Routes.LOGIN) {
LoginScreen(
onLoggedIn = {
navController.navigate(Routes.HOME) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
},
onScanQr = { navController.navigate(Routes.scan()) },
)
}

NavHost(navController = navController, startDestination = Routes.HOME) {
composable(Routes.HOME) {
HomeScreen(
profile = profile,
onScanQr = { navController.navigate(Routes.scan()) },
onLoggedOut = {
navController.navigate(Routes.LOGIN) {
popUpTo(Routes.HOME) { inclusive = true }
}
},
onLoggedOut = onLoggedOut,
onRefreshProfile = onRefreshProfile,
)
}

Expand All @@ -75,12 +109,24 @@ fun AmllNavGraph(
initialTicket = entry.arguments?.getString("ticket"),
onBack = { navController.popBackStack() },
onDone = {
// 扫码页带参数,popUpTo 用 route 模式匹配不到,直接清栈
navController.navigate(Routes.HOME) {
popUpTo(0) { inclusive = true }
popUpTo(Routes.HOME) { inclusive = true }
}
},
)
}
}
}

/** 读取本地登录态期间的启动占位,避免登录页一闪而过 */
@Composable
private fun SplashPane() {
Surface(color = MaterialTheme.colorScheme.background) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
CircularProgressIndicator()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.amll.hub.android.data.remote.UserProfile

Expand All @@ -36,13 +33,15 @@ import dev.amll.hub.android.data.remote.UserProfile
*/
@Composable
fun HomeScreen(
profile: UserProfile?,
onScanQr: () -> Unit,
onLoggedOut: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
onRefreshProfile: () -> Unit,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()

LaunchedEffect(Unit) { viewModel.restoreSession() }
// 登录态已恢复但资料缺失(如离线启动)时补拉一次
LaunchedEffect(profile) {
if (profile == null) onRefreshProfile()
}

Surface(color = MaterialTheme.colorScheme.background) {
Column(
Expand All @@ -58,7 +57,7 @@ fun HomeScreen(
)
Spacer(Modifier.height(24.dp))

state.profile?.let { ProfileCard(it) }
profile?.let { ProfileCard(it) }

Spacer(Modifier.height(24.dp))

Expand All @@ -78,10 +77,7 @@ fun HomeScreen(
Spacer(Modifier.height(8.dp))

OutlinedButton(
onClick = {
viewModel.logout()
onLoggedOut()
},
onClick = onLoggedOut,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.amll.hub.android.data.remote.UserProfile
import dev.amll.hub.android.ui.component.ErrorBanner

@Composable
fun LoginScreen(
onLoggedIn: () -> Unit,
onScanQr: () -> Unit,
onLoggedIn: (UserProfile) -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -105,7 +105,7 @@ fun LoginScreen(
Spacer(Modifier.height(24.dp))

Button(
onClick = { viewModel.submit { onLoggedIn() } },
onClick = { viewModel.submit { onLoggedIn(it.user) } },
enabled = state.canSubmit,
modifier = Modifier
.fillMaxWidth()
Expand All @@ -121,12 +121,6 @@ fun LoginScreen(
Text("登录")
}
}

Spacer(Modifier.height(12.dp))

TextButton(onClick = onScanQr) {
Text("扫码登录网页端")
}
}
}
}
Expand Down
Loading
Loading