Show latest credit ledger in admin

Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
This commit is contained in:
Rocky
2026-08-19 18:05:54 +08:00
parent 58445dd880
commit 75c046d91d
15 changed files with 356 additions and 34 deletions
+3
View File
@@ -173,6 +173,9 @@ export const adminApi = {
user: (userId: string) => user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`), request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
latestLedger: (cursor?: string) =>
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
ledger: (userId: string, cursor?: string) => ledger: (userId: string, cursor?: string) =>
request<PageResult<LedgerEntry>>( request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`, `/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
+1
View File
@@ -100,6 +100,7 @@ export type LedgerEntryType =
export interface LedgerEntry { export interface LedgerEntry {
entryId: string; entryId: string;
userId: string;
type: LedgerEntryType; type: LedgerEntryType;
amount: number; amount: number;
balanceAfter: number; balanceAfter: number;
+93 -28
View File
@@ -1,5 +1,12 @@
import { adminApi } from "../api/client"; import { adminApi, ApiError } from "../api/client";
import { renderEmpty, renderError, renderLoading } from "../components/ui"; import type { LedgerEntry } from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { import {
escapeHtml, escapeHtml,
formatDateTime, formatDateTime,
@@ -14,7 +21,7 @@ export function renderCredits(container: HTMLElement): void {
<div> <div>
<div class="eyebrow">积分账本</div> <div class="eyebrow">积分账本</div>
<h1>积分流水</h1> <h1>积分流水</h1>
<p>内部用户 ID 查询不可变积分流水。</p> <p>查看最新不可变积分流水,也可按完整内部用户 ID 查询。</p>
</div> </div>
</div> </div>
<section class="panel"> <section class="panel">
@@ -22,11 +29,11 @@ export function renderCredits(container: HTMLElement): void {
<label class="search-box"> <label class="search-box">
<span class="sr-only">内部用户 ID</span> <span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span> <span aria-hidden="true">⌕</span>
<input name="userId" type="search" placeholder="输入完整用户 ID" autocomplete="off" maxlength="36" required /> <input name="userId" type="search" placeholder="输入完整用户 ID,留空查看全部" autocomplete="off" maxlength="36" />
</label> </label>
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button> <wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
</form> </form>
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div> <div data-ledger-results>${renderEmpty("正在加载最新积分流水")}</div>
</section> </section>
`; `;
@@ -34,45 +41,103 @@ export function renderCredits(container: HTMLElement): void {
form?.addEventListener("submit", (event) => { form?.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const userId = new FormData(form).get("userId")?.toString().trim() ?? ""; const userId = new FormData(form).get("userId")?.toString().trim() ?? "";
if (userId) void loadLedger(container, userId); void loadLedger(container, userId || undefined);
}); });
void loadLedger(container);
} }
async function loadLedger(container: HTMLElement, userId: string): Promise<void> { async function loadLedger(
container: HTMLElement,
userId?: string,
): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-ledger-results]"); const results = container.querySelector<HTMLElement>("[data-ledger-results]");
if (!results) return; if (!results) return;
renderLoading(results, "加载积分流水"); renderLoading(results, "加载积分流水");
try { try {
const page = await adminApi.ledger(userId); const page = userId
? await adminApi.ledger(userId)
: await adminApi.latestLedger();
results.innerHTML = results.innerHTML =
page.items.length === 0 page.items.length === 0
? renderEmpty("该用户暂无积分流水") ? renderEmpty(userId ? "该用户暂无积分流水" : "暂无积分流水")
: ` : `
<div class="result-summary">用户 <span class="mono">${escapeHtml(userId)}</span> · ${formatNumber(page.items.length)} 条记录</div> <div class="result-summary">${
userId
? `用户 <span class="mono">${escapeHtml(userId)}</span>`
: "最新积分流水"
} · ${formatNumber(page.items.length)} 条记录</div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<caption class="sr-only">用户 ${escapeHtml(userId)} 的积分流水</caption> <caption class="sr-only">${userId ? `用户 ${escapeHtml(userId)}` : "最新"}积分流水</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> <thead><tr><th scope="col">时间</th><th scope="col">用户 ID</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody> <tbody data-ledger-body>${ledgerRows(page.items)}</tbody>
${page.items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table> </table>
</div> </div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-ledger-more>加载更多流水</wa-button></div>'
: ""
}
`; `;
if (page.nextCursor) {
bindLedgerPagination(container, userId, page.nextCursor);
}
} catch (error) { } catch (error) {
renderError(results, error, () => void loadLedger(container, userId)); renderError(results, error, () => void loadLedger(container, userId));
} }
} }
function ledgerRows(entries: LedgerEntry[]): string {
return entries
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.userId)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("");
}
function bindLedgerPagination(
container: HTMLElement,
userId: string | undefined,
initialCursor: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = userId
? await adminApi.ledger(userId, cursor)
: await adminApi.latestLedger(cursor);
body.insertAdjacentHTML("beforeend", ledgerRows(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);
}
});
}
+5 -1
View File
@@ -90,13 +90,17 @@ describe("adminApi", () => {
}); });
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
await adminApi.latestLedger("latest+/=");
await adminApi.ledger("user/with space", "ledger+/="); await adminApi.ledger("user/with space", "ledger+/=");
await adminApi.operators("operator+/="); await adminApi.operators("operator+/=");
expect(fetchMock.mock.calls[0]?.[0]).toBe( expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D", "/v1/admin/credits/ledger?cursor=latest%2B%2F%3D",
); );
expect(fetchMock.mock.calls[1]?.[0]).toBe( expect(fetchMock.mock.calls[1]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
"/v1/admin/operators?cursor=operator%2B%2F%3D", "/v1/admin/operators?cursor=operator%2B%2F%3D",
); );
}); });
+52
View File
@@ -7,6 +7,7 @@ import type {
UserSummary, UserSummary,
} from "../api/types"; } from "../api/types";
import { renderSecurity } from "../pages/security"; import { renderSecurity } from "../pages/security";
import { renderCredits } from "../pages/credits";
import { renderUsers } from "../pages/users"; import { renderUsers } from "../pages/users";
const userId = "11111111-1111-4111-8111-111111111111"; const userId = "11111111-1111-4111-8111-111111111111";
@@ -96,6 +97,7 @@ describe("用户页", () => {
items: [ items: [
{ {
entryId: "ledger-1", entryId: "ledger-1",
userId,
type: "grant", type: "grant",
amount: 100, amount: 100,
balanceAfter: 100, balanceAfter: 100,
@@ -109,6 +111,7 @@ describe("用户页", () => {
items: [ items: [
{ {
entryId: "ledger-2", entryId: "ledger-2",
userId,
type: "settle", type: "settle",
amount: -18, amount: -18,
balanceAfter: 82, balanceAfter: 82,
@@ -147,6 +150,55 @@ describe("用户页", () => {
}); });
}); });
describe("积分流水页", () => {
it("进入页面自动显示最新流水并支持继续加载", async () => {
vi.spyOn(adminApi, "latestLedger")
.mockResolvedValueOnce({
items: [
{
entryId: "latest-ledger-1",
userId,
type: "settle",
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
createdAt: "2026-08-19T09:00:00Z",
},
],
nextCursor: "latest-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "latest-ledger-2",
userId: "22222222-2222-4222-8222-222222222222",
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-19T08:00:00Z",
},
],
});
const container = document.createElement("main");
document.body.append(container);
renderCredits(container);
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(1);
});
expect(container.textContent).toContain(userId);
expect(container.textContent).toContain("USAGE_SETTLE");
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.latestLedger).toHaveBeenNthCalledWith(2, "latest-next");
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-ledger-more]")).toBeNull();
});
});
describe("安全中心", () => { describe("安全中心", () => {
it("按后端 nextCursor 加载更多管理员", async () => { it("按后端 nextCursor 加载更多管理员", async () => {
const firstOperator = operator("operator-1", "owner"); const firstOperator = operator("operator-1", "owner");
+21 -1
View File
@@ -604,6 +604,25 @@ paths:
"400": { description: Cursor is malformed } "400": { description: Cursor is malformed }
"403": { description: ANALYST role cannot access credit ledger records } "403": { description: ANALYST role cannot access credit ledger records }
"404": { description: User was not found } "404": { description: User was not found }
/v1/admin/credits/ledger:
get:
security:
- adminMtls: []
adminSession: []
summary: Return the latest immutable credit ledger entries across users
parameters:
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Latest credit ledger entries ordered by creation time and entry ID
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
"400": { description: Cursor is malformed }
"403": { description: ANALYST role cannot access credit ledger records }
/v1/admin/credits/grants: /v1/admin/credits/grants:
post: post:
security: security:
@@ -1015,9 +1034,10 @@ components:
AdminLedgerEntry: AdminLedgerEntry:
type: object type: object
additionalProperties: false additionalProperties: false
required: [entryId, type, amount, balanceAfter, reasonCode, createdAt] required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt]
properties: properties:
entryId: { type: string, format: uuid } entryId: { type: string, format: uuid }
userId: { type: string, format: uuid }
type: { type: string, enum: [grant, reserve, settle, refund, adjustment] } type: { type: string, enum: [grant, reserve, settle, refund, adjustment] }
amount: { type: integer, format: int64 } amount: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 } balanceAfter: { type: integer, format: int64, minimum: 0 }
@@ -237,6 +237,33 @@ fun Route.adminApiRoutes(
} }
} }
get("/credits/ledger") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
try {
val page = usersService.latestLedger(
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
call.respond(
PageResponse(
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
page.nextCursor,
),
)
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
}
}
post("/credits/grants") { post("/credits/grants") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
if (principal.role != AdminRole.SUPER_ADMIN) { if (principal.role != AdminRole.SUPER_ADMIN) {
@@ -667,6 +694,7 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse = private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse( AdminLedgerResponse(
entryId = id, entryId = id,
userId = userId,
type = when (type) { type = when (type) {
"USAGE_RESERVE" -> "reserve" "USAGE_RESERVE" -> "reserve"
"USAGE_SETTLE" -> "settle" "USAGE_SETTLE" -> "settle"
@@ -864,6 +892,7 @@ private data class AdminUserDetailResponse(
@Serializable @Serializable
private data class AdminLedgerResponse( private data class AdminLedgerResponse(
val entryId: String, val entryId: String,
val userId: String,
val type: String, val type: String,
val amount: Long, val amount: Long,
val balanceAfter: Long, val balanceAfter: Long,
@@ -26,6 +26,7 @@ data class AdminUserPageDto(
@Serializable @Serializable
data class AdminUserLedgerEntryDto( data class AdminUserLedgerEntryDto(
val id: String, val id: String,
val userId: String,
val type: String, val type: String,
val amountDelta: Long, val amountDelta: Long,
val balanceAfter: Long, val balanceAfter: Long,
@@ -47,6 +47,11 @@ interface AdminUsersRepository {
limit: Int, limit: Int,
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> ): List<AdminUserLedgerEntryDto>
suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto>
} }
class ExposedAdminUsersRepository( class ExposedAdminUsersRepository(
@@ -173,6 +178,28 @@ class ExposedAdminUsersRepository(
.limit(limit) .limit(limit)
.map { it.toUserLedgerRow().toDto() } .map { it.toUserLedgerRow().toDto() }
} }
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (cursor != null) {
query.where {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
}
} }
private data class UserSupportRows( private data class UserSupportRows(
@@ -324,6 +351,7 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto( private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
id = id.toString(), id = id.toString(),
userId = userId.toString(),
type = type.name, type = type.name,
amountDelta = amountDelta, amountDelta = amountDelta,
balanceAfter = balanceAfter, balanceAfter = balanceAfter,
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.users.services package com.osglab.account.features.admin.users.services
import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor import com.osglab.account.features.admin.users.repositories.AdminUserCursor
@@ -67,11 +68,27 @@ class AdminUsersService(
userId: UUID, userId: UUID,
limit: Int = 50, limit: Int = 50,
cursor: String? = null, cursor: String? = null,
): AdminUserLedgerPageDto {
return ledgerPage(limit, cursor) { pageSize, decodedCursor ->
if (!repository.exists(userId)) throw AdminUserNotFoundException()
repository.listLedger(userId, pageSize, decodedCursor)
}
}
suspend fun latestLedger(
limit: Int = 100,
cursor: String? = null,
): AdminUserLedgerPageDto =
ledgerPage(limit, cursor, repository::listLatestLedger)
private suspend fun ledgerPage(
limit: Int,
cursor: String?,
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto { ): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" } require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode) val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
if (!repository.exists(userId)) throw AdminUserNotFoundException() val results = load(limit + 1, decodedCursor)
val results = repository.listLedger(userId, limit + 1, decodedCursor)
val hasMore = results.size > limit val hasMore = results.size > limit
val items = results.take(limit) val items = results.take(limit)
val nextCursor = if (hasMore) { val nextCursor = if (hasMore) {
@@ -0,0 +1,2 @@
CREATE INDEX idx_credit_ledger_created
ON credit_ledger (created_at, id);
@@ -84,10 +84,14 @@ class DeploymentConsistencyTest : FunSpec({
val referralMigration = root.read( val referralMigration = root.read(
"src/main/resources/db/migration/V12__align_referral_rewards.sql", "src/main/resources/db/migration/V12__align_referral_rewards.sql",
) )
val ledgerTimelineMigration = root.read(
"src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql",
)
profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL" profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL"
profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE" profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
referralMigration shouldContain "inviter_reward_credits = 1000" referralMigration shouldContain "inviter_reward_credits = 1000"
referralMigration shouldContain "invitee_reward_credits = 1000" referralMigration shouldContain "invitee_reward_credits = 1000"
ledgerTimelineMigration shouldContain "ON credit_ledger (created_at, id)"
listOf( listOf(
root.read("src/main/resources/application.yaml"), root.read("src/main/resources/application.yaml"),
@@ -234,6 +238,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/users", "/v1/admin/users",
"/v1/admin/users/{userId}", "/v1/admin/users/{userId}",
"/v1/admin/users/{userId}/ledger", "/v1/admin/users/{userId}/ledger",
"/v1/admin/credits/ledger",
"/v1/admin/credits/grants", "/v1/admin/credits/grants",
"/v1/admin/operators/summary", "/v1/admin/operators/summary",
"/v1/admin/operators", "/v1/admin/operators",
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") { test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql") val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..12) val migrationTables = (1..13)
.flatMap { version -> .flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths -> val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") } paths.filter { it.fileName.toString().startsWith("V${version}__") }
@@ -12,6 +12,8 @@ import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminStatsService import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditNotFound
@@ -199,6 +201,40 @@ class AdminRoutesTest {
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION"""" response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
} }
@Test
fun `support can load latest credit ledger across users`() = testApplication {
val usersService = mockk<AdminUsersService>()
coEvery { usersService.latestLedger(100, null) } returns AdminUserLedgerPageDto(
items = listOf(
AdminUserLedgerEntryDto(
id = "ffffffff-ffff-ffff-ffff-ffffffffffff",
userId = "11111111-1111-4111-8111-111111111111",
type = "USAGE_SETTLE",
amountDelta = -18,
balanceAfter = 102,
referenceId = null,
createdAt = "2026-08-19T09:00:00Z",
),
),
nextCursor = null,
)
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPPORT),
usersService = usersService,
)
}
val response = client.get("/v1/admin/credits/ledger") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE""""
}
@Test @Test
fun `operator creation maps normalized username conflict to 409`() = testApplication { fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN) val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
@@ -256,6 +292,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
grantService: AdminGrantService = mockk(relaxed = true), grantService: AdminGrantService = mockk(relaxed = true),
operatorService: AdminOperatorService = mockk(relaxed = true), operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true), auditService: AdminAuditService = mockk(relaxed = true),
usersService: AdminUsersService = mockk(relaxed = true),
) { ) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { explicitNulls = false }) json(Json { explicitNulls = false })
@@ -276,7 +313,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
authService = authService, authService = authService,
sessionService = sessionService, sessionService = sessionService,
statsService = mockk<AdminStatsService>(relaxed = true), statsService = mockk<AdminStatsService>(relaxed = true),
usersService = mockk<AdminUsersService>(relaxed = true), usersService = usersService,
grantService = grantService, grantService = grantService,
operatorService = operatorService, operatorService = operatorService,
auditService = auditService, auditService = auditService,
@@ -109,6 +109,42 @@ class AdminUsersServiceTest : FunSpec({
second.nextCursor shouldBe null second.nextCursor shouldBe null
} }
test("latest ledger combines users in newest-first order with pagination") {
val firstUserId = UUID.randomUUID()
val secondUserId = UUID.randomUUID()
val newest = ledgerEntry(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
Instant.parse("2026-08-15T03:00:00Z"),
firstUserId,
)
val middle = ledgerEntry(
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
Instant.parse("2026-08-15T02:00:00Z"),
secondUserId,
)
val oldest = ledgerEntry(
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
Instant.parse("2026-08-15T01:00:00Z"),
firstUserId,
)
val service = AdminUsersService(
PagingUsersRepository(
users = emptyList(),
ledger = mapOf(
firstUserId to listOf(newest, oldest),
secondUserId to listOf(middle),
),
),
)
val first = service.latestLedger(limit = 2)
val second = service.latestLedger(limit = 2, cursor = first.nextCursor.shouldNotBeNull())
first.items shouldBe listOf(newest, middle)
second.items shouldBe listOf(oldest)
second.nextCursor shouldBe null
}
test("invalid ledger cursor throws a stable illegal argument exception") { test("invalid ledger cursor throws a stable illegal argument exception") {
val service = AdminUsersService(PagingUsersRepository(emptyList())) val service = AdminUsersService(PagingUsersRepository(emptyList()))
@@ -241,6 +277,26 @@ private class PagingUsersRepository(
.thenByDescending(AdminUserLedgerEntryDto::id), .thenByDescending(AdminUserLedgerEntryDto::id),
) )
.take(limit) .take(limit)
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> =
ledger.values.flatten()
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
)
.take(limit)
} }
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto( private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
@@ -259,8 +315,10 @@ private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
private fun ledgerEntry( private fun ledgerEntry(
id: UUID, id: UUID,
createdAt: Instant, createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
) = AdminUserLedgerEntryDto( ) = AdminUserLedgerEntryDto(
id = id.toString(), id = id.toString(),
userId = userId.toString(),
type = "MANUAL_GRANT", type = "MANUAL_GRANT",
amountDelta = 10, amountDelta = 10,
balanceAfter = 10, balanceAfter = 10,