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:
@@ -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,
|
||||
|
||||
+19
@@ -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() }
|
||||
|
||||
+16
-4
@@ -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() }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user