diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 8ac792a6..966745af 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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 ?? ""), diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts index 90c0cd04..04d46a14 100644 --- a/internal/cloud/user-billing-client.ts +++ b/internal/cloud/user-billing-client.ts @@ -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 ""; diff --git a/internal/userbilling/admin_audit.go b/internal/userbilling/admin_audit.go new file mode 100644 index 00000000..31e69b06 --- /dev/null +++ b/internal/userbilling/admin_audit.go @@ -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 +} diff --git a/internal/userbilling/migrations/0003_admin_usage_ledger_audit.sql b/internal/userbilling/migrations/0003_admin_usage_ledger_audit.sql new file mode 100644 index 00000000..e257d73b --- /dev/null +++ b/internal/userbilling/migrations/0003_admin_usage_ledger_audit.sql @@ -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; diff --git a/internal/userbilling/service.go b/internal/userbilling/service.go index 989ef97c..eded3853 100644 --- a/internal/userbilling/service.go +++ b/internal/userbilling/service.go @@ -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() diff --git a/web/hwlab-cloud-web/src/api/usage.ts b/web/hwlab-cloud-web/src/api/usage.ts index 6a05fbca..7b1067e9 100644 --- a/web/hwlab-cloud-web/src/api/usage.ts +++ b/web/hwlab-cloud-web/src/api/usage.ts @@ -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> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }), adminBillingSummary: (): Promise> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }), adminPlans: (): Promise> => fetchJson("/v1/admin/billing/plans", { timeoutMs: 12000, timeoutName: "admin billing plans" }), + adminUsage: (params: AdminAuditParams = {}): Promise>> => fetchJson(auditPath("/v1/admin/billing/usage", params), { timeoutMs: 12000, timeoutName: "admin usage" }), + adminLedger: (params: AdminAuditParams = {}): Promise>> => 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> => { 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> => fetchJson(`/v1/admin/billing/users/${encodeURIComponent(userId)}/status`, { method: "PATCH", body: JSON.stringify({ status }), timeoutMs: 12000, timeoutName: "admin user status" }), performance: (): Promise> => 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; +} diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 8445d4af..c9671a52 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -321,7 +321,8 @@ export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array; 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; 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 } export interface ResourceEntitlementSummary { @@ -425,6 +426,18 @@ export interface AdminBillingUserDetailResponse { [key: string]: unknown; } +export interface AdminAuditResponse { + contractVersion?: string; + rows?: T[]; + count?: number; + total?: number; + pagination?: { page?: number; pageSize?: number; total?: number; totalPages?: number }; + filters?: Record; + proxy?: Record; + valuesRedacted?: boolean; + [key: string]: unknown; +} + export interface AdminBillingMutationResponse { ok?: boolean; userId?: string; diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index 781d9aa2..2e2c0d91 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -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([]); const plans = ref([]); @@ -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(null); +const auditUsageRows = ref([]); +const auditLedgerRows = ref([]); +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 { @@ -160,6 +168,48 @@ async function adjustCredits(): Promise { creditForm.idempotencyKey = ""; await refresh(); await selectUser(userId); + await refreshAudit(); +} + +function auditParams(extra: Record = {}): Record { + const params: Record = { 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 { + 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 { @@ -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); +}