Merge pull request #1162 from pikasTech/feat/1127-admin-billing-summary
feat(v0.3): 管理员账务概览接入 user-billing
This commit is contained in:
@@ -319,6 +319,11 @@ async function handleRestAdapter(request, response, url, options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/v1/admin/billing/summary" && request.method === "GET") {
|
||||||
|
await handleAdminBillingSummaryHttp(request, response, url, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method === "GET" && url.pathname === "/v1") {
|
if (request.method === "GET" && url.pathname === "/v1") {
|
||||||
const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
|
const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
|
||||||
const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
||||||
@@ -983,6 +988,62 @@ async function handleUsageSummaryHttp(request, response, url, options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAdminBillingSummaryHttp(request, response, url, options) {
|
||||||
|
const auth = await options.accessController.authenticate(request, { required: true });
|
||||||
|
if (!auth.ok) {
|
||||||
|
sendJson(response, auth.status, auth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (auth.actor?.role !== "admin") {
|
||||||
|
sendRestError(request, response, 403, "admin_required", "Only admin users can read billing summaries", {
|
||||||
|
operation: "GET /v1/admin/billing/summary",
|
||||||
|
target: { type: "route", id: "/v1/admin/billing/summary" },
|
||||||
|
result: "rejected"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
||||||
|
if (!client?.configured || typeof client.adminBillingSummary !== "function") {
|
||||||
|
sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin summary is not configured", {
|
||||||
|
operation: "GET /v1/admin/billing/summary",
|
||||||
|
target: { type: "service", id: "hwlab-user-billing" }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const limit = parsePositiveInteger(url.searchParams.get("limit"), 50);
|
||||||
|
const result = await client.adminBillingSummary({ limit: Math.min(limit, 100) });
|
||||||
|
if (!result.ok) {
|
||||||
|
sendRestError(request, response, result.status ?? 502, result.error?.code ?? "user_billing_admin_summary_failed", result.error?.message ?? "user-billing admin summary request failed", {
|
||||||
|
operation: "GET /v1/admin/billing/summary",
|
||||||
|
target: { type: "service", id: "hwlab-user-billing" },
|
||||||
|
reason: result.error?.code,
|
||||||
|
result: "failed"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(response, 200, {
|
||||||
|
...(result.body ?? {}),
|
||||||
|
actor: adminActorSummary(auth.actor),
|
||||||
|
proxy: {
|
||||||
|
serviceId: CLOUD_API_SERVICE_ID,
|
||||||
|
route: "/v1/admin/billing/summary",
|
||||||
|
source: "hwlab-user-billing",
|
||||||
|
valuesRedacted: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminActorSummary(actor) {
|
||||||
|
return actor ? {
|
||||||
|
id: String(actor.id ?? ""),
|
||||||
|
username: String(actor.username ?? ""),
|
||||||
|
displayName: String(actor.displayName ?? ""),
|
||||||
|
role: String(actor.role ?? ""),
|
||||||
|
status: String(actor.status ?? ""),
|
||||||
|
valuesRedacted: true
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
function bearerTokenFromRequest(request) {
|
function bearerTokenFromRequest(request) {
|
||||||
const header = String(getHeader(request, "authorization") ?? "").trim();
|
const header = String(getHeader(request, "authorization") ?? "").trim();
|
||||||
return header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
return header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch }
|
|||||||
async billingSummary(token, { limit = 20 } = {}) {
|
async billingSummary(token, { limit = 20 } = {}) {
|
||||||
const query = new URLSearchParams({ limit: String(limit) });
|
const query = new URLSearchParams({ limit: String(limit) });
|
||||||
return requestJson(`/v1/billing/summary?${query.toString()}`, { method: "GET", bearerToken: token });
|
return requestJson(`/v1/billing/summary?${query.toString()}`, { method: "GET", bearerToken: token });
|
||||||
|
},
|
||||||
|
async adminBillingSummary({ limit = 50 } = {}) {
|
||||||
|
const query = new URLSearchParams({ limit: String(limit) });
|
||||||
|
return requestJson(`/internal/admin/billing/summary?${query.toString()}`, { method: "GET" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ test("cloud api accepts user-billing API keys and records Code Agent billing usa
|
|||||||
valuesRedacted: true
|
valuesRedacted: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
async adminBillingSummary(options = {}) {
|
||||||
|
calls.push({ op: "adminSummary", options });
|
||||||
|
assert.equal(options.limit, 50);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
contractVersion: "user-billing-admin-summary-v1",
|
||||||
|
status: "ok",
|
||||||
|
count: 1,
|
||||||
|
limit: 50,
|
||||||
|
totals: { users: 1, activeUsers: 1, balanceCredits: 99, usageCredits: 1, activeReservations: 0 },
|
||||||
|
users: [{
|
||||||
|
user: { id: "usr_user_billing_agent", email: "agent@hwlab.local", username: "billing-agent", displayName: "Billing Agent", role: "user", status: "active" },
|
||||||
|
planId: "default",
|
||||||
|
credits: { balance: 99, reserved: 0, available: 99 },
|
||||||
|
usage: { totalCredits: 1, totalQuantity: 1200, recordCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" },
|
||||||
|
apiKeys: { activeCount: 1, totalCount: 1, lastUsedAt: "2026-06-13T12:00:00.000Z" },
|
||||||
|
reservations: { activeCount: 0, active: [] }
|
||||||
|
}],
|
||||||
|
state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } },
|
||||||
|
valuesRedacted: true
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,7 +182,11 @@ test("cloud api accepts user-billing API keys and records Code Agent billing usa
|
|||||||
assert.equal(summary.state.redis.role, "cache-only");
|
assert.equal(summary.state.redis.role, "cache-only");
|
||||||
assert.equal(summary.proxy.source, "hwlab-user-billing");
|
assert.equal(summary.proxy.source, "hwlab-user-billing");
|
||||||
assert.equal(JSON.stringify(summary).includes("hwl_user_billing_test_secret"), false);
|
assert.equal(JSON.stringify(summary).includes("hwl_user_billing_test_secret"), false);
|
||||||
assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect", "introspect", "summary"]);
|
|
||||||
|
const adminDenied = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: authHeader });
|
||||||
|
assert.equal(adminDenied.status, 403);
|
||||||
|
|
||||||
|
assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect", "introspect", "summary", "introspect"]);
|
||||||
assert.equal(JSON.stringify(result).includes("hwl_user_billing_test_secret"), false);
|
assert.equal(JSON.stringify(result).includes("hwl_user_billing_test_secret"), false);
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||||
@@ -179,3 +208,69 @@ async function pollUserBillingAgentResult(port, traceId, headers) {
|
|||||||
}
|
}
|
||||||
throw new Error(`Code Agent result did not complete: ${JSON.stringify(last)}`);
|
throw new Error(`Code Agent result did not complete: ${JSON.stringify(last)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("cloud api proxies admin billing summary for web admins without exposing user-billing secrets", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const userBillingClient = {
|
||||||
|
configured: true,
|
||||||
|
async adminBillingSummary(options = {}) {
|
||||||
|
calls.push({ op: "adminSummary", options });
|
||||||
|
assert.equal(options.limit, 50);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
contractVersion: "user-billing-admin-summary-v1",
|
||||||
|
status: "ok",
|
||||||
|
count: 1,
|
||||||
|
limit: 50,
|
||||||
|
totals: { users: 1, activeUsers: 1, balanceCredits: 25, reservedCredits: 5, availableCredits: 20, usageCredits: 3, activeReservations: 1 },
|
||||||
|
users: [{
|
||||||
|
user: { id: "usr_billing_admin_subject", email: "subject@hwlab.local", username: "billing-subject", displayName: "Billing Subject", role: "user", status: "active" },
|
||||||
|
planId: "default",
|
||||||
|
credits: { balance: 25, reserved: 5, available: 20 },
|
||||||
|
usage: { totalCredits: 3, totalQuantity: 2048, recordCount: 2, lastUsedAt: "2026-06-13T13:00:00.000Z" },
|
||||||
|
apiKeys: { activeCount: 1, totalCount: 2, lastUsedAt: "2026-06-13T12:30:00.000Z" },
|
||||||
|
reservations: { activeCount: 1, active: [{ reservationId: "res_admin_billing", serviceId: "hwlab-hwpod", estimatedCredits: 5, estimatedTokens: 0, status: "reserved", expiresAt: "2026-06-13T13:30:00.000Z", createdAt: "2026-06-13T13:00:00.000Z" }] }
|
||||||
|
}],
|
||||||
|
state: { stateless: true, stateAuthority: "pk01-postgres", redis: { role: "cache-only" } },
|
||||||
|
valuesRedacted: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const server = createCloudApiServer({
|
||||||
|
env: {
|
||||||
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
||||||
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
||||||
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-password-1127"
|
||||||
|
},
|
||||||
|
userBillingClient
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
try {
|
||||||
|
const { port } = server.address();
|
||||||
|
const login = await fetch(`http://127.0.0.1:${port}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "admin", password: "admin-password-1127" })
|
||||||
|
});
|
||||||
|
assert.equal(login.status, 200);
|
||||||
|
const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||||
|
assert.match(cookie, /^hwlab_session=/u);
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/summary`, { headers: { cookie } });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const summary = await response.json();
|
||||||
|
assert.equal(summary.contractVersion, "user-billing-admin-summary-v1");
|
||||||
|
assert.equal(summary.users[0].user.id, "usr_billing_admin_subject");
|
||||||
|
assert.equal(summary.users[0].credits.available, 20);
|
||||||
|
assert.equal(summary.users[0].reservations.active[0].serviceId, "hwlab-hwpod");
|
||||||
|
assert.equal(summary.state.stateAuthority, "pk01-postgres");
|
||||||
|
assert.equal(summary.state.redis.role, "cache-only");
|
||||||
|
assert.equal(summary.proxy.source, "hwlab-user-billing");
|
||||||
|
assert.equal(JSON.stringify(summary).includes("admin-password-1127"), false);
|
||||||
|
assert.deepEqual(calls.map((call) => call.op), ["adminSummary"]);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,6 +105,39 @@ type reservationSummaryRow struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminBillingUserSummary struct {
|
||||||
|
User User `json:"user"`
|
||||||
|
PlanID string `json:"planId"`
|
||||||
|
Credits creditSummary `json:"credits"`
|
||||||
|
Usage userUsageAggregate `json:"usage"`
|
||||||
|
APIKeys apiKeyAggregate `json:"apiKeys"`
|
||||||
|
Reservations reservationAdminSummary `json:"reservations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type creditSummary struct {
|
||||||
|
Balance int64 `json:"balance"`
|
||||||
|
Reserved int64 `json:"reserved"`
|
||||||
|
Available int64 `json:"available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userUsageAggregate struct {
|
||||||
|
TotalCredits int64 `json:"totalCredits"`
|
||||||
|
TotalQuantity int64 `json:"totalQuantity"`
|
||||||
|
RecordCount int64 `json:"recordCount"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiKeyAggregate struct {
|
||||||
|
ActiveCount int64 `json:"activeCount"`
|
||||||
|
TotalCount int64 `json:"totalCount"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reservationAdminSummary struct {
|
||||||
|
ActiveCount int64 `json:"activeCount"`
|
||||||
|
Active []reservationSummaryRow `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiError struct {
|
type apiError struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -221,6 +254,7 @@ func (s *Server) routes() {
|
|||||||
s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect)
|
s.route(http.MethodPost, "/internal/auth/introspect", s.handleIntrospect)
|
||||||
s.route(http.MethodPost, "/internal/billing/preflight", s.handleBillingPreflight)
|
s.route(http.MethodPost, "/internal/billing/preflight", s.handleBillingPreflight)
|
||||||
s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord)
|
s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord)
|
||||||
|
s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary)
|
||||||
s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust)
|
s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust)
|
||||||
s.route(http.MethodPost, "/internal/admin/users/status", s.handleAdminUserStatus)
|
s.route(http.MethodPost, "/internal/admin/users/status", s.handleAdminUserStatus)
|
||||||
}
|
}
|
||||||
@@ -717,6 +751,44 @@ func (s *Server) handleAdminUserStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "status": req.Status})
|
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "status": req.Status})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminBillingSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryLimit(r, "limit", 50, 100)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
users, err := s.adminBillingUsers(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
s.logDatabaseError("admin_billing_summary_users", err)
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing summary")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totals, err := s.adminBillingTotals(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logDatabaseError("admin_billing_summary_totals", err)
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "admin_billing_summary_failed", "could not load admin billing totals")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"contractVersion": "user-billing-admin-summary-v1",
|
||||||
|
"serviceId": serviceID,
|
||||||
|
"status": "ok",
|
||||||
|
"users": users,
|
||||||
|
"count": len(users),
|
||||||
|
"limit": limit,
|
||||||
|
"totals": totals,
|
||||||
|
"state": map[string]any{
|
||||||
|
"stateless": true,
|
||||||
|
"stateAuthority": s.config.StateAuthority,
|
||||||
|
"database": map[string]any{"authority": s.config.ExternalDatabaseLabel},
|
||||||
|
"redis": map[string]any{"role": "cache-only"},
|
||||||
|
},
|
||||||
|
"valuesRedacted": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var errInsufficientCredits = errors.New("insufficient credits")
|
var errInsufficientCredits = errors.New("insufficient credits")
|
||||||
|
|
||||||
func (s *Server) reserveCredits(ctx context.Context, userID, service string, credits, tokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
func (s *Server) reserveCredits(ctx context.Context, userID, service string, credits, tokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
||||||
@@ -1100,6 +1172,128 @@ func (s *Server) activeReservations(ctx context.Context, userID string, limit in
|
|||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) adminBillingUsers(ctx context.Context, limit int) ([]adminBillingUserSummary, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.status,
|
||||||
|
u.role,
|
||||||
|
u.email_verified,
|
||||||
|
u.created_at,
|
||||||
|
COALESCE(a.plan_id, 'default'),
|
||||||
|
COALESCE(a.balance_credits, 0),
|
||||||
|
COALESCE(a.reserved_credits, 0),
|
||||||
|
COALESCE(usage.total_credits, 0),
|
||||||
|
COALESCE(usage.total_quantity, 0),
|
||||||
|
COALESCE(usage.record_count, 0),
|
||||||
|
usage.last_used_at,
|
||||||
|
COALESCE(keys.active_count, 0),
|
||||||
|
COALESCE(keys.total_count, 0),
|
||||||
|
keys.last_key_used_at,
|
||||||
|
COALESCE(res.active_count, 0)
|
||||||
|
FROM hwlab_users u
|
||||||
|
LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, SUM(credits) AS total_credits, SUM(quantity) AS total_quantity, COUNT(*) AS record_count, MAX(created_at) AS last_used_at
|
||||||
|
FROM hwlab_usage_records
|
||||||
|
GROUP BY user_id
|
||||||
|
) usage ON usage.user_id = u.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, COUNT(*) FILTER (WHERE status = 'active' AND revoked_at IS NULL) AS active_count, COUNT(*) AS total_count, MAX(last_used_at) AS last_key_used_at
|
||||||
|
FROM hwlab_api_keys
|
||||||
|
GROUP BY user_id
|
||||||
|
) keys ON keys.user_id = u.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, COUNT(*) AS active_count
|
||||||
|
FROM hwlab_billing_reservations
|
||||||
|
WHERE status = 'reserved' AND expires_at > now()
|
||||||
|
GROUP BY user_id
|
||||||
|
) res ON res.user_id = u.id
|
||||||
|
ORDER BY usage.last_used_at DESC NULLS LAST, u.created_at DESC, u.username ASC
|
||||||
|
LIMIT $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := []adminBillingUserSummary{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item adminBillingUserSummary
|
||||||
|
var lastUsed sql.NullTime
|
||||||
|
var lastKeyUsed sql.NullTime
|
||||||
|
if err := rows.Scan(&item.User.ID, &item.User.Email, &item.User.Username, &item.User.DisplayName, &item.User.Status, &item.User.Role, &item.User.EmailVerified, &item.User.CreatedAt, &item.PlanID, &item.Credits.Balance, &item.Credits.Reserved, &item.Usage.TotalCredits, &item.Usage.TotalQuantity, &item.Usage.RecordCount, &lastUsed, &item.APIKeys.ActiveCount, &item.APIKeys.TotalCount, &lastKeyUsed, &item.Reservations.ActiveCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastUsed.Valid {
|
||||||
|
value := lastUsed.Time
|
||||||
|
item.Usage.LastUsedAt = &value
|
||||||
|
}
|
||||||
|
if lastKeyUsed.Valid {
|
||||||
|
value := lastKeyUsed.Time
|
||||||
|
item.APIKeys.LastUsedAt = &value
|
||||||
|
}
|
||||||
|
item.Credits.Available = item.Credits.Balance - item.Credits.Reserved
|
||||||
|
reservations, err := s.activeReservations(ctx, item.User.ID, 5)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Reservations.Active = reservations
|
||||||
|
users = append(users, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) adminBillingTotals(ctx context.Context) (map[string]any, error) {
|
||||||
|
var totals struct {
|
||||||
|
Users int64
|
||||||
|
ActiveUsers int64
|
||||||
|
DisabledUsers int64
|
||||||
|
PendingUsers int64
|
||||||
|
BalanceCredits int64
|
||||||
|
ReservedCredits int64
|
||||||
|
AvailableCredits int64
|
||||||
|
UsageCredits int64
|
||||||
|
UsageQuantity int64
|
||||||
|
UsageRecords int64
|
||||||
|
ActiveReservations int64
|
||||||
|
}
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM hwlab_users),
|
||||||
|
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'active'),
|
||||||
|
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'disabled'),
|
||||||
|
(SELECT COUNT(*) FROM hwlab_users WHERE status = 'pending'),
|
||||||
|
COALESCE((SELECT SUM(balance_credits) FROM hwlab_credit_accounts), 0),
|
||||||
|
COALESCE((SELECT SUM(reserved_credits) FROM hwlab_credit_accounts), 0),
|
||||||
|
COALESCE((SELECT SUM(balance_credits - reserved_credits) FROM hwlab_credit_accounts), 0),
|
||||||
|
COALESCE((SELECT SUM(credits) FROM hwlab_usage_records), 0),
|
||||||
|
COALESCE((SELECT SUM(quantity) FROM hwlab_usage_records), 0),
|
||||||
|
(SELECT COUNT(*) FROM hwlab_usage_records),
|
||||||
|
(SELECT COUNT(*) FROM hwlab_billing_reservations WHERE status = 'reserved' AND expires_at > now())`).Scan(&totals.Users, &totals.ActiveUsers, &totals.DisabledUsers, &totals.PendingUsers, &totals.BalanceCredits, &totals.ReservedCredits, &totals.AvailableCredits, &totals.UsageCredits, &totals.UsageQuantity, &totals.UsageRecords, &totals.ActiveReservations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"users": totals.Users,
|
||||||
|
"activeUsers": totals.ActiveUsers,
|
||||||
|
"disabledUsers": totals.DisabledUsers,
|
||||||
|
"pendingUsers": totals.PendingUsers,
|
||||||
|
"balanceCredits": totals.BalanceCredits,
|
||||||
|
"reservedCredits": totals.ReservedCredits,
|
||||||
|
"availableCredits": totals.AvailableCredits,
|
||||||
|
"usageCredits": totals.UsageCredits,
|
||||||
|
"usageQuantity": totals.UsageQuantity,
|
||||||
|
"usageRecords": totals.UsageRecords,
|
||||||
|
"activeReservations": totals.ActiveReservations,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func queryLimit(r *http.Request, name string, fallback, max int) int {
|
func queryLimit(r *http.Request, name string, fallback, max int) int {
|
||||||
value := strings.TrimSpace(r.URL.Query().Get(name))
|
value := strings.TrimSpace(r.URL.Query().Get(name))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { fetchJson } from "./client";
|
import { fetchJson } from "./client";
|
||||||
import type { ApiResult, UsageSummary } from "@/types";
|
import type { AdminBillingSummary, ApiResult, UsageSummary } from "@/types";
|
||||||
|
|
||||||
export const usageAPI = {
|
export const usageAPI = {
|
||||||
summary: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }),
|
summary: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }),
|
||||||
|
adminBillingSummary: (): Promise<ApiResult<AdminBillingSummary>> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }),
|
||||||
performance: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" })
|
performance: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -674,6 +674,49 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-billing-panel {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-billing-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-billing-table td > strong,
|
||||||
|
.admin-billing-table td > small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-billing-table td > small {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill[data-status="active"] {
|
||||||
|
border-color: #99f6e4;
|
||||||
|
background: #ccfbf1;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill[data-status="disabled"] {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-host {
|
.toast-host {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
|
|||||||
@@ -259,6 +259,28 @@ export interface UsageSummary {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminBillingUserSummary {
|
||||||
|
user?: AuthUser & { email?: string; emailVerified?: boolean; createdAt?: string };
|
||||||
|
planId?: string;
|
||||||
|
credits?: { balance?: number; reserved?: number; available?: number };
|
||||||
|
usage?: { totalCredits?: number; totalQuantity?: number; recordCount?: number; lastUsedAt?: string | null };
|
||||||
|
apiKeys?: { activeCount?: number; totalCount?: number; lastUsedAt?: string | null };
|
||||||
|
reservations?: { activeCount?: number; active?: UsageReservationRow[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBillingSummary {
|
||||||
|
status?: string;
|
||||||
|
contractVersion?: string;
|
||||||
|
count?: number;
|
||||||
|
limit?: number;
|
||||||
|
totals?: Record<string, number>;
|
||||||
|
users?: AdminBillingUserSummary[];
|
||||||
|
state?: { stateless?: boolean; stateAuthority?: string; database?: Record<string, unknown>; redis?: Record<string, unknown> };
|
||||||
|
proxy?: Record<string, unknown>;
|
||||||
|
valuesRedacted?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
type: "success" | "error" | "info" | "warning";
|
type: "success" | "error" | "info" | "warning";
|
||||||
|
|||||||
@@ -1,11 +1,118 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { usageAPI } from "@/api";
|
||||||
import EmptyState from "@/components/common/EmptyState.vue";
|
import EmptyState from "@/components/common/EmptyState.vue";
|
||||||
import PageHeader from "@/components/common/PageHeader.vue";
|
import PageHeader from "@/components/common/PageHeader.vue";
|
||||||
|
import type { AdminBillingSummary } from "@/types";
|
||||||
|
|
||||||
|
const summary = ref<AdminBillingSummary | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const totals = computed(() => summary.value?.totals ?? {});
|
||||||
|
const users = computed(() => summary.value?.users ?? []);
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
const response = await usageAPI.adminBillingSummary();
|
||||||
|
if (!response.ok) {
|
||||||
|
error.value = response.error ?? `HTTP ${response.status}`;
|
||||||
|
summary.value = null;
|
||||||
|
} else {
|
||||||
|
summary.value = response.data;
|
||||||
|
error.value = null;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: unknown): string {
|
||||||
|
const number = Number(value ?? 0);
|
||||||
|
if (!Number.isFinite(number)) return "0";
|
||||||
|
return new Intl.NumberFormat("zh-CN").format(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: unknown): string {
|
||||||
|
const text = String(value ?? "").trim();
|
||||||
|
if (!text) return "-";
|
||||||
|
const date = new Date(text);
|
||||||
|
if (Number.isNaN(date.getTime())) return text;
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="route-stack">
|
<section class="route-stack">
|
||||||
<PageHeader eyebrow="Admin" title="用户管理" description="用户、角色和状态管理页面骨架。" />
|
<PageHeader eyebrow="Admin" title="用户与账务" description="来自 PK01 PostgreSQL ledger 的用户余额、用量和 reservation 概览。" />
|
||||||
<EmptyState title="用户列表扩展位" description="后续复用 Access store 和 /v1/admin/access/users 同源 API。" />
|
<EmptyState v-if="loading" title="正在加载用户账务" description="正在通过 cloud-api 代理读取 user-billing admin summary。" />
|
||||||
|
<EmptyState v-else-if="error" title="管理员账务接口不可用" :description="error" />
|
||||||
|
<EmptyState v-else-if="!summary" title="暂无用户账务" description="当前运行面未返回 admin billing summary。" />
|
||||||
|
<template v-else>
|
||||||
|
<div class="overview-grid usage-metrics">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>用户</span>
|
||||||
|
<strong>{{ formatNumber(totals.users ?? summary.count) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>平台 credit</span>
|
||||||
|
<strong>{{ formatNumber(totals.availableCredits) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span>活跃预留</span>
|
||||||
|
<strong>{{ formatNumber(totals.activeReservations) }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="data-panel usage-panel admin-billing-panel">
|
||||||
|
<header class="panel-header">
|
||||||
|
<h2>用户账务</h2>
|
||||||
|
<small>{{ summary.state?.stateAuthority ?? "pk01-postgres" }} / {{ summary.state?.redis?.role ?? "cache-only" }}</small>
|
||||||
|
</header>
|
||||||
|
<table v-if="users.length" class="data-table usage-table admin-billing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Credits</th>
|
||||||
|
<th>Usage</th>
|
||||||
|
<th>API keys</th>
|
||||||
|
<th>Reservations</th>
|
||||||
|
<th>Last activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in users" :key="item.user?.id ?? item.user?.username">
|
||||||
|
<td>
|
||||||
|
<strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong>
|
||||||
|
<small>{{ item.user?.username }} · {{ item.planId ?? "default" }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ formatNumber(item.credits?.available) }}</strong>
|
||||||
|
<small>{{ formatNumber(item.credits?.balance) }} balance · {{ formatNumber(item.credits?.reserved) }} reserved</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ formatNumber(item.usage?.totalCredits) }}</strong>
|
||||||
|
<small>{{ formatNumber(item.usage?.recordCount) }} records · {{ formatNumber(item.usage?.totalQuantity) }} units</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ formatNumber(item.apiKeys?.activeCount) }}</strong>
|
||||||
|
<small>{{ formatNumber(item.apiKeys?.totalCount) }} total</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ formatNumber(item.reservations?.activeCount) }}</strong>
|
||||||
|
<small v-if="item.reservations?.active?.length"><code>{{ item.reservations.active[0]?.serviceId }}</code></small>
|
||||||
|
<small v-else>none</small>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(item.usage?.lastUsedAt ?? item.apiKeys?.lastUsedAt ?? item.user?.createdAt) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="muted-box">暂无用户记录。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user