Show complete user credit data in admin

Load users by registration order, expose consumed and current credits, and accept short internal IDs so support can reliably locate accounts.
This commit is contained in:
Rocky
2026-08-19 17:54:27 +08:00
parent c592f426be
commit 58445dd880
9 changed files with 195 additions and 31 deletions
@@ -646,9 +646,10 @@ private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
AdminUserSummaryResponse(
userId = id,
displayName = "用户 ${id.take(8)}",
displayName = "用户 ${id.takeLast(8).uppercase()}",
status = if (antiAbuseRestricted) "suspended" else "active",
creditBalance = creditBalance,
consumedCredits = consumedCredits,
createdAt = createdAt,
)
@@ -817,6 +818,7 @@ private data class AdminUserSummaryResponse(
val displayName: String,
val status: String,
val creditBalance: Long,
val consumedCredits: Long,
val createdAt: String,
)
@@ -826,6 +828,7 @@ private data class AdminUserDetailResponse(
val displayName: String,
val status: String,
val creditBalance: Long,
val consumedCredits: Long,
val createdAt: String,
val lastActiveAt: String?,
val qualifiedUsage: Boolean,
@@ -847,6 +850,7 @@ private data class AdminUserDetailResponse(
displayName = summary.displayName,
status = summary.status,
creditBalance = summary.creditBalance,
consumedCredits = summary.consumedCredits,
createdAt = summary.createdAt,
lastActiveAt = lastActiveAt,
qualifiedUsage = qualifiedUsage,
@@ -16,6 +16,7 @@ import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.selectAll
@@ -35,6 +36,8 @@ data class AdminUserLedgerCursor(
interface AdminUsersRepository {
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
suspend fun findByIdSuffix(suffix: String, limit: Int): List<AdminUserSummaryDto>
suspend fun exists(userId: UUID): Boolean
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
@@ -74,6 +77,22 @@ class ExposedAdminUsersRepository(
accountRows.map { it.toSummary(support) }
}
override suspend fun findByIdSuffix(
suffix: String,
limit: Int,
): List<AdminUserSummaryDto> = databaseFactory.query {
val accountRows = AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id like "%$suffix" }
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
)
.limit(limit)
.toList()
val support = loadSupport(accountRows.map { it.userId() }.toSet())
accountRows.map { it.toSummary(support) }
}
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id eq userId.toString() }
@@ -17,10 +17,19 @@ class AdminUsersService(
private val repository: AdminUsersRepository,
) {
suspend fun searchByInternalId(query: String): AdminUserPageDto {
val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull()
?: return AdminUserPageDto(emptyList(), null)
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
val normalized = query.trim()
val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
if (userId != null) {
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
}
if (!SHORT_INTERNAL_ID.matches(normalized)) {
return AdminUserPageDto(emptyList(), null)
}
return AdminUserPageDto(
items = repository.findByIdSuffix(normalized.lowercase(), limit = MAX_SHORT_ID_MATCHES),
nextCursor = null,
)
}
suspend fun list(
@@ -80,6 +89,9 @@ class AdminUsersService(
}
}
private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$")
private const val MAX_SHORT_ID_MATCHES = 100
internal object AdminUserCursorCodec {
fun encode(cursor: AdminUserCursor): String {
val value = "${cursor.createdAt}|${cursor.userId}"
@@ -143,6 +143,40 @@ class AdminUsersServiceTest : FunSpec({
}
}
test("search accepts an uppercase 8-character internal user ID suffix") {
val matchingId = UUID.fromString("11111111-1111-4111-8111-aaaae3c7c475")
val otherId = UUID.fromString("22222222-2222-4222-8222-bbbb12345678")
val service = AdminUsersService(
PagingUsersRepository(
listOf(
summary(matchingId, Instant.parse("2026-08-15T02:00:00Z")),
summary(otherId, Instant.parse("2026-08-15T01:00:00Z")),
),
),
)
val page = service.searchByInternalId("E3C7C475")
page.items.map { it.id } shouldBe listOf(matchingId.toString())
page.nextCursor shouldBe null
}
test("short ID search returns all collisions for disambiguation") {
val firstId = UUID.fromString("11111111-1111-4111-8111-aaaae3c7c475")
val secondId = UUID.fromString("22222222-2222-4222-8222-bbbbe3c7c475")
val service = AdminUsersService(
PagingUsersRepository(
listOf(
summary(firstId, Instant.parse("2026-08-15T02:00:00Z")),
summary(secondId, Instant.parse("2026-08-15T01:00:00Z")),
),
),
)
service.searchByInternalId("e3c7c475").items.map { it.id } shouldBe
listOf(firstId.toString(), secondId.toString())
}
test("invalid user cursor and missing detail fail without returning user data") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
@@ -178,6 +212,12 @@ private class PagingUsersRepository(
ledgerLimit: Int,
): AdminUserDetailDto? = details[userId]
override suspend fun findByIdSuffix(
suffix: String,
limit: Int,
): List<AdminUserSummaryDto> =
users.filter { it.id.endsWith(suffix, ignoreCase = true) }.take(limit)
override suspend fun exists(userId: UUID): Boolean =
userId in details || userId in ledger || users.any { it.id == userId.toString() }