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:
@@ -173,6 +173,9 @@ export const adminApi = {
|
||||
user: (userId: string) =>
|
||||
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
||||
|
||||
latestLedger: (cursor?: string) =>
|
||||
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
|
||||
|
||||
ledger: (userId: string, cursor?: string) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
|
||||
|
||||
@@ -100,6 +100,7 @@ export type LedgerEntryType =
|
||||
|
||||
export interface LedgerEntry {
|
||||
entryId: string;
|
||||
userId: string;
|
||||
type: LedgerEntryType;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { adminApi } from "../api/client";
|
||||
import { renderEmpty, renderError, renderLoading } from "../components/ui";
|
||||
import { adminApi, ApiError } from "../api/client";
|
||||
import type { LedgerEntry } from "../api/types";
|
||||
import {
|
||||
renderEmpty,
|
||||
renderError,
|
||||
renderLoading,
|
||||
setButtonBusy,
|
||||
showToast,
|
||||
} from "../components/ui";
|
||||
import {
|
||||
escapeHtml,
|
||||
formatDateTime,
|
||||
@@ -14,7 +21,7 @@ export function renderCredits(container: HTMLElement): void {
|
||||
<div>
|
||||
<div class="eyebrow">积分账本</div>
|
||||
<h1>积分流水</h1>
|
||||
<p>按内部用户 ID 查询不可变积分流水。</p>
|
||||
<p>查看最新不可变积分流水,也可按完整内部用户 ID 查询。</p>
|
||||
</div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
@@ -22,11 +29,11 @@ export function renderCredits(container: HTMLElement): void {
|
||||
<label class="search-box">
|
||||
<span class="sr-only">内部用户 ID</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>
|
||||
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
|
||||
</form>
|
||||
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div>
|
||||
<div data-ledger-results>${renderEmpty("正在加载最新积分流水")}</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@@ -34,45 +41,103 @@ export function renderCredits(container: HTMLElement): void {
|
||||
form?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
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]");
|
||||
if (!results) return;
|
||||
renderLoading(results, "加载积分流水");
|
||||
try {
|
||||
const page = await adminApi.ledger(userId);
|
||||
const page = userId
|
||||
? await adminApi.ledger(userId)
|
||||
: await adminApi.latestLedger();
|
||||
results.innerHTML =
|
||||
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">
|
||||
<table>
|
||||
<caption class="sr-only">用户 ${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>
|
||||
<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>
|
||||
<caption class="sr-only">${userId ? `用户 ${escapeHtml(userId)} 的` : "最新"}积分流水</caption>
|
||||
<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 data-ledger-body>${ledgerRows(page.items)}</tbody>
|
||||
</table>
|
||||
</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) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,13 +90,17 @@ describe("adminApi", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await adminApi.latestLedger("latest+/=");
|
||||
await adminApi.ledger("user/with space", "ledger+/=");
|
||||
await adminApi.operators("operator+/=");
|
||||
|
||||
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(
|
||||
"/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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
UserSummary,
|
||||
} from "../api/types";
|
||||
import { renderSecurity } from "../pages/security";
|
||||
import { renderCredits } from "../pages/credits";
|
||||
import { renderUsers } from "../pages/users";
|
||||
|
||||
const userId = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -96,6 +97,7 @@ describe("用户页", () => {
|
||||
items: [
|
||||
{
|
||||
entryId: "ledger-1",
|
||||
userId,
|
||||
type: "grant",
|
||||
amount: 100,
|
||||
balanceAfter: 100,
|
||||
@@ -109,6 +111,7 @@ describe("用户页", () => {
|
||||
items: [
|
||||
{
|
||||
entryId: "ledger-2",
|
||||
userId,
|
||||
type: "settle",
|
||||
amount: -18,
|
||||
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("安全中心", () => {
|
||||
it("按后端 nextCursor 加载更多管理员", async () => {
|
||||
const firstOperator = operator("operator-1", "owner");
|
||||
|
||||
+21
-1
@@ -604,6 +604,25 @@ paths:
|
||||
"400": { description: Cursor is malformed }
|
||||
"403": { description: ANALYST role cannot access credit ledger records }
|
||||
"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:
|
||||
post:
|
||||
security:
|
||||
@@ -1015,9 +1034,10 @@ components:
|
||||
AdminLedgerEntry:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [entryId, type, amount, balanceAfter, reasonCode, createdAt]
|
||||
required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt]
|
||||
properties:
|
||||
entryId: { type: string, format: uuid }
|
||||
userId: { type: string, format: uuid }
|
||||
type: { type: string, enum: [grant, reserve, settle, refund, adjustment] }
|
||||
amount: { type: integer, format: int64 }
|
||||
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") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
@@ -667,6 +694,7 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
|
||||
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
||||
AdminLedgerResponse(
|
||||
entryId = id,
|
||||
userId = userId,
|
||||
type = when (type) {
|
||||
"USAGE_RESERVE" -> "reserve"
|
||||
"USAGE_SETTLE" -> "settle"
|
||||
@@ -864,6 +892,7 @@ private data class AdminUserDetailResponse(
|
||||
@Serializable
|
||||
private data class AdminLedgerResponse(
|
||||
val entryId: String,
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val amount: Long,
|
||||
val balanceAfter: Long,
|
||||
|
||||
@@ -26,6 +26,7 @@ data class AdminUserPageDto(
|
||||
@Serializable
|
||||
data class AdminUserLedgerEntryDto(
|
||||
val id: String,
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val amountDelta: Long,
|
||||
val balanceAfter: Long,
|
||||
|
||||
+28
@@ -47,6 +47,11 @@ interface AdminUsersRepository {
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto>
|
||||
|
||||
suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto>
|
||||
}
|
||||
|
||||
class ExposedAdminUsersRepository(
|
||||
@@ -173,6 +178,28 @@ class ExposedAdminUsersRepository(
|
||||
.limit(limit)
|
||||
.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(
|
||||
@@ -324,6 +351,7 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
|
||||
|
||||
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
userId = userId.toString(),
|
||||
type = type.name,
|
||||
amountDelta = amountDelta,
|
||||
balanceAfter = balanceAfter,
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
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.AdminUserLedgerEntryDto
|
||||
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.repositories.AdminUserCursor
|
||||
@@ -67,11 +68,27 @@ class AdminUsersService(
|
||||
userId: UUID,
|
||||
limit: Int = 50,
|
||||
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 {
|
||||
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
|
||||
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
|
||||
if (!repository.exists(userId)) throw AdminUserNotFoundException()
|
||||
val results = repository.listLedger(userId, limit + 1, decodedCursor)
|
||||
val results = load(limit + 1, decodedCursor)
|
||||
val hasMore = results.size > limit
|
||||
val items = results.take(limit)
|
||||
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(
|
||||
"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 "REFERENCES accounts (id) ON DELETE CASCADE"
|
||||
referralMigration shouldContain "inviter_reward_credits = 1000"
|
||||
referralMigration shouldContain "invitee_reward_credits = 1000"
|
||||
ledgerTimelineMigration shouldContain "ON credit_ledger (created_at, id)"
|
||||
|
||||
listOf(
|
||||
root.read("src/main/resources/application.yaml"),
|
||||
@@ -234,6 +238,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
||||
"/v1/admin/users",
|
||||
"/v1/admin/users/{userId}",
|
||||
"/v1/admin/users/{userId}/ledger",
|
||||
"/v1/admin/credits/ledger",
|
||||
"/v1/admin/credits/grants",
|
||||
"/v1/admin/operators/summary",
|
||||
"/v1/admin/operators",
|
||||
|
||||
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
|
||||
|
||||
test("runtime grants cover every migrated table without mutable history privileges") {
|
||||
val grants = root.read("deploy/smoke/runtime-grants.sql")
|
||||
val migrationTables = (1..12)
|
||||
val migrationTables = (1..13)
|
||||
.flatMap { version ->
|
||||
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
|
||||
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.AdminSessionService
|
||||
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.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
@@ -199,6 +201,40 @@ class AdminRoutesTest {
|
||||
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
|
||||
fun `operator creation maps normalized username conflict to 409`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
|
||||
@@ -256,6 +292,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
grantService: AdminGrantService = mockk(relaxed = true),
|
||||
operatorService: AdminOperatorService = mockk(relaxed = true),
|
||||
auditService: AdminAuditService = mockk(relaxed = true),
|
||||
usersService: AdminUsersService = mockk(relaxed = true),
|
||||
) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { explicitNulls = false })
|
||||
@@ -276,7 +313,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
authService = authService,
|
||||
sessionService = sessionService,
|
||||
statsService = mockk<AdminStatsService>(relaxed = true),
|
||||
usersService = mockk<AdminUsersService>(relaxed = true),
|
||||
usersService = usersService,
|
||||
grantService = grantService,
|
||||
operatorService = operatorService,
|
||||
auditService = auditService,
|
||||
|
||||
@@ -109,6 +109,42 @@ class AdminUsersServiceTest : FunSpec({
|
||||
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") {
|
||||
val service = AdminUsersService(PagingUsersRepository(emptyList()))
|
||||
|
||||
@@ -241,6 +277,26 @@ private class PagingUsersRepository(
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
)
|
||||
.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(
|
||||
@@ -259,8 +315,10 @@ private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
|
||||
private fun ledgerEntry(
|
||||
id: UUID,
|
||||
createdAt: Instant,
|
||||
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
|
||||
) = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
userId = userId.toString(),
|
||||
type = "MANUAL_GRANT",
|
||||
amountDelta = 10,
|
||||
balanceAfter = 10,
|
||||
|
||||
Reference in New Issue
Block a user