完善 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)))
@@ -0,0 +1,14 @@
package store
import (
"reflect"
"testing"
)
func TestNormalizeAPIKeyScopes(t *testing.T) {
got := normalizeAPIKeyScopes([]string{" Chat ", "AUDIO", "", "chat", "*", "all", " text_to_speech "})
want := []string{"chat", "audio", "all", "text_to_speech"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalize scopes = %#v, want %#v", got, want)
}
}
+84 -2
View File
@@ -22,9 +22,34 @@ type Store struct {
pool *pgxpool.Pool
}
func defaultAPIKeyScopes() []string {
return []string{"chat", "embedding", "rerank", "image", "video", "music", "audio"}
}
func normalizeAPIKeyScopes(scopes []string) []string {
out := make([]string, 0, len(scopes))
seen := make(map[string]struct{}, len(scopes))
for _, scope := range scopes {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
continue
}
if scope == "*" {
scope = "all"
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
out = append(out, scope)
}
return out
}
var (
ErrInvalidCredentials = errors.New("invalid account or password")
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
ErrInvalidAPIKeyScopes = errors.New("api key scopes must not be empty")
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
ErrLocalUserRequired = errors.New("local gateway user is required")
@@ -106,6 +131,10 @@ type CreateAPIKeyInput struct {
ExpiresAt string `json:"expiresAt"`
}
type UpdateAPIKeyScopesInput struct {
Scopes []string `json:"scopes"`
}
type APIKey struct {
ID string `json:"id"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
@@ -1156,6 +1185,32 @@ ORDER BY priority ASC, group_key ASC`)
return items, rows.Err()
}
func (s *Store) ListUserGroupsBySubject(ctx context.Context, groupIDs []string, groupKeys []string) ([]UserGroup, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+userGroupColumns+`
FROM gateway_user_groups
WHERE status = 'active'
AND (
id::text = ANY($1::text[])
OR group_key = ANY($2::text[])
)
ORDER BY priority ASC, group_key ASC`, groupIDs, groupKeys)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]UserGroup, 0)
for rows.Next() {
item, err := scanUserGroup(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListAPIKeys(ctx context.Context, user *auth.User) ([]APIKey, error) {
gatewayUserID := localGatewayUserID(user)
if gatewayUserID == "" {
@@ -1201,7 +1256,7 @@ func (s *Store) ListPlayableAPIKeys(ctx context.Context, user *auth.User) ([]Pla
}
created, err := s.CreateAPIKey(ctx, CreateAPIKeyInput{
Name: "Playground API Key",
Scopes: []string{"chat", "image", "video"},
Scopes: defaultAPIKeyScopes(),
}, user)
if err != nil {
return nil, err
@@ -1256,8 +1311,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, input CreateAPIKeyInput, user
}
scopes := input.Scopes
if len(scopes) == 0 {
scopes = []string{"chat", "image", "video"}
scopes = defaultAPIKeyScopes()
}
scopes = normalizeAPIKeyScopes(scopes)
secret, err := generateAPIKeySecret()
if err != nil {
return CreatedAPIKey{}, err
@@ -1317,6 +1373,32 @@ RETURNING id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text
return CreatedAPIKey{APIKey: item, Secret: secret}, nil
}
func (s *Store) UpdateAPIKeyScopes(ctx context.Context, apiKeyID string, input UpdateAPIKeyScopesInput, user *auth.User) (APIKey, error) {
gatewayUserID := localGatewayUserID(user)
if gatewayUserID == "" {
return APIKey{}, ErrLocalUserRequired
}
scopes := normalizeAPIKeyScopes(input.Scopes)
if len(scopes) == 0 {
return APIKey{}, ErrInvalidAPIKeyScopes
}
scopesJSON, err := json.Marshal(scopes)
if err != nil {
return APIKey{}, err
}
return scanAPIKey(s.pool.QueryRow(ctx, `
UPDATE gateway_api_keys
SET scopes = $3::jsonb, updated_at = now()
WHERE id = $1::uuid AND gateway_user_id = $2::uuid AND deleted_at IS NULL
RETURNING id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
key_prefix, name, scopes, COALESCE(user_group_id::text, ''),
rate_limit_policy, quota_policy, status, COALESCE(expires_at::text, ''),
COALESCE(last_used_at::text, ''), created_at, updated_at`,
apiKeyID, gatewayUserID, string(scopesJSON),
))
}
func (s *Store) DisableAPIKey(ctx context.Context, apiKeyID string, user *auth.User) (APIKey, error) {
gatewayUserID := localGatewayUserID(user)
if gatewayUserID == "" {
+103
View File
@@ -87,6 +87,15 @@ type WalletBalanceAdjustmentInput struct {
Metadata map[string]any `json:"metadata"`
}
type WalletRechargeInput struct {
GatewayUserID string `json:"gatewayUserId"`
Currency string `json:"currency"`
Amount float64 `json:"amount"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotencyKey"`
Metadata map[string]any `json:"metadata"`
}
type WalletAdjustmentResult struct {
Account GatewayWalletAccount `json:"account"`
Before GatewayWalletAccount `json:"before"`
@@ -668,6 +677,100 @@ RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COA
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
}
func (s *Store) RechargeUserWalletBalance(ctx context.Context, input WalletRechargeInput) (WalletAdjustmentResult, error) {
var result WalletAdjustmentResult
err := s.InTx(ctx, func(tx Tx) error {
next, err := s.RechargeUserWalletBalanceTx(ctx, tx, input)
if err != nil {
return err
}
result = next
return nil
})
return result, err
}
func (s *Store) RechargeUserWalletBalanceTx(ctx context.Context, tx Tx, input WalletRechargeInput) (WalletAdjustmentResult, error) {
input.GatewayUserID = strings.TrimSpace(input.GatewayUserID)
if input.GatewayUserID == "" {
return WalletAdjustmentResult{}, ErrLocalUserRequired
}
amount := roundMoney(input.Amount)
if amount <= 0 {
return WalletAdjustmentResult{}, fmt.Errorf("wallet recharge amount must be positive")
}
account, err := s.ensureWalletAccount(ctx, tx, input.GatewayUserID, input.Currency)
if err != nil {
return WalletAdjustmentResult{}, err
}
locked, err := scanWalletAccount(tx.QueryRow(ctx, `
SELECT id::text, COALESCE(gateway_tenant_id::text, ''), gateway_user_id::text,
COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), COALESCE(user_id, ''),
currency, balance::float8, frozen_balance::float8, total_recharged::float8,
total_spent::float8, status, metadata, created_at, updated_at
FROM gateway_wallet_accounts
WHERE id = $1::uuid
FOR UPDATE`, account.ID))
if err != nil {
return WalletAdjustmentResult{}, err
}
before := locked
nextBalance := roundMoney(locked.Balance + amount)
reason := strings.TrimSpace(input.Reason)
if reason == "" {
reason = "后台余额充值"
}
if _, err := tx.Exec(ctx, `
UPDATE gateway_wallet_accounts
SET balance = $2,
total_recharged = total_recharged + $3,
updated_at = now()
WHERE id = $1::uuid`,
locked.ID,
nextBalance,
amount,
); err != nil {
return WalletAdjustmentResult{}, err
}
metadata := mergeObjects(input.Metadata, map[string]any{
"reason": reason,
"previousBalance": roundMoney(before.Balance),
"rechargeAmount": amount,
"targetBalance": nextBalance,
})
metadataJSON, _ := json.Marshal(emptyObjectIfNil(metadata))
transaction, err := scanWalletTransaction(tx.QueryRow(ctx, `
INSERT INTO gateway_wallet_transactions (
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
)
VALUES (
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'recharge',
$4, $5, $6, NULLIF($7, ''), 'gateway_user', $8, $9::jsonb
)
RETURNING id::text, account_id::text, COALESCE(gateway_tenant_id::text, ''), COALESCE(gateway_user_id::text, ''),
direction, transaction_type, amount::float8, balance_before::float8, balance_after::float8,
COALESCE(idempotency_key, ''), COALESCE(reference_type, ''), COALESCE(reference_id, ''),
metadata, created_at`,
locked.ID,
locked.GatewayTenantID,
locked.GatewayUserID,
amount,
roundMoney(before.Balance),
nextBalance,
strings.TrimSpace(input.IdempotencyKey),
locked.GatewayUserID,
string(metadataJSON),
))
if err != nil {
return WalletAdjustmentResult{}, err
}
locked.Balance = nextBalance
locked.TotalRecharged = roundMoney(locked.TotalRecharged + amount)
locked.UpdatedAt = time.Now()
return WalletAdjustmentResult{Account: locked, Before: before, Transaction: transaction}, nil
}
func (s *Store) ensureWalletAccount(ctx context.Context, q Tx, gatewayUserID string, currency string) (GatewayWalletAccount, error) {
currency = normalizeWalletCurrency(currency)
if _, err := q.Exec(ctx, `