完善 API Key 能力范围可视化和维护

This commit is contained in:
2026-06-07 19:01:32 +08:00
parent dc14866210
commit f47132a653
19 changed files with 1165 additions and 49 deletions
@@ -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)
+78
View File
@@ -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,保留记录但不再允许调用。
+3
View File
@@ -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)))