diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 96aee5c..bb534b9 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -3575,6 +3575,82 @@ } } }, + "/api/admin/users/{userID}/wallet/recharge": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "管理端给指定用户钱包追加充值金额,并记录审计日志;amount 必须大于 0。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "billing" + ], + "summary": "充值用户钱包余额", + "parameters": [ + { + "type": "string", + "description": "用户 ID", + "name": "userID", + "in": "path", + "required": true + }, + { + "description": "钱包充值请求", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.walletRechargeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.WalletAdjustmentResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/playground/api-keys": { "get": { "security": [ @@ -3935,6 +4011,76 @@ } } }, + "/api/v1/api-keys/{apiKeyID}/scopes": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "更新当前用户拥有的 API Key scopes,至少需要保留一个能力范围。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-keys" + ], + "summary": "更新 API Key 能力范围", + "parameters": [ + { + "type": "string", + "description": "API Key ID", + "name": "apiKeyID", + "in": "path", + "required": true + }, + { + "description": "API Key scope 更新请求", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/store.UpdateAPIKeyScopesInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.APIKey" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/v1/auth/login": { "post": { "description": "使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。", @@ -5828,6 +5974,43 @@ } } }, + "/api/workspace/user-groups": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "返回当前用户关联的用户组及策略摘要,供个人中心使用。", + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "获取当前用户组策略", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.UserGroupListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/workspace/wallet": { "get": { "security": [ @@ -8937,6 +9120,31 @@ } } }, + "httpapi.walletRechargeRequest": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "currency": { + "type": "string", + "example": "resource" + }, + "idempotencyKey": { + "type": "string", + "example": "wallet-recharge-20260514-001" + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "reason": { + "type": "string", + "example": "manual recharge" + } + } + }, "store.APIKey": { "type": "object", "properties": { @@ -11220,6 +11428,17 @@ } } }, + "store.UpdateAPIKeyScopesInput": { + "type": "object", + "properties": { + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "store.UserGroup": { "type": "object", "properties": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 0574246..af8bb84 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -731,6 +731,24 @@ definitions: example: manual recharge type: string type: object + httpapi.walletRechargeRequest: + properties: + amount: + example: 100 + type: number + currency: + example: resource + type: string + idempotencyKey: + example: wallet-recharge-20260514-001 + type: string + metadata: + additionalProperties: {} + type: object + reason: + example: manual recharge + type: string + type: object store.APIKey: properties: createdAt: @@ -2278,6 +2296,13 @@ definitions: taskId: type: string type: object + store.UpdateAPIKeyScopesInput: + properties: + scopes: + items: + type: string + type: array + type: object store.UserGroup: properties: billingDiscountPolicy: @@ -4642,6 +4667,55 @@ paths: summary: 设置用户钱包余额 tags: - billing + /api/admin/users/{userID}/wallet/recharge: + post: + consumes: + - application/json + description: 管理端给指定用户钱包追加充值金额,并记录审计日志;amount 必须大于 0。 + parameters: + - description: 用户 ID + in: path + name: userID + required: true + type: string + - description: 钱包充值请求 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.walletRechargeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.WalletAdjustmentResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 充值用户钱包余额 + tags: + - billing /api/playground/api-keys: get: description: 返回当前本地用户可在 Playground 中直接使用的 API Key 和 secret。 @@ -4799,6 +4873,51 @@ paths: summary: 禁用 API Key tags: - api-keys + /api/v1/api-keys/{apiKeyID}/scopes: + patch: + consumes: + - application/json + description: 更新当前用户拥有的 API Key scopes,至少需要保留一个能力范围。 + parameters: + - description: API Key ID + in: path + name: apiKeyID + required: true + type: string + - description: API Key scope 更新请求 + in: body + name: input + required: true + schema: + $ref: '#/definitions/store.UpdateAPIKeyScopesInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.APIKey' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 更新 API Key 能力范围 + tags: + - api-keys /api/v1/api-keys/access-rules: get: description: 返回当前本地用户可管理的 API Key 访问规则。 @@ -6096,6 +6215,29 @@ paths: summary: 获取任务参数预处理日志 tags: - tasks + /api/workspace/user-groups: + get: + description: 返回当前用户关联的用户组及策略摘要,供个人中心使用。 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.UserGroupListResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 获取当前用户组策略 + tags: + - workspace /api/workspace/wallet: get: description: 返回当前用户的钱包账户、余额和最近消费摘要,可按 currency 过滤。 diff --git a/apps/api/internal/httpapi/billing_admin_handlers.go b/apps/api/internal/httpapi/billing_admin_handlers.go index ac10a96..5c0ef38 100644 --- a/apps/api/internal/httpapi/billing_admin_handlers.go +++ b/apps/api/internal/httpapi/billing_admin_handlers.go @@ -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, ",") diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index a5007d4..083c325 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -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) diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 40e32d1..f19d21c 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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,保留记录但不再允许调用。 diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index be3e9fa..8c9f74b 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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))) diff --git a/apps/api/internal/store/api_key_scopes_test.go b/apps/api/internal/store/api_key_scopes_test.go new file mode 100644 index 0000000..32377d1 --- /dev/null +++ b/apps/api/internal/store/api_key_scopes_test.go @@ -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) + } +} diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index a907baa..0d8a61c 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -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 == "" { diff --git a/apps/api/internal/store/wallet.go b/apps/api/internal/store/wallet.go index e3cadff..2a58d63 100644 --- a/apps/api/internal/store/wallet.go +++ b/apps/api/internal/store/wallet.go @@ -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, ` diff --git a/apps/api/migrations/0047_default_api_key_audio_scopes.sql b/apps/api/migrations/0047_default_api_key_audio_scopes.sql new file mode 100644 index 0000000..91d27b4 --- /dev/null +++ b/apps/api/migrations/0047_default_api_key_audio_scopes.sql @@ -0,0 +1,11 @@ +UPDATE gateway_api_keys +SET scopes = '["chat","embedding","rerank","image","video","music","audio"]'::jsonb, + updated_at = now() +WHERE deleted_at IS NULL + AND ( + (scopes @> '["chat","image","video"]'::jsonb + AND scopes <@ '["chat","image","video"]'::jsonb) + OR + (scopes @> '["chat","embedding","rerank","image","video"]'::jsonb + AND scopes <@ '["chat","embedding","rerank","image","video"]'::jsonb) + ); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d661428..e44ccc8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,6 +10,7 @@ import type { GatewayAccessRule, GatewayAccessRuleUpsertRequest, GatewayApiKey, + GatewayApiKeyScopeUpdateRequest, GatewayAuditLog, GatewayNetworkProxyConfig, GatewayRunnerPolicy, @@ -31,7 +32,7 @@ import type { RuntimePolicySet, UserGroupUpsertRequest, UserGroup, - WalletBalanceAdjustmentRequest, + WalletRechargeRequest, } from '@easyai-ai-gateway/contracts'; import { batchAccessRules, @@ -53,6 +54,7 @@ import { GatewayApiError, getHealth, listFileStorageChannels, + getCurrentUser, getFileStorageSettings, getNetworkProxyConfig, getRunnerPolicy, @@ -63,6 +65,7 @@ import { listApiKeys, listBaseModels, listCatalogProviders, + listCurrentUserGroups, listModelCatalog, listModelRateLimitStatuses, listModels, @@ -83,11 +86,12 @@ import { loginLocalAccount, pollTaskUntilSettled, registerLocalAccount, + rechargeUserWalletBalance, replacePlatformModels, restoreModelRuntimeStatus, - setUserWalletBalance, type HealthResponse, updateAccessRule, + updateApiKeyScopes, updateFileStorageChannel, updateFileStorageSettings, updateGatewayUser, @@ -142,6 +146,8 @@ import type { type DataKey = | 'health' + | 'currentUser' + | 'currentUserGroups' | 'publicCatalog' | 'playgroundApiKeys' | 'playgroundModels' @@ -184,6 +190,8 @@ export function App() { const [loginForm, setLoginForm] = useState({ account: '', password: '' }); const [registerForm, setRegisterForm] = useState({ username: '', email: '', password: '', displayName: '', invitationCode: '' }); const [health, setHealth] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [currentUserGroups, setCurrentUserGroups] = useState([]); const [platforms, setPlatforms] = useState([]); const [models, setModels] = useState([]); const [modelCatalog, setModelCatalog] = useState({ @@ -313,6 +321,8 @@ export function App() { auditLogs, apiKeys, baseModels, + currentUser, + currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, @@ -334,7 +344,7 @@ export function App() { users, walletAccounts, walletTransactions, - }), [accessRules, apiKeys, auditLogs, baseModels, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]); + }), [accessRules, apiKeys, auditLogs, baseModels, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]); async function refresh(nextToken = token) { await ensureRouteData(nextToken, true); @@ -387,6 +397,12 @@ export function App() { setHealth(await getHealth()); return; } + case 'currentUser': + setCurrentUser(await getCurrentUser(nextToken)); + return; + case 'currentUserGroups': + setCurrentUserGroups((await listCurrentUserGroups(nextToken)).items); + return; case 'publicCatalog': { const [providersResult, baseModelsResult] = await Promise.all([ listPublicCatalogProviders(), @@ -541,7 +557,7 @@ export function App() { try { const response = await createApiKey(token, { name: apiKeyForm.name, - scopes: ['chat', 'embedding', 'rerank', 'image', 'video'], + scopes: ['chat', 'embedding', 'rerank', 'image', 'video', 'music', 'audio'], expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined, }); setApiKeySecret(response.secret); @@ -743,11 +759,11 @@ export function App() { } } - async function saveUserWalletBalance(userId: string, input: WalletBalanceAdjustmentRequest) { + async function rechargeUserWallet(userId: string, input: WalletRechargeRequest) { setCoreState('loading'); setCoreMessage(''); try { - const response = await setUserWalletBalance(token, userId, input); + const response = await rechargeUserWalletBalance(token, userId, input); setUsers((current) => current.map((user) => user.id === userId ? { ...user, @@ -760,10 +776,10 @@ export function App() { setAuditLogs((current) => [response.auditLog, ...current.filter((item) => item.id !== response.auditLog.id)]); invalidateDataKeys('auditLogs'); setCoreState('ready'); - setCoreMessage('用户余额已更新,审计日志已记录。'); + setCoreMessage('用户余额已充值,审计日志已记录。'); } catch (err) { setCoreState('error'); - setCoreMessage(err instanceof Error ? err.message : '更新用户余额失败'); + setCoreMessage(err instanceof Error ? err.message : '充值用户余额失败'); throw err; } } @@ -840,6 +856,22 @@ export function App() { } } + async function saveAPIKeyScopes(apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) { + setCoreState('loading'); + setCoreMessage(''); + try { + const item = await updateApiKeyScopes(token, apiKeyId, input); + setApiKeys((current) => current.map((apiKey) => (apiKey.id === item.id ? { ...apiKey, ...item } : apiKey))); + invalidateDataKeys('playgroundApiKeys', 'playgroundModels', 'modelCatalog'); + setCoreState('ready'); + setCoreMessage('API Key 能力范围已更新。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '更新 API Key 能力范围失败'); + throw err; + } + } + async function saveAccessRule(input: GatewayAccessRuleUpsertRequest, ruleId?: string) { setCoreState('loading'); setCoreMessage(''); @@ -988,6 +1020,8 @@ export function App() { loadedDataKeysRef.current = new Set(health ? ['health'] : []); loadingDataKeysRef.current.clear(); setState('idle'); + setCurrentUser(null); + setCurrentUserGroups([]); setPlatforms([]); setModels([]); setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } }); @@ -1143,6 +1177,7 @@ export function App() { onDeleteApiKey={removeAPIKey} onApiKeyFormChange={setApiKeyForm} onSectionChange={navigateWorkspaceSection} + onSaveApiKeyScopes={saveAPIKeyScopes} onSubmitApiKey={submitAPIKey} onTaskQueryChange={navigateWorkspaceTaskQuery} onTransactionQueryChange={setWorkspaceTransactionQuery} @@ -1200,7 +1235,7 @@ export function App() { onSaveFileStorageSettings={saveFileStorageSettings} onSaveTenant={saveTenant} onSaveUser={saveUser} - onSetUserWalletBalance={saveUserWalletBalance} + onRechargeUserWalletBalance={rechargeUserWallet} onSaveUserGroup={saveUserGroup} onClearOperationMessage={() => setCoreMessage('')} onSectionChange={navigateAdminSection} @@ -1382,7 +1417,7 @@ function dataKeysForRoute( if (!isAuthenticated) return []; if (activePage === 'workspace') { - if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys']; + if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys']; if (workspaceSection === 'billing') return ['wallet']; if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels']; if (workspaceSection === 'tasks') return ['tasks']; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9386fcb..6846797 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,5 +1,6 @@ import type { AuthResponse, + AuthUser, BaseModelCatalogItem, BaseModelUpsertRequest, CatalogProvider, @@ -13,6 +14,7 @@ import type { GatewayAccessRule, GatewayAccessRuleUpsertRequest, GatewayApiKey, + GatewayApiKeyScopeUpdateRequest, GatewayAuditLog, GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, @@ -43,6 +45,7 @@ import type { UserGroupUpsertRequest, WalletAdjustmentResponse, WalletBalanceAdjustmentRequest, + WalletRechargeRequest, WalletSummaryResponse, } from '@easyai-ai-gateway/contracts'; import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types'; @@ -105,6 +108,10 @@ export async function loginLocalAccount(input: { account: string; password: stri }); } +export async function getCurrentUser(token: string): Promise { + return request('/api/v1/me', { token }); +} + export async function listPlatforms(token: string): Promise> { return request>('/api/admin/platforms', { token }); } @@ -366,6 +373,18 @@ export async function setUserWalletBalance( }); } +export async function rechargeUserWalletBalance( + token: string, + userId: string, + input: WalletRechargeRequest, +): Promise { + return request(`/api/admin/users/${userId}/wallet/recharge`, { + body: input, + method: 'POST', + token, + }); +} + export async function deleteGatewayUser(token: string, userId: string): Promise { await request(`/api/admin/users/${userId}`, { method: 'DELETE', @@ -381,6 +400,10 @@ export async function listUserGroups(token: string): Promise>('/api/admin/user-groups', { token }); } +export async function listCurrentUserGroups(token: string): Promise> { + return request>('/api/workspace/user-groups', { token }); +} + export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise { return request('/api/admin/user-groups', { body: input, @@ -474,6 +497,14 @@ export async function createApiKey( }); } +export async function updateApiKeyScopes(token: string, apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest): Promise { + return request(`/api/v1/api-keys/${apiKeyId}/scopes`, { + body: input, + method: 'PATCH', + token, + }); +} + export async function deleteApiKey(token: string, apiKeyId: string): Promise { await request(`/api/v1/api-keys/${apiKeyId}`, { method: 'DELETE', diff --git a/apps/web/src/app-state.ts b/apps/web/src/app-state.ts index 6b5e8bb..e01ef9e 100644 --- a/apps/web/src/app-state.ts +++ b/apps/web/src/app-state.ts @@ -1,4 +1,5 @@ import type { + AuthUser, BaseModelCatalogItem, CatalogProvider, FileStorageChannel, @@ -29,6 +30,8 @@ export interface ConsoleData { auditLogs: GatewayAuditLog[]; apiKeys: GatewayApiKey[]; baseModels: BaseModelCatalogItem[]; + currentUser: AuthUser | null; + currentUserGroups: UserGroup[]; fileStorageChannels: FileStorageChannel[]; fileStorageSettings: FileStorageSettings | null; modelCatalog: ModelCatalogResponse; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 7a24623..6e31ae8 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -15,7 +15,7 @@ import type { PricingRuleSetUpsertRequest, RuntimePolicySetUpsertRequest, UserGroupUpsertRequest, - WalletBalanceAdjustmentRequest, + WalletRechargeRequest, } from '@easyai-ai-gateway/contracts'; import type { ConsoleData, StatItem } from '../app-state'; import { EntityTable } from '../components/EntityTable'; @@ -82,7 +82,7 @@ export function AdminPage(props: { onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise; onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise; onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise; - onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise; + onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise; onClearOperationMessage: () => void; onSectionChange: (value: AdminSection) => void; @@ -90,6 +90,7 @@ export function AdminPage(props: { return (
Promise; onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise; onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise; - onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise; + onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise; }) { return { @@ -219,7 +220,7 @@ function identityPanelProps(props: { onDeleteUserGroup: props.onDeleteUserGroup, onSaveTenant: props.onSaveTenant, onSaveUser: props.onSaveUser, - onSetUserWalletBalance: props.onSetUserWalletBalance, + onRechargeUserWalletBalance: props.onRechargeUserWalletBalance, onSaveUserGroup: props.onSaveUserGroup, }; } diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 2e779e1..b7a6460 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'; import { Popover as AntPopover } from 'antd'; import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react'; -import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; +import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { ConsoleData } from '../app-state'; import { EntityTable } from '../components/EntityTable'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui'; import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor'; import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types'; import { listTaskParamPreprocessing } from '../api'; @@ -19,6 +19,19 @@ const tabs = [ const taskPageSizeOptions = [10, 20, 50]; +const apiKeyScopeOptions = [ + { value: 'chat', label: '对话', description: 'Chat Completions / Responses' }, + { value: 'embedding', label: '向量', description: 'Embeddings' }, + { value: 'rerank', label: '重排', description: 'Reranks' }, + { value: 'image', label: '图像', description: 'Images' }, + { value: 'video', label: '视频', description: 'Videos' }, + { value: 'music', label: '音乐生成', description: 'Song / Music' }, + { value: 'audio', label: '语音合成', description: 'Speech / TTS' }, + { value: 'all', label: '全部能力', description: '不按接口能力限制' }, +] as const; + +const knownApiKeyScopes = new Set(apiKeyScopeOptions.map((item) => item.value)); + export function WorkspacePage(props: { apiKeyForm: ApiKeyForm; apiKeySecret: string; @@ -37,6 +50,7 @@ export function WorkspacePage(props: { onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; onSectionChange: (value: WorkspaceSection) => void; + onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise; onSubmitApiKey: (event: FormEvent) => void | Promise; onTaskQueryChange: (value: WorkspaceTaskQuery) => void; onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void; @@ -59,7 +73,8 @@ export function WorkspacePage(props: { } function WorkspaceOverview(props: { data: ConsoleData }) { - const owner = props.data.users[0]; + const owner = props.data.currentUser; + const groupRows = currentUserGroupRows(owner, props.data.currentUserGroups); return (
@@ -81,7 +96,7 @@ function WorkspaceOverview(props: { data: ConsoleData }) { [item.groupKey, item.priority, item.status, item.source])} + rows={groupRows} /> @@ -89,6 +104,23 @@ function WorkspaceOverview(props: { data: ConsoleData }) { ); } +function currentUserGroupRows(owner: ConsoleData['currentUser'], groups: ConsoleData['userGroups']) { + if (!owner) return []; + const ownerGroupKeys = Array.from(new Set([ + owner.userGroupKey, + ...(owner.userGroupKeys ?? []), + ].filter((value): value is string => Boolean(value?.trim())))); + const ownerGroupIds = new Set([ + owner.userGroupId, + ...(owner.userGroupKeys ?? []), + ].filter((value): value is string => Boolean(value?.trim()))); + const matchedGroups = groups.filter((group) => ownerGroupIds.has(group.id) || ownerGroupKeys.includes(group.groupKey)); + if (matchedGroups.length) { + return matchedGroups.map((item) => [item.groupKey, item.priority, item.status, item.source]); + } + return ownerGroupKeys.map((groupKey) => [groupKey, '-', '-', owner.source ?? 'gateway']); +} + function BillingPanel(props: { walletAccounts: GatewayWalletAccount[] }) { const primaryWallet = primaryWalletAccount(props.walletAccounts); const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0; @@ -364,17 +396,25 @@ function ApiKeyPanel(props: { onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; + onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise; onSubmitApiKey: (event: FormEvent) => void | Promise; onUseApiKeyForPlayground: (apiKeyId?: string) => void; }) { const [createOpen, setCreateOpen] = useState(false); const [policyApiKeyId, setPolicyApiKeyId] = useState(''); + const [scopeApiKeyId, setScopeApiKeyId] = useState(''); + const [scopeDraft, setScopeDraft] = useState([]); + const [scopeError, setScopeError] = useState(''); const [pendingDelete, setPendingDelete] = useState(null); const [localMessage, setLocalMessage] = useState(''); const selectedPolicyKey = useMemo( () => props.data.apiKeys.find((item) => item.id === policyApiKeyId), [policyApiKeyId, props.data.apiKeys], ); + const selectedScopeKey = useMemo( + () => props.data.apiKeys.find((item) => item.id === scopeApiKeyId), + [scopeApiKeyId, props.data.apiKeys], + ); const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]); async function copyApiKey(item: GatewayApiKey) { @@ -399,6 +439,34 @@ function ApiKeyPanel(props: { setPendingDelete(null); } + function openScopeDialog(item: GatewayApiKey) { + setScopeApiKeyId(item.id); + setScopeDraft(normalizeApiKeyScopes(item.scopes)); + setScopeError(''); + } + + function closeScopeDialog() { + setScopeApiKeyId(''); + setScopeDraft([]); + setScopeError(''); + } + + async function submitScopes(event: FormEvent) { + event.preventDefault(); + if (!selectedScopeKey) return; + const scopes = normalizeApiKeyScopes(scopeDraft); + if (!scopes.length) { + setScopeError('至少保留一个能力范围。'); + return; + } + try { + await props.onSaveApiKeyScopes(selectedScopeKey.id, { scopes }); + closeScopeDialog(); + } catch { + return; + } + } + return ( <> @@ -418,6 +486,7 @@ function ApiKeyPanel(props: { 名称 API Key + 能力范围 权限策略 最近使用 有效期 @@ -451,6 +520,14 @@ function ApiKeyPanel(props: { + + + + + + + + + )} + open={Boolean(selectedScopeKey)} + title={selectedScopeKey ? `能力范围:${selectedScopeKey.name}` : '能力范围'} + onClose={closeScopeDialog} + onSubmit={(event) => void submitScopes(event)} + > + {scopeError &&

{scopeError}

} + { + setScopeDraft(scopes); + if (scopeError) setScopeError(''); + }} /> + + void; +}) { + const scopes = normalizeApiKeyScopes(props.value); + const customScopes = scopes.filter((scope) => !knownApiKeyScopes.has(scope as (typeof apiKeyScopeOptions)[number]['value'])); + const customValue = customScopes.join(', '); + + function setKnownScope(scope: string, checked: boolean) { + const next = new Set(scopes); + if (checked) { + if (scope === 'all') { + for (const option of apiKeyScopeOptions) { + next.delete(option.value); + } + } else { + next.delete('all'); + } + next.add(scope); + } else { + next.delete(scope); + } + props.onChange(Array.from(next)); + } + + function setCustomScopes(value: string) { + const known = scopes.filter((scope) => knownApiKeyScopes.has(scope as (typeof apiKeyScopeOptions)[number]['value'])); + props.onChange(normalizeApiKeyScopes([...known, ...value.split(',')])); + } + + return ( +
+
+ {apiKeyScopeOptions.map((option) => ( + + ))} +
+ +
+ ); +} + function TaskPanel(props: { data: ConsoleData; query: WorkspaceTaskQuery; @@ -1429,6 +1588,40 @@ function apiKeySecretFor(item: GatewayApiKey, secretsById: Record未配置; + } + const visibleScopes = scopes.includes('all') || scopes.includes('*') ? ['all'] : scopes.slice(0, 3); + const hiddenCount = scopes.length - visibleScopes.length; + return ( + + {visibleScopes.map((scope) => ( + {apiKeyScopeLabel(scope)} + ))} + {hiddenCount > 0 && +{hiddenCount}} + + ); +} + +function normalizeApiKeyScopes(scopes?: string[]) { + const out: string[] = []; + const seen = new Set(); + for (const value of scopes ?? []) { + let scope = value.trim().toLowerCase(); + if (scope === '*') scope = 'all'; + if (!scope || seen.has(scope)) continue; + seen.add(scope); + out.push(scope); + } + return out; +} + +function apiKeyScopeLabel(scope: string) { + return apiKeyScopeOptions.find((item) => item.value === scope)?.label ?? scope; +} + function maskApiKey(secret: string) { if (secret.length <= 18) return secret; return `${secret.slice(0, 12)}...${secret.slice(-4)}`; diff --git a/apps/web/src/pages/admin/AuditLogsPanel.tsx b/apps/web/src/pages/admin/AuditLogsPanel.tsx index 7bc6cb7..e4bc699 100644 --- a/apps/web/src/pages/admin/AuditLogsPanel.tsx +++ b/apps/web/src/pages/admin/AuditLogsPanel.tsx @@ -62,6 +62,7 @@ export function AuditLogsPanel(props: { auditLogs: GatewayAuditLog[]; message?: function actionLabel(action: string) { if (action === 'wallet.balance.set') return '余额调整'; + if (action === 'wallet.balance.recharge') return '余额充值'; return action; } diff --git a/apps/web/src/pages/admin/IdentityManagementPanels.tsx b/apps/web/src/pages/admin/IdentityManagementPanels.tsx index 01a65fd..3844df0 100644 --- a/apps/web/src/pages/admin/IdentityManagementPanels.tsx +++ b/apps/web/src/pages/admin/IdentityManagementPanels.tsx @@ -8,7 +8,7 @@ import type { GatewayWalletAccount, UserGroup, UserGroupUpsertRequest, - WalletBalanceAdjustmentRequest, + WalletRechargeRequest, } from '@easyai-ai-gateway/contracts'; import { Badge, @@ -21,6 +21,7 @@ import { FormDialog, Input, Label, + ScreenMessage, Select, Table, TableCell, @@ -68,7 +69,7 @@ type UserForm = { type WalletForm = { currency: string; - balance: string; + amount: string; reason: string; }; @@ -149,11 +150,12 @@ export function TenantsPanel(props: IdentityPanelProps) { return (
+ setLocalError('')} /> } - message={localError || props.operationMessage} + message={identityHeaderMessage(props)} title="租户管理" actionLabel="新增租户" onCreate={openCreateDialog} @@ -256,8 +258,8 @@ export function UsersPanel(props: IdentityPanelProps) { setWalletUser(user); setWalletForm({ currency: wallet?.currency ?? 'resource', - balance: String(wallet?.balance ?? 0), - reason: '', + amount: '', + reason: defaultRechargeReason, }); } @@ -290,9 +292,9 @@ export function UsersPanel(props: IdentityPanelProps) { event.preventDefault(); setLocalError(''); if (!walletUser) return; - const balance = Number(walletForm.balance); - if (!Number.isFinite(balance) || balance < 0) { - setLocalError('余额必须是非负数字'); + const amount = Number(walletForm.amount); + if (!Number.isFinite(amount) || amount <= 0) { + setLocalError('充值金额必须大于 0'); return; } if (!walletForm.reason.trim()) { @@ -300,15 +302,15 @@ export function UsersPanel(props: IdentityPanelProps) { return; } try { - await props.onSetUserWalletBalance(walletUser.id, { + await props.onRechargeUserWalletBalance(walletUser.id, { currency: walletForm.currency, - balance, + amount, reason: walletForm.reason.trim(), idempotencyKey: newIdempotencyKey(), }); closeWalletDialog(); } catch (err) { - setLocalError(err instanceof Error ? err.message : '更新余额失败'); + setLocalError(err instanceof Error ? err.message : '充值用户余额失败'); } } @@ -319,11 +321,12 @@ export function UsersPanel(props: IdentityPanelProps) { return (
+ setLocalError('')} /> } - message={localError || props.operationMessage} + message={identityHeaderMessage(props)} title="用户管理" actionLabel="新增用户" onCreate={openCreateDialog} @@ -400,18 +403,19 @@ export function UsersPanel(props: IdentityPanelProps) { onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined} /> } open={Boolean(walletUser)} - title={`修改余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`} + title={`充值余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`} onClose={closeWalletDialog} onSubmit={submitWallet} > - -