easyai-ai-gateway/apps/api/internal/httpapi/billing_admin_handlers.go
chensipeng 918dfbfee1 docs(api): 补全 OpenAPI 注释与生成文档
为接口、模型与脚本补齐 Swagger/OpenAPI 注释,生成最新文档,并增加一键生成与查看入口。
2026-05-14 18:18:27 +08:00

215 lines
6.7 KiB
Go
Raw 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"`
}
// 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,
})
}
// 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 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
}