完善 API Key 能力范围可视化和维护
This commit is contained in:
@@ -19,6 +19,14 @@ type walletBalanceRequest struct {
|
||||
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 不允许为负数。
|
||||
@@ -95,6 +103,80 @@ func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 查询审计日志。
|
||||
@@ -178,6 +260,12 @@ func walletAdjustmentAuditInput(r *http.Request, actor *auth.User, reason string
|
||||
}
|
||||
}
|
||||
|
||||
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, ",")
|
||||
|
||||
@@ -105,8 +105,7 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
} `json:"apiKey"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
|
||||
"name": "smoke key",
|
||||
"scopes": []string{"chat", "image", "video", "music", "audio"},
|
||||
"name": "smoke key",
|
||||
}, http.StatusCreated, &apiKeyResponse)
|
||||
if !strings.HasPrefix(apiKeyResponse.Secret, "sk-gw-") || apiKeyResponse.APIKey.Status != "active" {
|
||||
t.Fatalf("unexpected api key response: %+v", apiKeyResponse)
|
||||
@@ -156,14 +155,22 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
if !floatNear(walletAdjustment.Account.Balance, 1000) || walletAdjustment.AuditLog.Action != "wallet.balance.set" {
|
||||
t.Fatalf("wallet adjustment did not update balance and audit log: %+v", walletAdjustment)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/users/"+smokeGatewayUserID+"/wallet/recharge", loginResponse.AccessToken, map[string]any{
|
||||
"currency": "resource",
|
||||
"amount": 125,
|
||||
"reason": "seed integration wallet recharge",
|
||||
}, http.StatusOK, &walletAdjustment)
|
||||
if !floatNear(walletAdjustment.Account.Balance, 1125) || walletAdjustment.AuditLog.Action != "wallet.balance.recharge" {
|
||||
t.Fatalf("wallet recharge did not add balance and audit log: %+v", walletAdjustment)
|
||||
}
|
||||
var auditResponse struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/admin/audit-logs?category=billing&limit=5", loginResponse.AccessToken, nil, http.StatusOK, &auditResponse)
|
||||
if len(auditResponse.Items) == 0 || auditResponse.Items[0].Action != "wallet.balance.set" {
|
||||
t.Fatalf("wallet adjustment audit log not found: %+v", auditResponse)
|
||||
if len(auditResponse.Items) == 0 || auditResponse.Items[0].Action != "wallet.balance.recharge" {
|
||||
t.Fatalf("wallet recharge audit log not found: %+v", auditResponse)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/admin/models", apiKeyResponse.Secret, nil, http.StatusForbidden, nil)
|
||||
|
||||
|
||||
@@ -622,6 +622,45 @@ func (s *Server) listUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// listCurrentUserGroups godoc
|
||||
// @Summary 获取当前用户组策略
|
||||
// @Description 返回当前用户关联的用户组及策略摘要,供个人中心使用。
|
||||
// @Tags workspace
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} UserGroupListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/user-groups [get]
|
||||
func (s *Server) listCurrentUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
groupIDs := compactAuthStrings(user.UserGroupID)
|
||||
groupKeys := compactAuthStrings(user.UserGroupKey)
|
||||
groupKeys = append(groupKeys, compactAuthStrings(user.UserGroupKeys...)...)
|
||||
items, err := s.store.ListUserGroupsBySubject(r.Context(), groupIDs, groupKeys)
|
||||
if err != nil {
|
||||
s.logger.Error("list current user groups failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list current user groups failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func compactAuthStrings(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listAPIKeys godoc
|
||||
// @Summary 列出 API Key
|
||||
// @Description 返回当前用户创建的 API Key 元数据,secret 只在创建时返回。
|
||||
@@ -702,6 +741,45 @@ func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// updateAPIKeyScopes godoc
|
||||
// @Summary 更新 API Key 能力范围
|
||||
// @Description 更新当前用户拥有的 API Key scopes,至少需要保留一个能力范围。
|
||||
// @Tags api-keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param apiKeyID path string true "API Key ID"
|
||||
// @Param input body store.UpdateAPIKeyScopesInput true "API Key scope 更新请求"
|
||||
// @Success 200 {object} store.APIKey
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys/{apiKeyID}/scopes [patch]
|
||||
func (s *Server) updateAPIKeyScopes(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
var input store.UpdateAPIKeyScopesInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
item, err := s.store.UpdateAPIKeyScopes(r.Context(), r.PathValue("apiKeyID"), input, user)
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrLocalUserRequired) || errors.Is(err, store.ErrInvalidAPIKeyScopes) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "api key not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("update api key scopes failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update api key scopes failed")
|
||||
}
|
||||
|
||||
// disableAPIKey godoc
|
||||
// @Summary 禁用 API Key
|
||||
// @Description 禁用当前用户拥有的 API Key,保留记录但不再允许调用。
|
||||
|
||||
@@ -68,6 +68,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/admin/users", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser)))
|
||||
mux.Handle("PATCH /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser)))
|
||||
mux.Handle("PATCH /api/admin/users/{userID}/wallet", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.setUserWalletBalance)))
|
||||
mux.Handle("POST /api/admin/users/{userID}/wallet/recharge", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rechargeUserWalletBalance)))
|
||||
mux.Handle("DELETE /api/admin/users/{userID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteGatewayUser)))
|
||||
mux.Handle("GET /api/admin/audit-logs", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAuditLogs)))
|
||||
mux.Handle("GET /api/admin/user-groups", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listUserGroups)))
|
||||
@@ -83,9 +84,11 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
|
||||
mux.Handle("GET /api/v1/api-keys/access-rules", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
|
||||
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
|
||||
mux.Handle("GET /api/workspace/user-groups", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups)))
|
||||
mux.Handle("GET /api/workspace/wallet", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
|
||||
mux.Handle("GET /api/workspace/wallet/transactions", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
|
||||
mux.Handle("GET /api/workspace/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
|
||||
Reference in New Issue
Block a user