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:
@@ -165,7 +165,7 @@ export const adminApi = {
|
||||
referrals: (range: string) =>
|
||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||
|
||||
users: (search: string, cursor?: string) =>
|
||||
users: (search = "", cursor?: string) =>
|
||||
request<PageResult<UserSummary>>(
|
||||
`/users${query({ q: search.trim(), cursor })}`,
|
||||
),
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface UserSummary {
|
||||
maskedEmail?: string;
|
||||
status: "active" | "suspended" | "closed";
|
||||
creditBalance: number;
|
||||
consumedCredits: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<div class="eyebrow">账户管理</div>
|
||||
<h1>用户查询</h1>
|
||||
<p>按内部用户 ID 精确查询,不展示 Apple 身份标识。</p>
|
||||
<h1>用户列表</h1>
|
||||
<p>按注册时间查看全部用户,支持完整或后 8 位内部用户 ID 查询。</p>
|
||||
</div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
@@ -37,11 +37,11 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
|
||||
<label class="search-box">
|
||||
<span class="sr-only">内部用户 ID</span>
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input name="query" type="search" placeholder="输入完整内部用户 ID" autocomplete="off" maxlength="36" required />
|
||||
<input name="query" type="search" placeholder="输入完整或后 8 位用户 ID" autocomplete="off" maxlength="36" />
|
||||
</label>
|
||||
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
|
||||
</form>
|
||||
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
|
||||
<div data-results>${renderEmpty("正在加载用户列表")}</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@@ -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<HTMLElement>("[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 = `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<caption class="sr-only">用户查询结果</caption>
|
||||
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">积分余额</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
|
||||
<tbody>
|
||||
${page.items.map(userRow).join("")}
|
||||
</tbody>
|
||||
<caption class="sr-only">${search ? "用户查询结果" : "全部用户"}</caption>
|
||||
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">累计使用积分</th><th scope="col">当前积分</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
|
||||
<tbody data-user-body>${userRows(page.items)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${
|
||||
page.nextCursor
|
||||
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-user-more>加载更多用户</wa-button></div>'
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
results.querySelectorAll<HTMLButtonElement>("[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) => `
|
||||
<tr>
|
||||
<td>
|
||||
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
|
||||
<div class="subtle mono">${escapeHtml(user.userId)}</div>
|
||||
</td>
|
||||
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
|
||||
<td>${formatNumber(user.consumedCredits)}</td>
|
||||
<td>${formatNumber(user.creditBalance)}</td>
|
||||
<td>${formatDateTime(user.createdAt)}</td>
|
||||
<td><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-button></td>
|
||||
</tr>
|
||||
`;
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function bindUserRows(
|
||||
scope: HTMLElement,
|
||||
container: HTMLElement,
|
||||
role: AdminRole,
|
||||
): void {
|
||||
scope.addEventListener("click", (event) => {
|
||||
const button = (event.target as Element).closest<HTMLButtonElement>(
|
||||
"[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<HTMLButtonElement>("[data-user-more]");
|
||||
const body = container.querySelector<HTMLTableSectionElement>("[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(
|
||||
|
||||
@@ -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<HTMLButtonElement>("[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: [] });
|
||||
|
||||
+5
-2
@@ -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 }
|
||||
|
||||
@@ -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