easyai-ai-gateway/apps/api/internal/httpapi/billing_admin_handlers.go

303 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type walletBalanceRequest struct {
Currency string `json:"currency" example:"USD"`
Balance float64 `json:"balance" example:"100"`
Reason string `json:"reason" example:"manual recharge"`
IdempotencyKey string `json:"idempotencyKey" example:"wallet-set-20260514-001"`
Metadata map[string]any `json:"metadata"`
}
type walletRechargeRequest struct {
Currency string `json:"currency" example:"resource"`
Amount float64 `json:"amount" example:"100"`
Reason string `json:"reason" example:"manual recharge"`
IdempotencyKey string `json:"idempotencyKey" example:"wallet-recharge-20260514-001"`
Metadata map[string]any `json:"metadata"`
}
// setUserWalletBalance godoc
// @Summary 设置用户钱包余额
// @Description 管理端把指定用户钱包余额调整到目标值并记录审计日志balance 不允许为负数。
// @Tags billing
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param userID path string true "用户 ID"
// @Param input body walletBalanceRequest true "钱包余额设置请求"
// @Success 200 {object} WalletAdjustmentResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/users/{userID}/wallet [patch]
func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
actor, _ := auth.UserFromContext(r.Context())
var input walletBalanceRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if input.Balance < 0 {
writeError(w, http.StatusBadRequest, "wallet balance cannot be negative")
return
}
gatewayUserID := strings.TrimSpace(r.PathValue("userID"))
reason := strings.TrimSpace(input.Reason)
if reason == "" {
writeError(w, http.StatusBadRequest, "reason is required")
return
}
var result store.WalletAdjustmentResult
var auditLog store.AuditLog
err := s.store.InTx(r.Context(), func(tx store.Tx) error {
next, err := s.store.SetUserWalletBalanceTx(r.Context(), tx, store.WalletBalanceAdjustmentInput{
GatewayUserID: gatewayUserID,
Currency: input.Currency,
Balance: input.Balance,
Reason: reason,
IdempotencyKey: input.IdempotencyKey,
Metadata: input.Metadata,
})
if err != nil {
return err
}
result = next
record, err := s.store.RecordAuditLogTx(r.Context(), tx, walletAdjustmentAuditInput(r, actor, reason, result))
if err != nil {
return err
}
auditLog = record
return nil
})
if err != nil {
switch {
case store.IsNotFound(err):
writeError(w, http.StatusNotFound, "user not found")
case errors.Is(err, store.ErrWalletBalanceUnchanged):
writeError(w, http.StatusBadRequest, "wallet balance is unchanged")
default:
s.logger.Error("set user wallet balance failed", "error", err)
writeError(w, http.StatusInternalServerError, "set user wallet balance failed")
}
return
}
writeJSON(w, http.StatusOK, map[string]any{
"account": result.Account,
"before": result.Before,
"transaction": result.Transaction,
"auditLog": auditLog,
})
}
// rechargeUserWalletBalance godoc
// @Summary 充值用户钱包余额
// @Description 管理端给指定用户钱包追加充值金额并记录审计日志amount 必须大于 0。
// @Tags billing
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param userID path string true "用户 ID"
// @Param input body walletRechargeRequest true "钱包充值请求"
// @Success 200 {object} WalletAdjustmentResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/users/{userID}/wallet/recharge [post]
func (s *Server) rechargeUserWalletBalance(w http.ResponseWriter, r *http.Request) {
actor, _ := auth.UserFromContext(r.Context())
var input walletRechargeRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if input.Amount <= 0 {
writeError(w, http.StatusBadRequest, "wallet recharge amount must be positive")
return
}
gatewayUserID := strings.TrimSpace(r.PathValue("userID"))
reason := strings.TrimSpace(input.Reason)
if reason == "" {
writeError(w, http.StatusBadRequest, "reason is required")
return
}
var result store.WalletAdjustmentResult
var auditLog store.AuditLog
err := s.store.InTx(r.Context(), func(tx store.Tx) error {
next, err := s.store.RechargeUserWalletBalanceTx(r.Context(), tx, store.WalletRechargeInput{
GatewayUserID: gatewayUserID,
Currency: input.Currency,
Amount: input.Amount,
Reason: reason,
IdempotencyKey: input.IdempotencyKey,
Metadata: input.Metadata,
})
if err != nil {
return err
}
result = next
record, err := s.store.RecordAuditLogTx(r.Context(), tx, walletRechargeAuditInput(r, actor, reason, result))
if err != nil {
return err
}
auditLog = record
return nil
})
if err != nil {
switch {
case store.IsNotFound(err):
writeError(w, http.StatusNotFound, "user not found")
default:
s.logger.Error("recharge user wallet balance failed", "error", err)
writeError(w, http.StatusInternalServerError, "recharge user wallet balance failed")
}
return
}
writeJSON(w, http.StatusOK, map[string]any{
"account": result.Account,
"before": result.Before,
"transaction": result.Transaction,
"auditLog": auditLog,
})
}
// listAuditLogs godoc
// @Summary 列出审计日志
// @Description 管理端按分类、动作、目标类型和目标 ID 查询审计日志。
// @Tags billing
// @Produce json
// @Security BearerAuth
// @Param category query string false "审计分类"
// @Param action query string false "审计动作"
// @Param targetType query string false "目标类型"
// @Param targetId query string false "目标 ID"
// @Param limit query int false "返回数量" default(100)
// @Success 200 {object} AuditLogListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/audit-logs [get]
func (s *Server) listAuditLogs(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
limit, err := positiveQueryInt(query.Get("limit"), 100)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
items, err := s.store.ListAuditLogs(r.Context(), store.AuditLogFilter{
Category: query.Get("category"),
Action: query.Get("action"),
TargetType: query.Get("targetType"),
TargetID: query.Get("targetId"),
Limit: limit,
})
if err != nil {
s.logger.Error("list audit logs failed", "error", err)
writeError(w, http.StatusInternalServerError, "list audit logs failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func walletAdjustmentAuditInput(r *http.Request, actor *auth.User, reason string, result store.WalletAdjustmentResult) store.AuditLogInput {
actorGatewayUserID := ""
actorUserID := ""
actorUsername := ""
actorSource := ""
actorRoles := []string(nil)
if actor != nil {
actorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
actorUserID = actor.ID
actorUsername = actor.Username
actorSource = actor.Source
actorRoles = actor.Roles
}
return store.AuditLogInput{
Category: "billing",
Action: "wallet.balance.set",
ActorGatewayUserID: actorGatewayUserID,
ActorUserID: actorUserID,
ActorUsername: actorUsername,
ActorSource: actorSource,
ActorRoles: actorRoles,
TargetType: "gateway_user",
TargetID: result.Account.GatewayUserID,
TargetGatewayUserID: result.Account.GatewayUserID,
TargetGatewayTenantID: result.Account.GatewayTenantID,
RequestIP: requestIP(r),
UserAgent: r.UserAgent(),
BeforeState: map[string]any{
"walletAccount": result.Before,
},
AfterState: map[string]any{
"walletAccount": result.Account,
"transaction": result.Transaction,
},
Metadata: map[string]any{
"reason": reason,
"currency": result.Account.Currency,
"transactionId": result.Transaction.ID,
"amount": result.Transaction.Amount,
"direction": result.Transaction.Direction,
},
}
}
func walletRechargeAuditInput(r *http.Request, actor *auth.User, reason string, result store.WalletAdjustmentResult) store.AuditLogInput {
input := walletAdjustmentAuditInput(r, actor, reason, result)
input.Action = "wallet.balance.recharge"
return input
}
func requestIP(r *http.Request) string {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
func firstNonEmptyText(values ...string) string {
for _, value := range values {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
return ""
}
func uuidText(value string) string {
value = strings.TrimSpace(value)
if len(value) != 36 {
return ""
}
if value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
return ""
}
return value
}