From 58445dd880a2fa8e9c68bb43762f0bb4da26353a Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:54:27 +0800 Subject: [PATCH] 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. --- admin-web/src/api/client.ts | 2 +- admin-web/src/api/types.ts | 1 + admin-web/src/pages/users.ts | 99 ++++++++++++++----- admin-web/src/test/pages.test.ts | 32 ++++++ docs/openapi.yaml | 7 +- .../features/admin/routes/AdminRoutes.kt | 6 +- .../repositories/AdminUsersRepository.kt | 19 ++++ .../admin/users/services/AdminUsersService.kt | 20 +++- .../admin/users/AdminUsersServiceTest.kt | 40 ++++++++ 9 files changed, 195 insertions(+), 31 deletions(-) diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index 7ef62f6..2e8b0ba 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -165,7 +165,7 @@ export const adminApi = { referrals: (range: string) => request(`/referrals${query({ range })}`), - users: (search: string, cursor?: string) => + users: (search = "", cursor?: string) => request>( `/users${query({ q: search.trim(), cursor })}`, ), diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index de380a4..4c70a60 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -58,6 +58,7 @@ export interface UserSummary { maskedEmail?: string; status: "active" | "suspended" | "closed"; creditBalance: number; + consumedCredits: number; createdAt: string; } diff --git a/admin-web/src/pages/users.ts b/admin-web/src/pages/users.ts index 04df59f..a50d19f 100644 --- a/admin-web/src/pages/users.ts +++ b/admin-web/src/pages/users.ts @@ -28,8 +28,8 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
账户管理
-

用户查询

-

按内部用户 ID 精确查询,不展示 Apple 身份标识。

+

用户列表

+

按注册时间查看全部用户,支持完整或后 8 位内部用户 ID 查询。

@@ -37,11 +37,11 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void { 搜索 -
${renderEmpty("输入查询条件开始搜索")}
+
${renderEmpty("正在加载用户列表")}
`; @@ -49,11 +49,14 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void { form?.addEventListener("submit", (event) => { event.preventDefault(); const search = new FormData(form).get("query")?.toString().trim() ?? ""; - if (search) void searchUsers(container, search, role); + void loadUsers(container, search, role); }); + const results = container.querySelector("[data-results]"); + if (results) bindUserRows(results, container, role); + void loadUsers(container, "", role); } -async function searchUsers( +async function loadUsers( container: HTMLElement, search: string, role: AdminRole, @@ -71,40 +74,90 @@ async function searchUsers( results.innerHTML = `
- - - - ${page.items.map(userRow).join("")} - + + + ${userRows(page.items)}
用户查询结果
用户状态积分余额注册时间操作
${search ? "用户查询结果" : "全部用户"}
用户状态累计使用积分当前积分注册时间操作
+ ${ + page.nextCursor + ? '
加载更多用户
' + : "" + } `; - results.querySelectorAll("[data-user-id]").forEach( - (button) => { - button.addEventListener("click", () => { - const userId = button.dataset.userId; - if (userId) void renderUserDetail(container, userId, role); - }); - }, - ); + if (page.nextCursor) { + bindUserPagination(container, search, page.nextCursor); + } } catch (error) { - renderError(results, error, () => void searchUsers(container, search, role)); + renderError(results, error, () => void loadUsers(container, search, role)); } } -function userRow(user: UserSummary): string { - return ` +function userRows(users: UserSummary[]): string { + return users + .map( + (user) => ` ${escapeHtml(user.displayName || "未命名用户")}
${escapeHtml(user.userId)}
${statusLabel(user.status)} + ${formatNumber(user.consumedCredits)} ${formatNumber(user.creditBalance)} ${formatDateTime(user.createdAt)} 查看 - `; + `, + ) + .join(""); +} + +function bindUserRows( + scope: HTMLElement, + container: HTMLElement, + role: AdminRole, +): void { + scope.addEventListener("click", (event) => { + const button = (event.target as Element).closest( + "[data-user-id]", + ); + const userId = button?.dataset.userId; + if (userId) void renderUserDetail(container, userId, role); + }); +} + +function bindUserPagination( + container: HTMLElement, + search: string, + initialCursor: string, +): void { + const button = container.querySelector("[data-user-more]"); + const body = container.querySelector("[data-user-body]"); + if (!button || !body) return; + let cursor: string | undefined = initialCursor; + + button.addEventListener("click", async () => { + if (!cursor) return; + setButtonBusy(button, true, "加载中…"); + try { + const page = await adminApi.users(search, cursor); + body.insertAdjacentHTML("beforeend", userRows(page.items)); + cursor = page.nextCursor; + if (!cursor) { + button.closest(".pagination-actions")?.remove(); + } else { + setButtonBusy(button, false); + button.focus(); + } + } catch (error) { + showToast( + error instanceof ApiError ? error.message : "加载用户失败", + "error", + ); + setButtonBusy(button, false); + } + }); } async function renderUserDetail( diff --git a/admin-web/src/test/pages.test.ts b/admin-web/src/test/pages.test.ts index 7ef7681..b2dc28a 100644 --- a/admin-web/src/test/pages.test.ts +++ b/admin-web/src/test/pages.test.ts @@ -15,6 +15,7 @@ const userSummary: UserSummary = { displayName: "测试用户", status: "active", creditBalance: 120, + consumedCredits: 18, createdAt: "2026-08-01T08:00:00Z", }; const userDetail: UserDetail = { @@ -45,6 +46,37 @@ afterEach(() => { }); describe("用户页", () => { + it("进入页面自动显示积分、注册时间并按游标加载更多用户", async () => { + const secondUser: UserSummary = { + ...userSummary, + userId: "22222222-2222-4222-8222-222222222222", + displayName: "第二位用户", + creditBalance: 80, + consumedCredits: 40, + }; + vi.spyOn(adminApi, "users") + .mockResolvedValueOnce({ items: [userSummary], nextCursor: "user-next" }) + .mockResolvedValueOnce({ items: [secondUser] }); + const container = document.createElement("main"); + document.body.append(container); + + renderUsers(container, "SUPPORT"); + await vi.waitFor(() => { + expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(1); + }); + expect(container.textContent).toContain("累计使用积分"); + expect(container.textContent).toContain("当前积分"); + expect(container.textContent).toContain("18"); + expect(container.textContent).toContain("120"); + + container.querySelector("[data-user-more]")?.click(); + await vi.waitFor(() => { + expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next"); + expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(2); + }); + expect(container.querySelector("[data-user-more]")).toBeNull(); + }); + it("支持人员可查看详情但不显示人工赠送", async () => { mockUserRequests(); vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] }); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e2b871b..52f5f0c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -549,7 +549,7 @@ paths: security: - adminMtls: [] adminSession: [] - summary: List users or search by exact internal user ID + summary: List users or search by full or 8-character internal user ID suffix parameters: - name: q in: query @@ -959,12 +959,13 @@ components: AdminUserSummary: type: object additionalProperties: false - required: [userId, displayName, status, creditBalance, createdAt] + required: [userId, displayName, status, creditBalance, consumedCredits, createdAt] properties: userId: { type: string, format: uuid } displayName: { type: string } status: { type: string, enum: [active, suspended, closed] } creditBalance: { type: integer, format: int64, minimum: 0 } + consumedCredits: { type: integer, format: int64, minimum: 0 } createdAt: { type: string, format: date-time } AdminUserPage: type: object @@ -991,6 +992,7 @@ components: - displayName - status - creditBalance + - consumedCredits - createdAt - qualifiedUsage - usage @@ -1000,6 +1002,7 @@ components: displayName: { type: string } status: { type: string, enum: [active, suspended, closed] } creditBalance: { type: integer, format: int64, minimum: 0 } + consumedCredits: { type: integer, format: int64, minimum: 0 } createdAt: { type: string, format: date-time } lastActiveAt: { type: ["string", "null"], format: date-time } qualifiedUsage: { type: boolean } diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt index ae45f1a..6ba29f6 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -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, diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt index 3962345..1560384 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt @@ -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 + suspend fun findByIdSuffix(suffix: String, limit: Int): List + 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 = 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() } diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt index dbfb850..dfa4c8a 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt @@ -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}" diff --git a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt index 2977651..fc9f57c 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt @@ -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 = + 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() }