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) =>
|
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 })}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+28
@@ -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,
|
||||||
|
|||||||
+19
-2
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user