feat: add admin usage ledger audit
This commit is contained in:
@@ -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 ?? ""),
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user