feat: add admin usage ledger audit

This commit is contained in:
lyon
2026-06-14 20:52:27 +08:00
parent a35d862279
commit 763b583070
8 changed files with 704 additions and 34 deletions
+91
View File
@@ -330,6 +330,26 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/admin/billing/usage" && request.method === "GET") {
await handleAdminBillingUsageHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/admin/billing/usage/export" && request.method === "GET") {
await handleAdminBillingUsageExportHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/admin/billing/ledger" && request.method === "GET") {
await handleAdminBillingLedgerHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/admin/billing/ledger/export" && request.method === "GET") {
await handleAdminBillingLedgerExportHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/admin/billing/users" && (request.method === "GET" || request.method === "POST")) {
await handleAdminBillingUsersHttp(request, response, url, options);
return;
@@ -1137,6 +1157,50 @@ async function handleAdminBillingPlansHttp(request, response, options) {
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/plans", route: "/v1/admin/billing/plans", actor: adminActorSummary(auth.actor) });
}
async function handleAdminBillingUsageHttp(request, response, url, options) {
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/usage");
if (!context) return;
const { auth, client } = context;
if (typeof client.adminUsage !== "function") {
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin usage API is not configured", { operation: "GET /v1/admin/billing/usage", target: { type: "service", id: "hwlab-user-billing" } });
}
const result = await client.adminUsage(adminAuditParams(url));
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/usage", route: "/v1/admin/billing/usage", actor: adminActorSummary(auth.actor) });
}
async function handleAdminBillingUsageExportHttp(request, response, url, options) {
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/usage/export");
if (!context) return;
const { client } = context;
if (typeof client.adminUsageExport !== "function") {
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin usage export API is not configured", { operation: "GET /v1/admin/billing/usage/export", target: { type: "service", id: "hwlab-user-billing" } });
}
const result = await client.adminUsageExport(adminAuditParams(url));
sendUserBillingCsvProxyResult({ request, response, result, operation: "GET /v1/admin/billing/usage/export", filename: "hwlab-admin-usage.csv" });
}
async function handleAdminBillingLedgerHttp(request, response, url, options) {
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/ledger");
if (!context) return;
const { auth, client } = context;
if (typeof client.adminLedger !== "function") {
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin ledger API is not configured", { operation: "GET /v1/admin/billing/ledger", target: { type: "service", id: "hwlab-user-billing" } });
}
const result = await client.adminLedger(adminAuditParams(url));
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/ledger", route: "/v1/admin/billing/ledger", actor: adminActorSummary(auth.actor) });
}
async function handleAdminBillingLedgerExportHttp(request, response, url, options) {
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/ledger/export");
if (!context) return;
const { client } = context;
if (typeof client.adminLedgerExport !== "function") {
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin ledger export API is not configured", { operation: "GET /v1/admin/billing/ledger/export", target: { type: "service", id: "hwlab-user-billing" } });
}
const result = await client.adminLedgerExport(adminAuditParams(url));
sendUserBillingCsvProxyResult({ request, response, result, operation: "GET /v1/admin/billing/ledger/export", filename: "hwlab-admin-ledger.csv" });
}
async function handleAdminBillingUserHttp(request, response, url, userId, options) {
const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users/{userId}`);
if (!context) return;
@@ -1334,6 +1398,33 @@ function sendUserBillingProxyResult({ request, response, result, operation, rout
});
}
function sendUserBillingCsvProxyResult({ request, response, result, operation, filename }) {
if (!result?.ok) {
sendRestError(request, response, result?.status ?? 502, result?.error?.code ?? "user_billing_request_failed", result?.error?.message ?? "user-billing export request failed", {
operation,
target: { type: "service", id: "hwlab-user-billing" },
reason: result?.error?.code,
result: "failed"
});
return;
}
response.writeHead(result.status ?? 200, {
"content-type": "text/csv; charset=utf-8",
"cache-control": "no-store",
"content-disposition": `attachment; filename="${filename}"`
});
response.end(String(result.body ?? ""));
}
function adminAuditParams(url) {
const params = {};
for (const key of ["page", "pageSize", "limit", "userId", "search", "serviceId", "resourceType", "apiKeyId", "status", "kind", "source", "idempotencyKey", "from", "to"]) {
const value = String(url.searchParams.get(key) ?? "").trim();
if (value) params[key] = value;
}
return params;
}
function adminActorSummary(actor) {
return actor ? {
id: String(actor.id ?? ""),
+40
View File
@@ -53,6 +53,24 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch }
}
}
async function requestText(path, { method = "GET" } = {}) {
if (!baseUrl) {
return { ok: false, status: 503, error: { code: "user_billing_not_configured", message: "HWLAB user-billing URL is not configured" } };
}
const headers = { accept: "text/csv" };
if (internalToken) headers["x-hwlab-internal-token"] = internalToken;
try {
const response = await fetchImpl(`${baseUrl}${path}`, { method, headers, signal: AbortSignal.timeout(timeoutMs) });
const body = await response.text();
if (!response.ok) {
return { ok: false, status: response.status, body, error: { code: "user_billing_request_failed", message: `user-billing request failed with HTTP ${response.status}` } };
}
return { ok: true, status: response.status, body, contentType: response.headers?.get?.("content-type") ?? "text/csv" };
} catch (error) {
return { ok: false, status: 503, error: { code: "user_billing_unreachable", message: error instanceof Error ? error.message : String(error) } };
}
}
async function post(path, body = {}) {
return requestJson(path, { method: "POST", body });
}
@@ -122,6 +140,18 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch }
async adminBillingPlans() {
return requestJson("/internal/admin/billing/plans", { method: "GET" });
},
async adminUsage(params = {}) {
return requestJson(adminAuditPath("/internal/admin/usage", params), { method: "GET" });
},
async adminUsageExport(params = {}) {
return requestText(adminAuditPath("/internal/admin/usage/export", params), { method: "GET" });
},
async adminLedger(params = {}) {
return requestJson(adminAuditPath("/internal/admin/ledger", params), { method: "GET" });
},
async adminLedgerExport(params = {}) {
return requestText(adminAuditPath("/internal/admin/ledger/export", params), { method: "GET" });
},
async adminUsers({ page = 1, pageSize = 50, search = "", status = "", role = "" } = {}) {
const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (text(search)) query.set("search", text(search));
@@ -147,6 +177,16 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch }
};
}
function adminAuditPath(path, params = {}) {
const query = new URLSearchParams();
for (const key of ["page", "pageSize", "limit", "userId", "search", "serviceId", "resourceType", "apiKeyId", "status", "kind", "source", "idempotencyKey", "from", "to"]) {
const value = text(params?.[key]);
if (value) query.set(key, value);
}
const suffix = query.toString();
return suffix ? `${path}?${suffix}` : path;
}
function normalizeBaseUrl(value) {
const input = text(value);
if (!input) return "";
+270
View File
@@ -0,0 +1,270 @@
package userbilling
import (
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func (s *Server) handleAdminUsage(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
query := parseAdminAuditQuery(r)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, total, err := s.adminUsageRows(ctx, query)
if err != nil {
s.logDatabaseError("admin_usage", err)
writeAPIError(w, http.StatusInternalServerError, "admin_usage_failed", "could not load usage records")
return
}
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-usage-v1", "rows": rows, "count": len(rows), "total": total, "pagination": pagination(query.Page, query.PageSize, total), "filters": auditFilterSummary(query), "valuesRedacted": true})
}
func (s *Server) handleAdminUsageExport(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
query := parseAdminAuditQuery(r)
query.Page = 1
query.PageSize = queryLimit(r, "limit", 1000, 5000)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rows, _, err := s.adminUsageRows(ctx, query)
if err != nil {
s.logDatabaseError("admin_usage_export", err)
writeAPIError(w, http.StatusInternalServerError, "admin_usage_export_failed", "could not export usage records")
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="hwlab-admin-usage.csv"`)
writer := csv.NewWriter(w)
_ = writer.Write([]string{"id", "created_at", "user_id", "email", "username", "api_key_id", "service_id", "resource_type", "unit", "quantity", "credits", "status", "idempotency_key", "metadata_json"})
for _, row := range rows {
_ = writer.Write([]string{row.ID, row.CreatedAt.Format(time.RFC3339), row.UserID, row.Email, row.Username, row.APIKeyID, row.ServiceID, row.ResourceType, row.Unit, strconv.FormatInt(row.Quantity, 10), strconv.FormatInt(row.Credits, 10), row.Status, row.IdempotencyKey, compactJSON(row.Metadata)})
}
writer.Flush()
}
func (s *Server) handleAdminLedger(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
query := parseAdminAuditQuery(r)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, total, err := s.adminLedgerRows(ctx, query)
if err != nil {
s.logDatabaseError("admin_ledger", err)
writeAPIError(w, http.StatusInternalServerError, "admin_ledger_failed", "could not load ledger rows")
return
}
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-ledger-v1", "rows": rows, "count": len(rows), "total": total, "pagination": pagination(query.Page, query.PageSize, total), "filters": auditFilterSummary(query), "valuesRedacted": true})
}
func (s *Server) handleAdminLedgerExport(w http.ResponseWriter, r *http.Request) {
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
return
}
query := parseAdminAuditQuery(r)
query.Page = 1
query.PageSize = queryLimit(r, "limit", 1000, 5000)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
rows, _, err := s.adminLedgerRows(ctx, query)
if err != nil {
s.logDatabaseError("admin_ledger_export", err)
writeAPIError(w, http.StatusInternalServerError, "admin_ledger_export_failed", "could not export ledger rows")
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="hwlab-admin-ledger.csv"`)
writer := csv.NewWriter(w)
_ = writer.Write([]string{"id", "created_at", "user_id", "email", "username", "kind", "reason", "source", "status", "delta_credits", "balance_before", "balance_after", "operator_user_id", "operator_username", "idempotency_key", "metadata_json"})
for _, row := range rows {
before := ""
if row.BalanceBefore != nil {
before = strconv.FormatInt(*row.BalanceBefore, 10)
}
_ = writer.Write([]string{row.ID, row.CreatedAt.Format(time.RFC3339), row.UserID, row.Email, row.Username, row.Kind, row.Reason, row.Source, row.Status, strconv.FormatInt(row.DeltaCredits, 10), before, strconv.FormatInt(row.BalanceAfter, 10), row.OperatorUserID, row.OperatorUsername, row.IdempotencyKey, compactJSON(row.Metadata)})
}
writer.Flush()
}
func (s *Server) adminUsageRows(ctx context.Context, query adminAuditQuery) ([]adminUsageRow, int64, error) {
where, args := adminUsageWhere(query)
total, err := countRows(ctx, s.db, `SELECT COUNT(*) FROM hwlab_usage_records ur JOIN hwlab_users u ON u.id = ur.user_id `+where, args)
if err != nil {
return nil, 0, err
}
rowArgs := append([]any{}, args...)
rowArgs = append(rowArgs, query.PageSize, (query.Page-1)*query.PageSize)
limitParam := "$" + strconv.Itoa(len(rowArgs)-1)
offsetParam := "$" + strconv.Itoa(len(rowArgs))
rows, err := s.db.QueryContext(ctx, `SELECT ur.id, COALESCE(ur.reservation_id, ''), ur.user_id, u.email, u.username, COALESCE(ur.api_key_id, ''), ur.service_id, ur.resource_type, ur.unit, ur.quantity, ur.credits, ur.status, COALESCE(ur.idempotency_key, ''), ur.metadata::text, ur.created_at FROM hwlab_usage_records ur JOIN hwlab_users u ON u.id = ur.user_id `+where+` ORDER BY ur.created_at DESC, ur.id DESC LIMIT `+limitParam+` OFFSET `+offsetParam, rowArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := []adminUsageRow{}
for rows.Next() {
var item adminUsageRow
var metadataRaw string
if err := rows.Scan(&item.ID, &item.ReservationID, &item.UserID, &item.Email, &item.Username, &item.APIKeyID, &item.ServiceID, &item.ResourceType, &item.Unit, &item.Quantity, &item.Credits, &item.Status, &item.IdempotencyKey, &metadataRaw, &item.CreatedAt); err != nil {
return nil, 0, err
}
item.Metadata = redactedMetadata(parseJSONMap(metadataRaw))
items = append(items, item)
}
return items, total, rows.Err()
}
func (s *Server) adminLedgerRows(ctx context.Context, query adminAuditQuery) ([]adminLedgerRow, int64, error) {
where, args := adminLedgerWhere(query)
total, err := countRows(ctx, s.db, `SELECT COUNT(*) FROM hwlab_credit_ledger l JOIN hwlab_users u ON u.id = l.user_id `+where, args)
if err != nil {
return nil, 0, err
}
rowArgs := append([]any{}, args...)
rowArgs = append(rowArgs, query.PageSize, (query.Page-1)*query.PageSize)
limitParam := "$" + strconv.Itoa(len(rowArgs)-1)
offsetParam := "$" + strconv.Itoa(len(rowArgs))
rows, err := s.db.QueryContext(ctx, `SELECT l.id, l.user_id, u.email, u.username, l.kind, l.reason, l.delta_credits, l.balance_before, l.balance_after, l.source, l.status, COALESCE(l.idempotency_key, ''), COALESCE(l.operator_user_id, ''), COALESCE(l.operator_username, ''), l.metadata::text, l.created_at FROM hwlab_credit_ledger l JOIN hwlab_users u ON u.id = l.user_id `+where+` ORDER BY l.created_at DESC, l.id DESC LIMIT `+limitParam+` OFFSET `+offsetParam, rowArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := []adminLedgerRow{}
for rows.Next() {
var item adminLedgerRow
var before sql.NullInt64
var metadataRaw string
if err := rows.Scan(&item.ID, &item.UserID, &item.Email, &item.Username, &item.Kind, &item.Reason, &item.DeltaCredits, &before, &item.BalanceAfter, &item.Source, &item.Status, &item.IdempotencyKey, &item.OperatorUserID, &item.OperatorUsername, &metadataRaw, &item.CreatedAt); err != nil {
return nil, 0, err
}
if before.Valid {
value := before.Int64
item.BalanceBefore = &value
}
item.Metadata = redactedMetadata(parseJSONMap(metadataRaw))
items = append(items, item)
}
return items, total, rows.Err()
}
func adminUsageWhere(query adminAuditQuery) (string, []any) {
clauses, args := []string{}, []any{}
addAuditClause(&clauses, &args, "ur.user_id = %s", query.UserID)
addAuditClause(&clauses, &args, "ur.service_id = %s", query.ServiceID)
addAuditClause(&clauses, &args, "ur.resource_type = %s", query.ResourceType)
addAuditClause(&clauses, &args, "ur.api_key_id = %s", query.APIKeyID)
addAuditClause(&clauses, &args, "ur.status = %s", query.Status)
addAuditClause(&clauses, &args, "ur.idempotency_key = %s", query.IdempotencyKey)
addAuditTimeClause(&clauses, &args, "ur.created_at >= %s::timestamptz", query.From)
addAuditTimeClause(&clauses, &args, "ur.created_at <= %s::timestamptz", query.To)
if search := strings.TrimSpace(strings.ToLower(query.Search)); search != "" {
args = append(args, "%"+search+"%")
param := "$" + strconv.Itoa(len(args))
clauses = append(clauses, "(lower(ur.id) LIKE "+param+" OR lower(ur.service_id) LIKE "+param+" OR lower(ur.resource_type) LIKE "+param+" OR lower(u.email) LIKE "+param+" OR lower(u.username) LIKE "+param+")")
}
return whereSQL(clauses), args
}
func adminLedgerWhere(query adminAuditQuery) (string, []any) {
clauses, args := []string{}, []any{}
addAuditClause(&clauses, &args, "l.user_id = %s", query.UserID)
addAuditClause(&clauses, &args, "l.kind = %s", query.Kind)
addAuditClause(&clauses, &args, "l.source = %s", query.Source)
addAuditClause(&clauses, &args, "l.status = %s", query.Status)
addAuditClause(&clauses, &args, "l.idempotency_key = %s", query.IdempotencyKey)
addAuditTimeClause(&clauses, &args, "l.created_at >= %s::timestamptz", query.From)
addAuditTimeClause(&clauses, &args, "l.created_at <= %s::timestamptz", query.To)
if search := strings.TrimSpace(strings.ToLower(query.Search)); search != "" {
args = append(args, "%"+search+"%")
param := "$" + strconv.Itoa(len(args))
clauses = append(clauses, "(lower(l.id) LIKE "+param+" OR lower(l.kind) LIKE "+param+" OR lower(l.reason) LIKE "+param+" OR lower(u.email) LIKE "+param+" OR lower(u.username) LIKE "+param+")")
}
return whereSQL(clauses), args
}
func parseAdminAuditQuery(r *http.Request) adminAuditQuery {
return adminAuditQuery{Page: queryLimit(r, "page", 1, 100000), PageSize: queryLimit(r, "pageSize", 50, 500), UserID: textQuery(r, "userId"), Search: textQuery(r, "search"), ServiceID: textQuery(r, "serviceId"), ResourceType: textQuery(r, "resourceType"), APIKeyID: textQuery(r, "apiKeyId"), Status: textQuery(r, "status"), Kind: textQuery(r, "kind"), Source: textQuery(r, "source"), IdempotencyKey: textQuery(r, "idempotencyKey"), From: textQuery(r, "from"), To: textQuery(r, "to")}
}
func addAuditClause(clauses *[]string, args *[]any, template, value string) {
if value = strings.TrimSpace(value); value != "" {
*args = append(*args, value)
*clauses = append(*clauses, fmt.Sprintf(template, "$"+strconv.Itoa(len(*args))))
}
}
func addAuditTimeClause(clauses *[]string, args *[]any, template, value string) {
addAuditClause(clauses, args, template, value)
}
func whereSQL(clauses []string) string {
if len(clauses) == 0 {
return ""
}
return "WHERE " + strings.Join(clauses, " AND ")
}
func countRows(ctx context.Context, db *sql.DB, query string, args []any) (int64, error) {
var total int64
err := db.QueryRowContext(ctx, query, args...).Scan(&total)
return total, err
}
func pagination(page, pageSize int, total int64) map[string]any {
totalPages := int64(1)
if pageSize > 0 && total > 0 {
totalPages = (total + int64(pageSize) - 1) / int64(pageSize)
}
return map[string]any{"page": page, "pageSize": pageSize, "total": total, "totalPages": totalPages}
}
func auditFilterSummary(query adminAuditQuery) map[string]any {
return map[string]any{"userId": query.UserID, "search": query.Search, "serviceId": query.ServiceID, "resourceType": query.ResourceType, "apiKeyId": query.APIKeyID, "status": query.Status, "kind": query.Kind, "source": query.Source, "idempotencyKey": query.IdempotencyKey, "from": query.From, "to": query.To}
}
func textQuery(r *http.Request, name string) string {
return strings.TrimSpace(r.URL.Query().Get(name))
}
func compactJSON(value map[string]any) string {
if len(value) == 0 {
return "{}"
}
body, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(body)
}
func redactedMetadata(input map[string]any) map[string]any {
if len(input) == 0 {
return map[string]any{}
}
output := map[string]any{}
for key, value := range input {
lower := strings.ToLower(key)
if strings.Contains(lower, "token") || strings.Contains(lower, "secret") || strings.Contains(lower, "password") || strings.Contains(lower, "database_url") || strings.Contains(lower, "dsn") || strings.Contains(lower, "key_hash") || strings.Contains(lower, "apikey") || strings.Contains(lower, "api_key") {
output[key] = "redacted"
continue
}
if nested, ok := value.(map[string]any); ok {
output[key] = redactedMetadata(nested)
continue
}
output[key] = value
}
return output
}
@@ -0,0 +1,33 @@
ALTER TABLE hwlab_credit_ledger ADD COLUMN IF NOT EXISTS balance_before BIGINT;
ALTER TABLE hwlab_credit_ledger ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'system';
ALTER TABLE hwlab_credit_ledger ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'applied';
ALTER TABLE hwlab_credit_ledger ADD COLUMN IF NOT EXISTS operator_user_id TEXT;
ALTER TABLE hwlab_credit_ledger ADD COLUMN IF NOT EXISTS operator_username TEXT;
UPDATE hwlab_credit_ledger
SET balance_before = balance_after - delta_credits
WHERE balance_before IS NULL;
UPDATE hwlab_credit_ledger
SET source = CASE
WHEN kind = 'usage' THEN 'usage'
WHEN kind LIKE 'admin_%' THEN 'admin'
WHEN kind = 'grant' THEN 'registration'
ELSE source
END
WHERE source = 'system';
ALTER TABLE hwlab_billing_reservations ADD COLUMN IF NOT EXISTS api_key_id TEXT;
ALTER TABLE hwlab_usage_records ADD COLUMN IF NOT EXISTS api_key_id TEXT;
ALTER TABLE hwlab_usage_records ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'recorded';
CREATE INDEX IF NOT EXISTS hwlab_credit_ledger_created_idx ON hwlab_credit_ledger(created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS hwlab_credit_ledger_kind_created_idx ON hwlab_credit_ledger(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS hwlab_credit_ledger_source_created_idx ON hwlab_credit_ledger(source, created_at DESC);
CREATE INDEX IF NOT EXISTS hwlab_credit_ledger_idempotency_idx ON hwlab_credit_ledger(idempotency_key) WHERE idempotency_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS hwlab_credit_ledger_operator_idx ON hwlab_credit_ledger(operator_user_id) WHERE operator_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS hwlab_usage_records_created_idx ON hwlab_usage_records(created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS hwlab_usage_records_resource_created_idx ON hwlab_usage_records(resource_type, created_at DESC);
CREATE INDEX IF NOT EXISTS hwlab_usage_records_status_created_idx ON hwlab_usage_records(status, created_at DESC);
CREATE INDEX IF NOT EXISTS hwlab_usage_records_api_key_idx ON hwlab_usage_records(api_key_id) WHERE api_key_id IS NOT NULL;
+129 -30
View File
@@ -88,12 +88,74 @@ type serviceUsageSummary struct {
}
type ledgerSummaryRow struct {
ID string `json:"id"`
Kind string `json:"kind"`
Reason string `json:"reason"`
DeltaCredits int64 `json:"deltaCredits"`
BalanceAfter int64 `json:"balanceAfter"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
Kind string `json:"kind"`
Reason string `json:"reason"`
DeltaCredits int64 `json:"deltaCredits"`
BalanceBefore *int64 `json:"balanceBefore,omitempty"`
BalanceAfter int64 `json:"balanceAfter"`
Source string `json:"source"`
Status string `json:"status"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
OperatorUserID string `json:"operatorUserId,omitempty"`
OperatorUsername string `json:"operatorUsername,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type adminUsageRow struct {
ID string `json:"id"`
ReservationID string `json:"reservationId,omitempty"`
UserID string `json:"userId"`
Email string `json:"email"`
Username string `json:"username"`
APIKeyID string `json:"apiKeyId,omitempty"`
ServiceID string `json:"serviceId"`
ResourceType string `json:"resourceType"`
Unit string `json:"unit"`
Quantity int64 `json:"quantity"`
Credits int64 `json:"credits"`
Status string `json:"status"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type adminLedgerRow struct {
ledgerSummaryRow
UserID string `json:"userId"`
Email string `json:"email"`
Username string `json:"username"`
}
type adminAuditQuery struct {
Page int
PageSize int
UserID string
Search string
ServiceID string
ResourceType string
APIKeyID string
Status string
Kind string
Source string
IdempotencyKey string
From string
To string
}
type creditAdjustResult struct {
UserID string `json:"userId"`
BalanceBefore int64 `json:"balanceBefore"`
BalanceAfter int64 `json:"balanceAfter"`
DeltaCredits int64 `json:"deltaCredits"`
LedgerID string `json:"ledgerId"`
Kind string `json:"kind"`
Reason string `json:"reason"`
Source string `json:"source"`
Status string `json:"status"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Idempotent bool `json:"idempotent"`
}
type reservationSummaryRow struct {
@@ -323,6 +385,10 @@ func (s *Server) routes() {
s.route(http.MethodPost, "/internal/billing/release", s.handleBillingRelease)
s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary)
s.route(http.MethodGet, "/internal/admin/billing/plans", s.handleAdminBillingPlans)
s.route(http.MethodGet, "/internal/admin/usage", s.handleAdminUsage)
s.route(http.MethodGet, "/internal/admin/usage/export", s.handleAdminUsageExport)
s.route(http.MethodGet, "/internal/admin/ledger", s.handleAdminLedger)
s.route(http.MethodGet, "/internal/admin/ledger/export", s.handleAdminLedgerExport)
s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust)
s.mux.HandleFunc("/internal/admin/users", s.handleAdminUsers)
s.mux.HandleFunc("/internal/admin/users/", s.handleAdminUserByID)
@@ -462,7 +528,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
if s.config.InitialCredits > 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, metadata) VALUES ($1, $2, $3, $3, 'grant', 'registration_initial_credit', '{}'::jsonb)`, newID("led"), user.ID, s.config.InitialCredits)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, metadata) VALUES ($1, $2, $3, 0, $3, 'grant', 'registration_initial_credit', 'registration', 'applied', '{}'::jsonb)`, newID("led"), user.ID, s.config.InitialCredits)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "credit_ledger_failed", "could not write initial credit ledger")
return
@@ -943,7 +1009,7 @@ func (s *Server) handleBillingPreflight(w http.ResponseWriter, r *http.Request)
estimated := s.estimateCredits(req.EstimatedCredits, req.EstimatedTokens)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
reservation, err := s.reserveCredits(ctx, principal.UserID, service, estimated, req.EstimatedTokens, strings.TrimSpace(req.IdempotencyKey), req.Metadata)
reservation, err := s.reserveCredits(ctx, principal.UserID, principal.KeyID, service, estimated, req.EstimatedTokens, strings.TrimSpace(req.IdempotencyKey), req.Metadata)
if err != nil {
if errors.Is(err, errInsufficientCredits) {
writeJSON(w, http.StatusPaymentRequired, map[string]any{"allowed": false, "reason": "insufficient_credits"})
@@ -1144,7 +1210,7 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
if req.InitialCredits > 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, metadata) VALUES ($1, $2, $3, $3, 'admin_grant', 'admin_create_user_initial_credit', '{}'::jsonb)`, newID("led"), user.ID, req.InitialCredits)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, metadata) VALUES ($1, $2, $3, 0, $3, 'admin_grant', 'admin_create_user_initial_credit', 'admin', 'applied', '{}'::jsonb)`, newID("led"), user.ID, req.InitialCredits)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "credit_ledger_failed", "could not write initial credit ledger")
return
@@ -1305,12 +1371,12 @@ func (s *Server) handleAdminCreditAdjust(w http.ResponseWriter, r *http.Request)
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
balance, err := s.adjustCredits(ctx, req.UserID, req.DeltaCredits, "admin_adjust", req.Reason, req.IdempotencyKey, req.Metadata)
adjustment, err := s.adjustCredits(ctx, req.UserID, req.DeltaCredits, "admin_adjust", req.Reason, req.IdempotencyKey, req.Metadata)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "credit_adjust_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "balance": balance})
writeJSON(w, http.StatusOK, map[string]any{"userId": req.UserID, "balance": adjustment.BalanceAfter, "adjustment": adjustment, "valuesRedacted": true})
}
func (s *Server) handleAdminUserStatus(w http.ResponseWriter, r *http.Request) {
@@ -1406,7 +1472,7 @@ type billingLimitError struct {
func (err billingLimitError) Error() string { return err.Message }
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, apiKeyID, service string, credits, tokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil, err
@@ -1441,7 +1507,7 @@ func (s *Server) reserveCredits(ctx context.Context, userID, service string, cre
reservationID := newID("res")
metadataJSON := jsonObject(metadata)
expiresAt := time.Now().UTC().Add(s.config.ReservationTTL)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_billing_reservations (id, user_id, service_id, resource_type, estimated_credits, estimated_tokens, idempotency_key, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, $6, nullif($7, ''), $8::jsonb, $9)`, reservationID, userID, service, resourceType, credits, nonNegative(tokens), idempotencyKey, metadataJSON, expiresAt)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_billing_reservations (id, user_id, service_id, resource_type, estimated_credits, estimated_tokens, api_key_id, idempotency_key, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, $6, nullif($7, ''), nullif($8, ''), $9::jsonb, $10)`, reservationID, userID, service, resourceType, credits, nonNegative(tokens), apiKeyID, idempotencyKey, metadataJSON, expiresAt)
if err != nil {
return nil, err
}
@@ -1472,10 +1538,11 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
}
}
var reserved int64
apiKeyID := ""
resourceType := ""
if reservationID != "" {
var status string
err := tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &serviceID, &resourceType, &reserved, &status)
err := tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status, COALESCE(api_key_id, '') FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &serviceID, &resourceType, &reserved, &status, &apiKeyID)
if err != nil {
return nil, err
}
@@ -1488,6 +1555,7 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
return nil, err
}
userID = principal.UserID
apiKeyID = principal.KeyID
serviceID = strings.TrimSpace(serviceID)
resourceType = resourceTypeForService(serviceID)
}
@@ -1525,7 +1593,7 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
}
recordID := newID("use")
metadataJSON := jsonObject(metadata)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_usage_records (id, reservation_id, user_id, service_id, resource_type, quantity, credits, idempotency_key, metadata) VALUES ($1, nullif($2, ''), $3, $4, $5, $6, $7, nullif($8, ''), $9::jsonb)`, recordID, reservationID, userID, serviceID, resourceType, nonNegative(usedTokens), credits, idempotencyKey, metadataJSON)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_usage_records (id, reservation_id, user_id, service_id, resource_type, api_key_id, quantity, credits, status, idempotency_key, metadata) VALUES ($1, nullif($2, ''), $3, $4, $5, nullif($6, ''), $7, $8, 'recorded', nullif($9, ''), $10::jsonb)`, recordID, reservationID, userID, serviceID, resourceType, apiKeyID, nonNegative(usedTokens), credits, idempotencyKey, metadataJSON)
if err != nil {
return nil, err
}
@@ -1533,7 +1601,7 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
if idempotencyKey != "" {
usageLedgerKey = "usage:" + idempotencyKey
}
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, idempotency_key, metadata) VALUES ($1, $2, $3, $4, 'usage', $5, nullif($6, ''), $7::jsonb)`, newID("led"), userID, -credits, newBalance, serviceID, usageLedgerKey, metadataJSON)
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, 'usage', $6, 'usage', 'applied', nullif($7, ''), $8::jsonb)`, newID("led"), userID, -credits, balance, newBalance, serviceID, usageLedgerKey, metadataJSON)
if err != nil {
return nil, err
}
@@ -1605,39 +1673,48 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI
return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "resourceType": resourceType, "releasedCredits": reserved, "status": "cancelled"}, nil
}
func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64, kind, reason, idempotencyKey string, metadata map[string]any) (int64, error) {
func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64, kind, reason, idempotencyKey string, metadata map[string]any) (creditAdjustResult, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return 0, err
return creditAdjustResult{}, err
}
defer tx.Rollback()
operatorUserID, operatorUsername, _ := operatorFields(metadata)
if idempotencyKey != "" {
var balance int64
err := tx.QueryRowContext(ctx, `SELECT balance_after FROM hwlab_credit_ledger WHERE idempotency_key = $1`, idempotencyKey).Scan(&balance)
var result creditAdjustResult
var before sql.NullInt64
err := tx.QueryRowContext(ctx, `SELECT id, user_id, COALESCE(balance_before, balance_after - delta_credits), balance_after, delta_credits, kind, reason, source, status FROM hwlab_credit_ledger WHERE idempotency_key = $1`, idempotencyKey).Scan(&result.LedgerID, &result.UserID, &before, &result.BalanceAfter, &result.DeltaCredits, &result.Kind, &result.Reason, &result.Source, &result.Status)
if err == nil {
return balance, tx.Commit()
if before.Valid {
result.BalanceBefore = before.Int64
}
result.IdempotencyKey = idempotencyKey
result.Idempotent = true
return result, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
return creditAdjustResult{}, err
}
}
var balance int64
if err := tx.QueryRowContext(ctx, `SELECT balance_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance); err != nil {
return 0, err
return creditAdjustResult{}, err
}
newBalance := balance + delta
if newBalance < 0 {
return 0, errInsufficientCredits
return creditAdjustResult{}, errInsufficientCredits
}
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET balance_credits = $2, updated_at = now() WHERE user_id = $1`, userID, newBalance)
if err != nil {
return 0, err
return creditAdjustResult{}, err
}
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_after, kind, reason, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, $6, nullif($7, ''), $8::jsonb)`, newID("led"), userID, delta, newBalance, kind, reason, idempotencyKey, jsonObject(metadata))
ledgerID := newID("led")
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, idempotency_key, operator_user_id, operator_username, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, 'admin', 'applied', nullif($8, ''), nullif($9, ''), nullif($10, ''), $11::jsonb)`, ledgerID, userID, delta, balance, newBalance, kind, reason, idempotencyKey, operatorUserID, operatorUsername, jsonObject(metadata))
if err != nil {
return 0, err
return creditAdjustResult{}, err
}
return newBalance, tx.Commit()
result := creditAdjustResult{UserID: userID, BalanceBefore: balance, BalanceAfter: newBalance, DeltaCredits: delta, LedgerID: ledgerID, Kind: kind, Reason: reason, Source: "admin", Status: "applied", IdempotencyKey: idempotencyKey, Idempotent: false}
return result, tx.Commit()
}
type txQueryer interface {
@@ -1835,6 +1912,21 @@ func parseJSONMap(raw string) map[string]any {
return result
}
func operatorFields(metadata map[string]any) (string, string, string) {
operator, _ := metadata["operator"].(map[string]any)
if operator == nil {
return "", "", ""
}
return stringFromAny(operator["id"]), stringFromAny(operator["username"]), stringFromAny(operator["role"])
}
func stringFromAny(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func (s *Server) principalForBilling(ctx context.Context, r *http.Request, token, apiKey, userID string) (*Principal, error) {
if token == "" && apiKey == "" {
token = bearerToken(r)
@@ -2036,7 +2128,7 @@ func (s *Server) usageByService(ctx context.Context, userID string, limit int) (
}
func (s *Server) ledgerRows(ctx context.Context, userID string, limit int) ([]ledgerSummaryRow, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, kind, reason, delta_credits, balance_after, created_at FROM hwlab_credit_ledger WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, userID, limit)
rows, err := s.db.QueryContext(ctx, `SELECT id, kind, reason, delta_credits, balance_before, balance_after, source, status, COALESCE(idempotency_key, ''), COALESCE(operator_user_id, ''), COALESCE(operator_username, ''), metadata::text, created_at FROM hwlab_credit_ledger WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
@@ -2044,9 +2136,16 @@ func (s *Server) ledgerRows(ctx context.Context, userID string, limit int) ([]le
items := []ledgerSummaryRow{}
for rows.Next() {
var item ledgerSummaryRow
if err := rows.Scan(&item.ID, &item.Kind, &item.Reason, &item.DeltaCredits, &item.BalanceAfter, &item.CreatedAt); err != nil {
var before sql.NullInt64
var metadataRaw string
if err := rows.Scan(&item.ID, &item.Kind, &item.Reason, &item.DeltaCredits, &before, &item.BalanceAfter, &item.Source, &item.Status, &item.IdempotencyKey, &item.OperatorUserID, &item.OperatorUsername, &metadataRaw, &item.CreatedAt); err != nil {
return nil, err
}
if before.Valid {
value := before.Int64
item.BalanceBefore = &value
}
item.Metadata = parseJSONMap(metadataRaw)
items = append(items, item)
}
return items, rows.Err()
+17 -1
View File
@@ -1,10 +1,14 @@
import { fetchJson } from "./client";
import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, ApiResult, UsageSummary } from "@/types";
import type { AccessUserStatus, AdminAuditResponse, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, AdminUsageRow, ApiResult, UsageLedgerRow, UsageSummary } from "@/types";
export const usageAPI = {
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" }),
adminPlans: (): Promise<ApiResult<AdminBillingPlansResponse>> => fetchJson("/v1/admin/billing/plans", { timeoutMs: 12000, timeoutName: "admin billing plans" }),
adminUsage: (params: AdminAuditParams = {}): Promise<ApiResult<AdminAuditResponse<AdminUsageRow>>> => fetchJson(auditPath("/v1/admin/billing/usage", params), { timeoutMs: 12000, timeoutName: "admin usage" }),
adminLedger: (params: AdminAuditParams = {}): Promise<ApiResult<AdminAuditResponse<UsageLedgerRow>>> => fetchJson(auditPath("/v1/admin/billing/ledger", params), { timeoutMs: 12000, timeoutName: "admin ledger" }),
adminUsageExportUrl: (params: AdminAuditParams = {}): string => auditPath("/v1/admin/billing/usage/export", { ...params, limit: params.limit ?? 1000 }),
adminLedgerExportUrl: (params: AdminAuditParams = {}): string => auditPath("/v1/admin/billing/ledger/export", { ...params, limit: params.limit ?? 1000 }),
adminUsers: (params: { page?: number; pageSize?: number; search?: string; status?: string; role?: string } = {}): Promise<ApiResult<AdminBillingUsersResponse>> => {
const query = new URLSearchParams({ page: String(params.page ?? 1), pageSize: String(params.pageSize ?? 25) });
if (params.search) query.set("search", params.search);
@@ -19,3 +23,15 @@ export const usageAPI = {
updateAdminUserStatus: (userId: string, status: AccessUserStatus): Promise<ApiResult<AdminBillingMutationResponse>> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }),
performance: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" })
};
export interface AdminAuditParams { page?: number; pageSize?: number; limit?: number; userId?: string; search?: string; serviceId?: string; resourceType?: string; apiKeyId?: string; status?: string; kind?: string; source?: string; idempotencyKey?: string; from?: string; to?: string }
function auditPath(path: string, params: AdminAuditParams = {}): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const text = String(value ?? "").trim();
if (text) query.set(key, text);
}
const suffix = query.toString();
return suffix ? `${path}?${suffix}` : path;
}
+14 -1
View File
@@ -321,7 +321,8 @@ export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array<Reco
export interface AdminAccessMutationResponse { ok?: boolean; user?: AuthUser; [key: string]: unknown }
export interface ApiKeyRecord { id: string; name?: string; prefix?: string; createdAt?: string; lastUsedAt?: string | null; revokedAt?: string | null; displaySecret?: string | null }
export interface UsageServiceSummary { serviceId: string; resourceType?: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string }
export interface UsageLedgerRow { id: string; kind?: string; reason?: string; deltaCredits?: number; balanceAfter?: number; createdAt?: string }
export interface UsageLedgerRow { id: string; userId?: string; email?: string; username?: string; kind?: string; reason?: string; deltaCredits?: number; balanceBefore?: number; balanceAfter?: number; source?: string; status?: string; idempotencyKey?: string; operatorUserId?: string; operatorUsername?: string; metadata?: Record<string, unknown>; createdAt?: string }
export interface AdminUsageRow { id: string; reservationId?: string; userId?: string; email?: string; username?: string; apiKeyId?: string; serviceId?: string; resourceType?: string; unit?: string; quantity?: number; credits?: number; status?: string; idempotencyKey?: string; metadata?: Record<string, unknown>; createdAt?: string }
export interface UsageReservationRow { reservationId: string; serviceId?: string; resourceType?: string; estimatedCredits?: number; estimatedTokens?: number; status?: string; expiresAt?: string; createdAt?: string }
export interface BillingPlanSummary { id?: string; displayName?: string; status?: string; description?: string; metadata?: Record<string, unknown> }
export interface ResourceEntitlementSummary {
@@ -425,6 +426,18 @@ export interface AdminBillingUserDetailResponse {
[key: string]: unknown;
}
export interface AdminAuditResponse<T> {
contractVersion?: string;
rows?: T[];
count?: number;
total?: number;
pagination?: { page?: number; pageSize?: number; total?: number; totalPages?: number };
filters?: Record<string, unknown>;
proxy?: Record<string, unknown>;
valuesRedacted?: boolean;
[key: string]: unknown;
}
export interface AdminBillingMutationResponse {
ok?: boolean;
userId?: string;
@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import { usageAPI } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AuthUser, BillingPlanDetail, ResourceEntitlementSummary } from "@/types";
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AdminUsageRow, AuthUser, BillingPlanDetail, ResourceEntitlementSummary, UsageLedgerRow } from "@/types";
const users = ref<AdminBillingUserSummary[]>([]);
const plans = ref<BillingPlanDetail[]>([]);
@@ -18,8 +18,15 @@ const page = ref(1);
const pageSize = ref(25);
const total = ref(0);
const stateAuthority = ref("pk01-postgres");
const auditTab = ref<"usage" | "ledger">("usage");
const auditLoading = ref(false);
const auditError = ref<string | null>(null);
const auditUsageRows = ref<AdminUsageRow[]>([]);
const auditLedgerRows = ref<UsageLedgerRow[]>([]);
const auditTotal = ref(0);
const filters = reactive({ search: "", status: "", role: "" });
const auditFilters = reactive({ search: "", userId: "", serviceId: "", resourceType: "", apiKeyId: "", status: "", kind: "", source: "", idempotencyKey: "", from: "", to: "" });
const createForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 });
const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", planId: "default" });
const creditForm = reactive({ deltaCredits: 0, reason: "admin_adjust", idempotencyKey: "" });
@@ -35,6 +42,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.v
onMounted(async () => {
await loadPlans();
await refresh();
await refreshAudit();
});
async function loadPlans(): Promise<void> {
@@ -160,6 +168,48 @@ async function adjustCredits(): Promise<void> {
creditForm.idempotencyKey = "";
await refresh();
await selectUser(userId);
await refreshAudit();
}
function auditParams(extra: Record<string, unknown> = {}): Record<string, unknown> {
const params: Record<string, unknown> = { page: 1, pageSize: 25, ...extra };
for (const [key, value] of Object.entries(auditFilters)) {
const text = String(value ?? "").trim();
if (text) params[key] = text;
}
if (auditTab.value === "usage") delete params.kind;
if (auditTab.value === "usage") delete params.source;
if (auditTab.value === "ledger") delete params.serviceId;
if (auditTab.value === "ledger") delete params.resourceType;
if (auditTab.value === "ledger") delete params.apiKeyId;
return params;
}
async function refreshAudit(): Promise<void> {
auditLoading.value = true;
auditError.value = null;
const response = auditTab.value === "usage" ? await usageAPI.adminUsage(auditParams()) : await usageAPI.adminLedger(auditParams());
auditLoading.value = false;
if (!response.ok) {
auditError.value = response.error ?? `HTTP ${response.status}`;
auditUsageRows.value = [];
auditLedgerRows.value = [];
auditTotal.value = 0;
return;
}
auditTotal.value = Number(response.data?.total ?? response.data?.count ?? 0);
if (auditTab.value === "usage") auditUsageRows.value = (response.data?.rows ?? []) as AdminUsageRow[];
else auditLedgerRows.value = (response.data?.rows ?? []) as UsageLedgerRow[];
}
function setAuditTab(tab: "usage" | "ledger"): void {
auditTab.value = tab;
void refreshAudit();
}
function downloadAudit(): void {
const url = auditTab.value === "usage" ? usageAPI.adminUsageExportUrl(auditParams({ limit: 1000 })) : usageAPI.adminLedgerExportUrl(auditParams({ limit: 1000 }));
window.location.assign(url);
}
async function nextPage(delta: number): Promise<void> {
@@ -198,6 +248,11 @@ function formatDate(value: unknown): string {
function entitlementLimit(current: unknown, limit: unknown, unlimited: unknown): string {
return `${formatNumber(current)} / ${unlimited ? "unlimited" : formatNumber(limit)}`;
}
function metadataPreview(value: unknown): string {
if (!value || typeof value !== "object") return "{}";
return JSON.stringify(value).slice(0, 180);
}
</script>
<template>
@@ -221,6 +276,59 @@ function entitlementLimit(current: unknown, limit: unknown, unlimited: unknown):
</div>
</section>
<section class="data-panel admin-audit-panel">
<header class="panel-header">
<h2>运营审计</h2>
<small>{{ auditLoading ? "loading" : `${formatNumber(auditTotal)} rows` }}</small>
</header>
<div class="admin-users-filters audit-filters">
<button class="table-action" type="button" :data-selected="auditTab === 'usage'" @click="setAuditTab('usage')">Usage</button>
<button class="table-action" type="button" :data-selected="auditTab === 'ledger'" @click="setAuditTab('ledger')">Ledger</button>
<input v-model="auditFilters.search" class="input" placeholder="搜索用户、记录或原因" @keyup.enter="refreshAudit" />
<input v-model="auditFilters.userId" class="input" placeholder="user id" @keyup.enter="refreshAudit" />
<input v-if="auditTab === 'usage'" v-model="auditFilters.serviceId" class="input" placeholder="service" @keyup.enter="refreshAudit" />
<input v-if="auditTab === 'usage'" v-model="auditFilters.resourceType" class="input" placeholder="resource" @keyup.enter="refreshAudit" />
<input v-if="auditTab === 'usage'" v-model="auditFilters.apiKeyId" class="input" placeholder="api key id" @keyup.enter="refreshAudit" />
<input v-if="auditTab === 'ledger'" v-model="auditFilters.kind" class="input" placeholder="kind" @keyup.enter="refreshAudit" />
<input v-if="auditTab === 'ledger'" v-model="auditFilters.source" class="input" placeholder="source" @keyup.enter="refreshAudit" />
<input v-model="auditFilters.status" class="input" placeholder="status" @keyup.enter="refreshAudit" />
<input v-model="auditFilters.from" class="input" type="datetime-local" @change="refreshAudit" />
<input v-model="auditFilters.to" class="input" type="datetime-local" @change="refreshAudit" />
<button class="btn btn-secondary" type="button" @click="refreshAudit">过滤</button>
<button class="btn btn-secondary" type="button" @click="downloadAudit">CSV</button>
</div>
<p v-if="auditError" class="form-error admin-action-error">{{ auditError }}</p>
<table v-if="auditTab === 'usage' && auditUsageRows.length" class="data-table usage-table audit-table">
<thead><tr><th>Time</th><th>User</th><th>Service</th><th>Resource</th><th>Credits</th><th>Status</th><th>Metadata</th></tr></thead>
<tbody>
<tr v-for="row in auditUsageRows" :key="row.id">
<td>{{ formatDate(row.createdAt) }}<small><code>{{ row.id }}</code></small></td>
<td>{{ row.username || row.email || row.userId }}<small>{{ row.userId }}</small></td>
<td><code>{{ row.serviceId }}</code><small>{{ row.apiKeyId || "session/internal" }}</small></td>
<td><code>{{ row.resourceType }}</code><small>{{ formatNumber(row.quantity) }} {{ row.unit || "unit" }}</small></td>
<td>{{ formatNumber(row.credits) }}</td>
<td><span class="status-pill" :data-status="row.status">{{ row.status }}</span></td>
<td><small>{{ metadataPreview(row.metadata) }}</small></td>
</tr>
</tbody>
</table>
<table v-else-if="auditTab === 'ledger' && auditLedgerRows.length" class="data-table usage-table audit-table">
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Delta</th><th>Before/After</th><th>Operator</th><th>Idempotency</th></tr></thead>
<tbody>
<tr v-for="row in auditLedgerRows" :key="row.id">
<td>{{ formatDate(row.createdAt) }}<small><code>{{ row.id }}</code></small></td>
<td>{{ row.username || row.email || row.userId }}<small>{{ row.userId }}</small></td>
<td>{{ row.kind }}<small>{{ row.source }} / {{ row.status }} / {{ row.reason }}</small></td>
<td>{{ formatSignedCredits(row.deltaCredits) }}</td>
<td>{{ formatNumber(row.balanceBefore) }} -> {{ formatNumber(row.balanceAfter) }}</td>
<td>{{ row.operatorUsername || row.operatorUserId || "-" }}</td>
<td><code>{{ row.idempotencyKey || "-" }}</code></td>
</tr>
</tbody>
</table>
<p v-else class="muted-box">当前筛选下没有 {{ auditTab }} 记录</p>
</section>
<div class="admin-users-layout">
<section class="data-panel admin-users-list-panel">
<header class="panel-header">
@@ -330,7 +438,7 @@ function entitlementLimit(current: unknown, limit: unknown, unlimited: unknown):
</section>
<section class="detail-subpanel">
<h3>Ledger</h3>
<table v-if="ledgerRows.length" class="data-table usage-table"><tbody><tr v-for="row in ledgerRows" :key="row.id"><td>{{ formatDate(row.createdAt) }}<small>{{ row.kind }} / {{ row.reason }}</small></td><td>{{ formatSignedCredits(row.deltaCredits) }}</td><td>{{ formatNumber(row.balanceAfter) }}</td></tr></tbody></table>
<table v-if="ledgerRows.length" class="data-table usage-table"><tbody><tr v-for="row in ledgerRows" :key="row.id"><td>{{ formatDate(row.createdAt) }}<small>{{ row.kind }} / {{ row.reason }} / {{ row.source }}</small></td><td>{{ formatSignedCredits(row.deltaCredits) }}</td><td>{{ formatNumber(row.balanceBefore) }} -> {{ formatNumber(row.balanceAfter) }}<small>{{ row.operatorUsername || row.operatorUserId || "-" }} · {{ row.idempotencyKey || "no-key" }}</small></td></tr></tbody></table>
<p v-else class="muted-box">暂无 ledger</p>
</section>
</div>