Files
2026-06-14 20:52:27 +08:00

271 lines
12 KiB
Go

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
}