完善 API Key 能力范围可视化和维护
This commit is contained in:
@@ -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": {
|
"/api/playground/api-keys": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"/api/v1/auth/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。",
|
"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": {
|
"/api/workspace/wallet": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"store.APIKey": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -11220,6 +11428,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"store.UpdateAPIKeyScopesInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"store.UserGroup": {
|
"store.UserGroup": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -731,6 +731,24 @@ definitions:
|
|||||||
example: manual recharge
|
example: manual recharge
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
store.APIKey:
|
||||||
properties:
|
properties:
|
||||||
createdAt:
|
createdAt:
|
||||||
@@ -2278,6 +2296,13 @@ definitions:
|
|||||||
taskId:
|
taskId:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
store.UpdateAPIKeyScopesInput:
|
||||||
|
properties:
|
||||||
|
scopes:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
store.UserGroup:
|
store.UserGroup:
|
||||||
properties:
|
properties:
|
||||||
billingDiscountPolicy:
|
billingDiscountPolicy:
|
||||||
@@ -4642,6 +4667,55 @@ paths:
|
|||||||
summary: 设置用户钱包余额
|
summary: 设置用户钱包余额
|
||||||
tags:
|
tags:
|
||||||
- billing
|
- 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:
|
/api/playground/api-keys:
|
||||||
get:
|
get:
|
||||||
description: 返回当前本地用户可在 Playground 中直接使用的 API Key 和 secret。
|
description: 返回当前本地用户可在 Playground 中直接使用的 API Key 和 secret。
|
||||||
@@ -4799,6 +4873,51 @@ paths:
|
|||||||
summary: 禁用 API Key
|
summary: 禁用 API Key
|
||||||
tags:
|
tags:
|
||||||
- api-keys
|
- 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:
|
/api/v1/api-keys/access-rules:
|
||||||
get:
|
get:
|
||||||
description: 返回当前本地用户可管理的 API Key 访问规则。
|
description: 返回当前本地用户可管理的 API Key 访问规则。
|
||||||
@@ -6096,6 +6215,29 @@ paths:
|
|||||||
summary: 获取任务参数预处理日志
|
summary: 获取任务参数预处理日志
|
||||||
tags:
|
tags:
|
||||||
- tasks
|
- 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:
|
/api/workspace/wallet:
|
||||||
get:
|
get:
|
||||||
description: 返回当前用户的钱包账户、余额和最近消费摘要,可按 currency 过滤。
|
description: 返回当前用户的钱包账户、余额和最近消费摘要,可按 currency 过滤。
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ type walletBalanceRequest struct {
|
|||||||
Metadata map[string]any `json:"metadata"`
|
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
|
// setUserWalletBalance godoc
|
||||||
// @Summary 设置用户钱包余额
|
// @Summary 设置用户钱包余额
|
||||||
// @Description 管理端把指定用户钱包余额调整到目标值,并记录审计日志;balance 不允许为负数。
|
// @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
|
// listAuditLogs godoc
|
||||||
// @Summary 列出审计日志
|
// @Summary 列出审计日志
|
||||||
// @Description 管理端按分类、动作、目标类型和目标 ID 查询审计日志。
|
// @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 {
|
func requestIP(r *http.Request) string {
|
||||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
|
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
|
||||||
parts := strings.Split(forwarded, ",")
|
parts := strings.Split(forwarded, ",")
|
||||||
|
|||||||
@@ -105,8 +105,7 @@ func TestCoreLocalFlow(t *testing.T) {
|
|||||||
} `json:"apiKey"`
|
} `json:"apiKey"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
|
||||||
"name": "smoke key",
|
"name": "smoke key",
|
||||||
"scopes": []string{"chat", "image", "video", "music", "audio"},
|
|
||||||
}, http.StatusCreated, &apiKeyResponse)
|
}, http.StatusCreated, &apiKeyResponse)
|
||||||
if !strings.HasPrefix(apiKeyResponse.Secret, "sk-gw-") || apiKeyResponse.APIKey.Status != "active" {
|
if !strings.HasPrefix(apiKeyResponse.Secret, "sk-gw-") || apiKeyResponse.APIKey.Status != "active" {
|
||||||
t.Fatalf("unexpected api key response: %+v", apiKeyResponse)
|
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" {
|
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)
|
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 {
|
var auditResponse struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodGet, "/api/admin/audit-logs?category=billing&limit=5", loginResponse.AccessToken, nil, http.StatusOK, &auditResponse)
|
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" {
|
if len(auditResponse.Items) == 0 || auditResponse.Items[0].Action != "wallet.balance.recharge" {
|
||||||
t.Fatalf("wallet adjustment audit log not found: %+v", auditResponse)
|
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)
|
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})
|
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
|
// listAPIKeys godoc
|
||||||
// @Summary 列出 API Key
|
// @Summary 列出 API Key
|
||||||
// @Description 返回当前用户创建的 API Key 元数据,secret 只在创建时返回。
|
// @Description 返回当前用户创建的 API Key 元数据,secret 只在创建时返回。
|
||||||
@@ -702,6 +741,45 @@ func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, created)
|
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
|
// disableAPIKey godoc
|
||||||
// @Summary 禁用 API Key
|
// @Summary 禁用 API Key
|
||||||
// @Description 禁用当前用户拥有的 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("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}", 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("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("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/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)))
|
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("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("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("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("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("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/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", 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/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)))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,34 @@ type Store struct {
|
|||||||
pool *pgxpool.Pool
|
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 (
|
var (
|
||||||
ErrInvalidCredentials = errors.New("invalid account or password")
|
ErrInvalidCredentials = errors.New("invalid account or password")
|
||||||
ErrInvalidInvitation = errors.New("invalid or expired invitation code")
|
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")
|
ErrAccessRuleResourceDenied = errors.New("access rule resource is not available")
|
||||||
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
|
ErrInsufficientWalletBalance = errors.New("insufficient wallet balance")
|
||||||
ErrLocalUserRequired = errors.New("local gateway user is required")
|
ErrLocalUserRequired = errors.New("local gateway user is required")
|
||||||
@@ -106,6 +131,10 @@ type CreateAPIKeyInput struct {
|
|||||||
ExpiresAt string `json:"expiresAt"`
|
ExpiresAt string `json:"expiresAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateAPIKeyScopesInput struct {
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||||
@@ -1156,6 +1185,32 @@ ORDER BY priority ASC, group_key ASC`)
|
|||||||
return items, rows.Err()
|
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) {
|
func (s *Store) ListAPIKeys(ctx context.Context, user *auth.User) ([]APIKey, error) {
|
||||||
gatewayUserID := localGatewayUserID(user)
|
gatewayUserID := localGatewayUserID(user)
|
||||||
if gatewayUserID == "" {
|
if gatewayUserID == "" {
|
||||||
@@ -1201,7 +1256,7 @@ func (s *Store) ListPlayableAPIKeys(ctx context.Context, user *auth.User) ([]Pla
|
|||||||
}
|
}
|
||||||
created, err := s.CreateAPIKey(ctx, CreateAPIKeyInput{
|
created, err := s.CreateAPIKey(ctx, CreateAPIKeyInput{
|
||||||
Name: "Playground API Key",
|
Name: "Playground API Key",
|
||||||
Scopes: []string{"chat", "image", "video"},
|
Scopes: defaultAPIKeyScopes(),
|
||||||
}, user)
|
}, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1256,8 +1311,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, input CreateAPIKeyInput, user
|
|||||||
}
|
}
|
||||||
scopes := input.Scopes
|
scopes := input.Scopes
|
||||||
if len(scopes) == 0 {
|
if len(scopes) == 0 {
|
||||||
scopes = []string{"chat", "image", "video"}
|
scopes = defaultAPIKeyScopes()
|
||||||
}
|
}
|
||||||
|
scopes = normalizeAPIKeyScopes(scopes)
|
||||||
secret, err := generateAPIKeySecret()
|
secret, err := generateAPIKeySecret()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CreatedAPIKey{}, err
|
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
|
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) {
|
func (s *Store) DisableAPIKey(ctx context.Context, apiKeyID string, user *auth.User) (APIKey, error) {
|
||||||
gatewayUserID := localGatewayUserID(user)
|
gatewayUserID := localGatewayUserID(user)
|
||||||
if gatewayUserID == "" {
|
if gatewayUserID == "" {
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ type WalletBalanceAdjustmentInput struct {
|
|||||||
Metadata map[string]any `json:"metadata"`
|
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 {
|
type WalletAdjustmentResult struct {
|
||||||
Account GatewayWalletAccount `json:"account"`
|
Account GatewayWalletAccount `json:"account"`
|
||||||
Before GatewayWalletAccount `json:"before"`
|
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
|
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) {
|
func (s *Store) ensureWalletAccount(ctx context.Context, q Tx, gatewayUserID string, currency string) (GatewayWalletAccount, error) {
|
||||||
currency = normalizeWalletCurrency(currency)
|
currency = normalizeWalletCurrency(currency)
|
||||||
if _, err := q.Exec(ctx, `
|
if _, err := q.Exec(ctx, `
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
+45
-10
@@ -10,6 +10,7 @@ import type {
|
|||||||
GatewayAccessRule,
|
GatewayAccessRule,
|
||||||
GatewayAccessRuleUpsertRequest,
|
GatewayAccessRuleUpsertRequest,
|
||||||
GatewayApiKey,
|
GatewayApiKey,
|
||||||
|
GatewayApiKeyScopeUpdateRequest,
|
||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
GatewayNetworkProxyConfig,
|
GatewayNetworkProxyConfig,
|
||||||
GatewayRunnerPolicy,
|
GatewayRunnerPolicy,
|
||||||
@@ -31,7 +32,7 @@ import type {
|
|||||||
RuntimePolicySet,
|
RuntimePolicySet,
|
||||||
UserGroupUpsertRequest,
|
UserGroupUpsertRequest,
|
||||||
UserGroup,
|
UserGroup,
|
||||||
WalletBalanceAdjustmentRequest,
|
WalletRechargeRequest,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
import {
|
import {
|
||||||
batchAccessRules,
|
batchAccessRules,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
GatewayApiError,
|
GatewayApiError,
|
||||||
getHealth,
|
getHealth,
|
||||||
listFileStorageChannels,
|
listFileStorageChannels,
|
||||||
|
getCurrentUser,
|
||||||
getFileStorageSettings,
|
getFileStorageSettings,
|
||||||
getNetworkProxyConfig,
|
getNetworkProxyConfig,
|
||||||
getRunnerPolicy,
|
getRunnerPolicy,
|
||||||
@@ -63,6 +65,7 @@ import {
|
|||||||
listApiKeys,
|
listApiKeys,
|
||||||
listBaseModels,
|
listBaseModels,
|
||||||
listCatalogProviders,
|
listCatalogProviders,
|
||||||
|
listCurrentUserGroups,
|
||||||
listModelCatalog,
|
listModelCatalog,
|
||||||
listModelRateLimitStatuses,
|
listModelRateLimitStatuses,
|
||||||
listModels,
|
listModels,
|
||||||
@@ -83,11 +86,12 @@ import {
|
|||||||
loginLocalAccount,
|
loginLocalAccount,
|
||||||
pollTaskUntilSettled,
|
pollTaskUntilSettled,
|
||||||
registerLocalAccount,
|
registerLocalAccount,
|
||||||
|
rechargeUserWalletBalance,
|
||||||
replacePlatformModels,
|
replacePlatformModels,
|
||||||
restoreModelRuntimeStatus,
|
restoreModelRuntimeStatus,
|
||||||
setUserWalletBalance,
|
|
||||||
type HealthResponse,
|
type HealthResponse,
|
||||||
updateAccessRule,
|
updateAccessRule,
|
||||||
|
updateApiKeyScopes,
|
||||||
updateFileStorageChannel,
|
updateFileStorageChannel,
|
||||||
updateFileStorageSettings,
|
updateFileStorageSettings,
|
||||||
updateGatewayUser,
|
updateGatewayUser,
|
||||||
@@ -142,6 +146,8 @@ import type {
|
|||||||
|
|
||||||
type DataKey =
|
type DataKey =
|
||||||
| 'health'
|
| 'health'
|
||||||
|
| 'currentUser'
|
||||||
|
| 'currentUserGroups'
|
||||||
| 'publicCatalog'
|
| 'publicCatalog'
|
||||||
| 'playgroundApiKeys'
|
| 'playgroundApiKeys'
|
||||||
| 'playgroundModels'
|
| 'playgroundModels'
|
||||||
@@ -184,6 +190,8 @@ export function App() {
|
|||||||
const [loginForm, setLoginForm] = useState<LoginForm>({ account: '', password: '' });
|
const [loginForm, setLoginForm] = useState<LoginForm>({ account: '', password: '' });
|
||||||
const [registerForm, setRegisterForm] = useState<RegisterForm>({ username: '', email: '', password: '', displayName: '', invitationCode: '' });
|
const [registerForm, setRegisterForm] = useState<RegisterForm>({ username: '', email: '', password: '', displayName: '', invitationCode: '' });
|
||||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||||
|
const [currentUser, setCurrentUser] = useState<ConsoleData['currentUser']>(null);
|
||||||
|
const [currentUserGroups, setCurrentUserGroups] = useState<UserGroup[]>([]);
|
||||||
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
||||||
const [models, setModels] = useState<PlatformModel[]>([]);
|
const [models, setModels] = useState<PlatformModel[]>([]);
|
||||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse>({
|
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse>({
|
||||||
@@ -313,6 +321,8 @@ export function App() {
|
|||||||
auditLogs,
|
auditLogs,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
baseModels,
|
baseModels,
|
||||||
|
currentUser,
|
||||||
|
currentUserGroups,
|
||||||
fileStorageChannels,
|
fileStorageChannels,
|
||||||
fileStorageSettings,
|
fileStorageSettings,
|
||||||
modelCatalog,
|
modelCatalog,
|
||||||
@@ -334,7 +344,7 @@ export function App() {
|
|||||||
users,
|
users,
|
||||||
walletAccounts,
|
walletAccounts,
|
||||||
walletTransactions,
|
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) {
|
async function refresh(nextToken = token) {
|
||||||
await ensureRouteData(nextToken, true);
|
await ensureRouteData(nextToken, true);
|
||||||
@@ -387,6 +397,12 @@ export function App() {
|
|||||||
setHealth(await getHealth());
|
setHealth(await getHealth());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'currentUser':
|
||||||
|
setCurrentUser(await getCurrentUser(nextToken));
|
||||||
|
return;
|
||||||
|
case 'currentUserGroups':
|
||||||
|
setCurrentUserGroups((await listCurrentUserGroups(nextToken)).items);
|
||||||
|
return;
|
||||||
case 'publicCatalog': {
|
case 'publicCatalog': {
|
||||||
const [providersResult, baseModelsResult] = await Promise.all([
|
const [providersResult, baseModelsResult] = await Promise.all([
|
||||||
listPublicCatalogProviders(),
|
listPublicCatalogProviders(),
|
||||||
@@ -541,7 +557,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const response = await createApiKey(token, {
|
const response = await createApiKey(token, {
|
||||||
name: apiKeyForm.name,
|
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,
|
expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined,
|
||||||
});
|
});
|
||||||
setApiKeySecret(response.secret);
|
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');
|
setCoreState('loading');
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
try {
|
try {
|
||||||
const response = await setUserWalletBalance(token, userId, input);
|
const response = await rechargeUserWalletBalance(token, userId, input);
|
||||||
setUsers((current) => current.map((user) => user.id === userId
|
setUsers((current) => current.map((user) => user.id === userId
|
||||||
? {
|
? {
|
||||||
...user,
|
...user,
|
||||||
@@ -760,10 +776,10 @@ export function App() {
|
|||||||
setAuditLogs((current) => [response.auditLog, ...current.filter((item) => item.id !== response.auditLog.id)]);
|
setAuditLogs((current) => [response.auditLog, ...current.filter((item) => item.id !== response.auditLog.id)]);
|
||||||
invalidateDataKeys('auditLogs');
|
invalidateDataKeys('auditLogs');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('用户余额已更新,审计日志已记录。');
|
setCoreMessage('用户余额已充值,审计日志已记录。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCoreState('error');
|
setCoreState('error');
|
||||||
setCoreMessage(err instanceof Error ? err.message : '更新用户余额失败');
|
setCoreMessage(err instanceof Error ? err.message : '充值用户余额失败');
|
||||||
throw err;
|
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) {
|
async function saveAccessRule(input: GatewayAccessRuleUpsertRequest, ruleId?: string) {
|
||||||
setCoreState('loading');
|
setCoreState('loading');
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
@@ -988,6 +1020,8 @@ export function App() {
|
|||||||
loadedDataKeysRef.current = new Set(health ? ['health'] : []);
|
loadedDataKeysRef.current = new Set(health ? ['health'] : []);
|
||||||
loadingDataKeysRef.current.clear();
|
loadingDataKeysRef.current.clear();
|
||||||
setState('idle');
|
setState('idle');
|
||||||
|
setCurrentUser(null);
|
||||||
|
setCurrentUserGroups([]);
|
||||||
setPlatforms([]);
|
setPlatforms([]);
|
||||||
setModels([]);
|
setModels([]);
|
||||||
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
||||||
@@ -1143,6 +1177,7 @@ export function App() {
|
|||||||
onDeleteApiKey={removeAPIKey}
|
onDeleteApiKey={removeAPIKey}
|
||||||
onApiKeyFormChange={setApiKeyForm}
|
onApiKeyFormChange={setApiKeyForm}
|
||||||
onSectionChange={navigateWorkspaceSection}
|
onSectionChange={navigateWorkspaceSection}
|
||||||
|
onSaveApiKeyScopes={saveAPIKeyScopes}
|
||||||
onSubmitApiKey={submitAPIKey}
|
onSubmitApiKey={submitAPIKey}
|
||||||
onTaskQueryChange={navigateWorkspaceTaskQuery}
|
onTaskQueryChange={navigateWorkspaceTaskQuery}
|
||||||
onTransactionQueryChange={setWorkspaceTransactionQuery}
|
onTransactionQueryChange={setWorkspaceTransactionQuery}
|
||||||
@@ -1200,7 +1235,7 @@ export function App() {
|
|||||||
onSaveFileStorageSettings={saveFileStorageSettings}
|
onSaveFileStorageSettings={saveFileStorageSettings}
|
||||||
onSaveTenant={saveTenant}
|
onSaveTenant={saveTenant}
|
||||||
onSaveUser={saveUser}
|
onSaveUser={saveUser}
|
||||||
onSetUserWalletBalance={saveUserWalletBalance}
|
onRechargeUserWalletBalance={rechargeUserWallet}
|
||||||
onSaveUserGroup={saveUserGroup}
|
onSaveUserGroup={saveUserGroup}
|
||||||
onClearOperationMessage={() => setCoreMessage('')}
|
onClearOperationMessage={() => setCoreMessage('')}
|
||||||
onSectionChange={navigateAdminSection}
|
onSectionChange={navigateAdminSection}
|
||||||
@@ -1382,7 +1417,7 @@ function dataKeysForRoute(
|
|||||||
if (!isAuthenticated) return [];
|
if (!isAuthenticated) return [];
|
||||||
|
|
||||||
if (activePage === 'workspace') {
|
if (activePage === 'workspace') {
|
||||||
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
|
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||||
if (workspaceSection === 'billing') return ['wallet'];
|
if (workspaceSection === 'billing') return ['wallet'];
|
||||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
||||||
if (workspaceSection === 'tasks') return ['tasks'];
|
if (workspaceSection === 'tasks') return ['tasks'];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
AuthUser,
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
BaseModelUpsertRequest,
|
BaseModelUpsertRequest,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
@@ -13,6 +14,7 @@ import type {
|
|||||||
GatewayAccessRule,
|
GatewayAccessRule,
|
||||||
GatewayAccessRuleUpsertRequest,
|
GatewayAccessRuleUpsertRequest,
|
||||||
GatewayApiKey,
|
GatewayApiKey,
|
||||||
|
GatewayApiKeyScopeUpdateRequest,
|
||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
GatewayRunnerPolicy,
|
GatewayRunnerPolicy,
|
||||||
GatewayRunnerPolicyUpsertRequest,
|
GatewayRunnerPolicyUpsertRequest,
|
||||||
@@ -43,6 +45,7 @@ import type {
|
|||||||
UserGroupUpsertRequest,
|
UserGroupUpsertRequest,
|
||||||
WalletAdjustmentResponse,
|
WalletAdjustmentResponse,
|
||||||
WalletBalanceAdjustmentRequest,
|
WalletBalanceAdjustmentRequest,
|
||||||
|
WalletRechargeRequest,
|
||||||
WalletSummaryResponse,
|
WalletSummaryResponse,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
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<AuthUser> {
|
||||||
|
return request<AuthUser>('/api/v1/me', { token });
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
|
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
|
||||||
return request<ListResponse<IntegrationPlatform>>('/api/admin/platforms', { token });
|
return request<ListResponse<IntegrationPlatform>>('/api/admin/platforms', { token });
|
||||||
}
|
}
|
||||||
@@ -366,6 +373,18 @@ export async function setUserWalletBalance(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rechargeUserWalletBalance(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
input: WalletRechargeRequest,
|
||||||
|
): Promise<WalletAdjustmentResponse> {
|
||||||
|
return request<WalletAdjustmentResponse>(`/api/admin/users/${userId}/wallet/recharge`, {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteGatewayUser(token: string, userId: string): Promise<void> {
|
export async function deleteGatewayUser(token: string, userId: string): Promise<void> {
|
||||||
await request<void>(`/api/admin/users/${userId}`, {
|
await request<void>(`/api/admin/users/${userId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -381,6 +400,10 @@ export async function listUserGroups(token: string): Promise<ListResponse<UserGr
|
|||||||
return request<ListResponse<UserGroup>>('/api/admin/user-groups', { token });
|
return request<ListResponse<UserGroup>>('/api/admin/user-groups', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCurrentUserGroups(token: string): Promise<ListResponse<UserGroup>> {
|
||||||
|
return request<ListResponse<UserGroup>>('/api/workspace/user-groups', { token });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
|
export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
|
||||||
return request<UserGroup>('/api/admin/user-groups', {
|
return request<UserGroup>('/api/admin/user-groups', {
|
||||||
body: input,
|
body: input,
|
||||||
@@ -474,6 +497,14 @@ export async function createApiKey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateApiKeyScopes(token: string, apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest): Promise<GatewayApiKey> {
|
||||||
|
return request<GatewayApiKey>(`/api/v1/api-keys/${apiKeyId}/scopes`, {
|
||||||
|
body: input,
|
||||||
|
method: 'PATCH',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(token: string, apiKeyId: string): Promise<void> {
|
export async function deleteApiKey(token: string, apiKeyId: string): Promise<void> {
|
||||||
await request<void>(`/api/v1/api-keys/${apiKeyId}`, {
|
await request<void>(`/api/v1/api-keys/${apiKeyId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AuthUser,
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
FileStorageChannel,
|
FileStorageChannel,
|
||||||
@@ -29,6 +30,8 @@ export interface ConsoleData {
|
|||||||
auditLogs: GatewayAuditLog[];
|
auditLogs: GatewayAuditLog[];
|
||||||
apiKeys: GatewayApiKey[];
|
apiKeys: GatewayApiKey[];
|
||||||
baseModels: BaseModelCatalogItem[];
|
baseModels: BaseModelCatalogItem[];
|
||||||
|
currentUser: AuthUser | null;
|
||||||
|
currentUserGroups: UserGroup[];
|
||||||
fileStorageChannels: FileStorageChannel[];
|
fileStorageChannels: FileStorageChannel[];
|
||||||
fileStorageSettings: FileStorageSettings | null;
|
fileStorageSettings: FileStorageSettings | null;
|
||||||
modelCatalog: ModelCatalogResponse;
|
modelCatalog: ModelCatalogResponse;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
PricingRuleSetUpsertRequest,
|
PricingRuleSetUpsertRequest,
|
||||||
RuntimePolicySetUpsertRequest,
|
RuntimePolicySetUpsertRequest,
|
||||||
UserGroupUpsertRequest,
|
UserGroupUpsertRequest,
|
||||||
WalletBalanceAdjustmentRequest,
|
WalletRechargeRequest,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
import type { ConsoleData, StatItem } from '../app-state';
|
import type { ConsoleData, StatItem } from '../app-state';
|
||||||
import { EntityTable } from '../components/EntityTable';
|
import { EntityTable } from '../components/EntityTable';
|
||||||
@@ -82,7 +82,7 @@ export function AdminPage(props: {
|
|||||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||||
onClearOperationMessage: () => void;
|
onClearOperationMessage: () => void;
|
||||||
onSectionChange: (value: AdminSection) => void;
|
onSectionChange: (value: AdminSection) => void;
|
||||||
@@ -90,6 +90,7 @@ export function AdminPage(props: {
|
|||||||
return (
|
return (
|
||||||
<div className="pageStack">
|
<div className="pageStack">
|
||||||
<ScreenMessage
|
<ScreenMessage
|
||||||
|
duration={props.state === 'error' ? 0 : undefined}
|
||||||
message={props.operationMessage}
|
message={props.operationMessage}
|
||||||
variant={props.state === 'error' ? 'error' : 'success'}
|
variant={props.state === 'error' ? 'error' : 'success'}
|
||||||
onClose={props.onClearOperationMessage}
|
onClose={props.onClearOperationMessage}
|
||||||
@@ -207,7 +208,7 @@ function identityPanelProps(props: {
|
|||||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -219,7 +220,7 @@ function identityPanelProps(props: {
|
|||||||
onDeleteUserGroup: props.onDeleteUserGroup,
|
onDeleteUserGroup: props.onDeleteUserGroup,
|
||||||
onSaveTenant: props.onSaveTenant,
|
onSaveTenant: props.onSaveTenant,
|
||||||
onSaveUser: props.onSaveUser,
|
onSaveUser: props.onSaveUser,
|
||||||
onSetUserWalletBalance: props.onSetUserWalletBalance,
|
onRechargeUserWalletBalance: props.onRechargeUserWalletBalance,
|
||||||
onSaveUserGroup: props.onSaveUserGroup,
|
onSaveUserGroup: props.onSaveUserGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Popover as AntPopover } from 'antd';
|
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 { 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 type { ConsoleData } from '../app-state';
|
||||||
import { EntityTable } from '../components/EntityTable';
|
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 { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
||||||
import { listTaskParamPreprocessing } from '../api';
|
import { listTaskParamPreprocessing } from '../api';
|
||||||
@@ -19,6 +19,19 @@ const tabs = [
|
|||||||
|
|
||||||
const taskPageSizeOptions = [10, 20, 50];
|
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: {
|
export function WorkspacePage(props: {
|
||||||
apiKeyForm: ApiKeyForm;
|
apiKeyForm: ApiKeyForm;
|
||||||
apiKeySecret: string;
|
apiKeySecret: string;
|
||||||
@@ -37,6 +50,7 @@ export function WorkspacePage(props: {
|
|||||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
onSectionChange: (value: WorkspaceSection) => void;
|
onSectionChange: (value: WorkspaceSection) => void;
|
||||||
|
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
|
||||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||||
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||||
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
|
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
|
||||||
@@ -59,7 +73,8 @@ export function WorkspacePage(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceOverview(props: { data: ConsoleData }) {
|
function WorkspaceOverview(props: { data: ConsoleData }) {
|
||||||
const owner = props.data.users[0];
|
const owner = props.data.currentUser;
|
||||||
|
const groupRows = currentUserGroupRows(owner, props.data.currentUserGroups);
|
||||||
return (
|
return (
|
||||||
<section className="contentGrid two">
|
<section className="contentGrid two">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -81,7 +96,7 @@ function WorkspaceOverview(props: { data: ConsoleData }) {
|
|||||||
<EntityTable
|
<EntityTable
|
||||||
columns={['用户组', '优先级', '状态', '来源']}
|
columns={['用户组', '优先级', '状态', '来源']}
|
||||||
empty="暂无用户组"
|
empty="暂无用户组"
|
||||||
rows={props.data.userGroups.map((item) => [item.groupKey, item.priority, item.status, item.source])}
|
rows={groupRows}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -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[] }) {
|
function BillingPanel(props: { walletAccounts: GatewayWalletAccount[] }) {
|
||||||
const primaryWallet = primaryWalletAccount(props.walletAccounts);
|
const primaryWallet = primaryWalletAccount(props.walletAccounts);
|
||||||
const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0;
|
const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0;
|
||||||
@@ -364,17 +396,25 @@ function ApiKeyPanel(props: {
|
|||||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
|
onSaveApiKeyScopes: (apiKeyId: string, input: GatewayApiKeyScopeUpdateRequest) => Promise<void>;
|
||||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [policyApiKeyId, setPolicyApiKeyId] = useState('');
|
const [policyApiKeyId, setPolicyApiKeyId] = useState('');
|
||||||
|
const [scopeApiKeyId, setScopeApiKeyId] = useState('');
|
||||||
|
const [scopeDraft, setScopeDraft] = useState<string[]>([]);
|
||||||
|
const [scopeError, setScopeError] = useState('');
|
||||||
const [pendingDelete, setPendingDelete] = useState<GatewayApiKey | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<GatewayApiKey | null>(null);
|
||||||
const [localMessage, setLocalMessage] = useState('');
|
const [localMessage, setLocalMessage] = useState('');
|
||||||
const selectedPolicyKey = useMemo(
|
const selectedPolicyKey = useMemo(
|
||||||
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
||||||
[policyApiKeyId, props.data.apiKeys],
|
[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]);
|
const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]);
|
||||||
|
|
||||||
async function copyApiKey(item: GatewayApiKey) {
|
async function copyApiKey(item: GatewayApiKey) {
|
||||||
@@ -399,6 +439,34 @@ function ApiKeyPanel(props: {
|
|||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openScopeDialog(item: GatewayApiKey) {
|
||||||
|
setScopeApiKeyId(item.id);
|
||||||
|
setScopeDraft(normalizeApiKeyScopes(item.scopes));
|
||||||
|
setScopeError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScopeDialog() {
|
||||||
|
setScopeApiKeyId('');
|
||||||
|
setScopeDraft([]);
|
||||||
|
setScopeError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitScopes(event: FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -418,6 +486,7 @@ function ApiKeyPanel(props: {
|
|||||||
<TableRow className="shTableHeader">
|
<TableRow className="shTableHeader">
|
||||||
<TableHead>名称</TableHead>
|
<TableHead>名称</TableHead>
|
||||||
<TableHead>API Key</TableHead>
|
<TableHead>API Key</TableHead>
|
||||||
|
<TableHead>能力范围</TableHead>
|
||||||
<TableHead>权限策略</TableHead>
|
<TableHead>权限策略</TableHead>
|
||||||
<TableHead>最近使用</TableHead>
|
<TableHead>最近使用</TableHead>
|
||||||
<TableHead>有效期</TableHead>
|
<TableHead>有效期</TableHead>
|
||||||
@@ -451,6 +520,14 @@ function ApiKeyPanel(props: {
|
|||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="apiKeyScopeCell">
|
||||||
|
<ApiKeyScopeBadges scopes={item.scopes} />
|
||||||
|
<Button type="button" variant="ghost" size="icon" title="维护能力范围" onClick={() => openScopeDialog(item)}>
|
||||||
|
<SlidersHorizontal size={14} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
|
<button type="button" className="apiKeyPolicyButton" onClick={() => setPolicyApiKeyId(item.id)}>
|
||||||
<ShieldCheck size={14} />
|
<ShieldCheck size={14} />
|
||||||
@@ -509,6 +586,30 @@ function ApiKeyPanel(props: {
|
|||||||
</Label>
|
</Label>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
|
||||||
|
<FormDialog
|
||||||
|
ariaLabel="维护 API Key 能力范围"
|
||||||
|
bodyClassName="apiKeyScopeDialogBody"
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={closeScopeDialog}>取消</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={props.state === 'loading' || normalizeApiKeyScopes(scopeDraft).length === 0}>
|
||||||
|
<SlidersHorizontal size={15} />
|
||||||
|
保存范围
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
open={Boolean(selectedScopeKey)}
|
||||||
|
title={selectedScopeKey ? `能力范围:${selectedScopeKey.name}` : '能力范围'}
|
||||||
|
onClose={closeScopeDialog}
|
||||||
|
onSubmit={(event) => void submitScopes(event)}
|
||||||
|
>
|
||||||
|
{scopeError && <p className="formMessage error">{scopeError}</p>}
|
||||||
|
<APIKeyScopeEditor value={scopeDraft} onChange={(scopes) => {
|
||||||
|
setScopeDraft(scopes);
|
||||||
|
if (scopeError) setScopeError('');
|
||||||
|
}} />
|
||||||
|
</FormDialog>
|
||||||
|
|
||||||
<FormDialog
|
<FormDialog
|
||||||
ariaLabel="维护 API Key 权限策略"
|
ariaLabel="维护 API Key 权限策略"
|
||||||
bodyClassName="apiKeyPolicyDialogBody"
|
bodyClassName="apiKeyPolicyDialogBody"
|
||||||
@@ -544,6 +645,64 @@ function ApiKeyPanel(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function APIKeyScopeEditor(props: {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => 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 (
|
||||||
|
<div className="apiKeyScopeEditor">
|
||||||
|
<div className="apiKeyScopeOptionGrid">
|
||||||
|
{apiKeyScopeOptions.map((option) => (
|
||||||
|
<label key={option.value} className="apiKeyScopeOption">
|
||||||
|
<Checkbox
|
||||||
|
checked={scopes.includes(option.value)}
|
||||||
|
onCheckedChange={(checked) => setKnownScope(option.value, checked === true)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{option.label}</strong>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Label>
|
||||||
|
自定义 scope
|
||||||
|
<Input
|
||||||
|
value={customValue}
|
||||||
|
placeholder="例如:text_to_speech, audio_generate"
|
||||||
|
onChange={(event) => setCustomScopes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TaskPanel(props: {
|
function TaskPanel(props: {
|
||||||
data: ConsoleData;
|
data: ConsoleData;
|
||||||
query: WorkspaceTaskQuery;
|
query: WorkspaceTaskQuery;
|
||||||
@@ -1429,6 +1588,40 @@ function apiKeySecretFor(item: GatewayApiKey, secretsById: Record<string, string
|
|||||||
return secretsById[item.id] || item.secret || '';
|
return secretsById[item.id] || item.secret || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ApiKeyScopeBadges(props: { scopes?: string[] }) {
|
||||||
|
const scopes = normalizeApiKeyScopes(props.scopes);
|
||||||
|
if (!scopes.length) {
|
||||||
|
return <Badge variant="warning">未配置</Badge>;
|
||||||
|
}
|
||||||
|
const visibleScopes = scopes.includes('all') || scopes.includes('*') ? ['all'] : scopes.slice(0, 3);
|
||||||
|
const hiddenCount = scopes.length - visibleScopes.length;
|
||||||
|
return (
|
||||||
|
<span className="apiKeyScopeBadges">
|
||||||
|
{visibleScopes.map((scope) => (
|
||||||
|
<Badge key={scope} variant={scope === 'all' ? 'success' : 'outline'}>{apiKeyScopeLabel(scope)}</Badge>
|
||||||
|
))}
|
||||||
|
{hiddenCount > 0 && <Badge variant="secondary">+{hiddenCount}</Badge>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApiKeyScopes(scopes?: string[]) {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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) {
|
function maskApiKey(secret: string) {
|
||||||
if (secret.length <= 18) return secret;
|
if (secret.length <= 18) return secret;
|
||||||
return `${secret.slice(0, 12)}...${secret.slice(-4)}`;
|
return `${secret.slice(0, 12)}...${secret.slice(-4)}`;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function AuditLogsPanel(props: { auditLogs: GatewayAuditLog[]; message?:
|
|||||||
|
|
||||||
function actionLabel(action: string) {
|
function actionLabel(action: string) {
|
||||||
if (action === 'wallet.balance.set') return '余额调整';
|
if (action === 'wallet.balance.set') return '余额调整';
|
||||||
|
if (action === 'wallet.balance.recharge') return '余额充值';
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
GatewayWalletAccount,
|
GatewayWalletAccount,
|
||||||
UserGroup,
|
UserGroup,
|
||||||
UserGroupUpsertRequest,
|
UserGroupUpsertRequest,
|
||||||
WalletBalanceAdjustmentRequest,
|
WalletRechargeRequest,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FormDialog,
|
FormDialog,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
|
ScreenMessage,
|
||||||
Select,
|
Select,
|
||||||
Table,
|
Table,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -68,7 +69,7 @@ type UserForm = {
|
|||||||
|
|
||||||
type WalletForm = {
|
type WalletForm = {
|
||||||
currency: string;
|
currency: string;
|
||||||
balance: string;
|
amount: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,11 +150,12 @@ export function TenantsPanel(props: IdentityPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pageStack">
|
<div className="pageStack">
|
||||||
|
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
||||||
<IdentityHeader
|
<IdentityHeader
|
||||||
count={props.data.tenants.length}
|
count={props.data.tenants.length}
|
||||||
description="租户可独立维护,也可承载 server-main 同步过来的租户标识。"
|
description="租户可独立维护,也可承载 server-main 同步过来的租户标识。"
|
||||||
icon={<Building2 size={17} />}
|
icon={<Building2 size={17} />}
|
||||||
message={localError || props.operationMessage}
|
message={identityHeaderMessage(props)}
|
||||||
title="租户管理"
|
title="租户管理"
|
||||||
actionLabel="新增租户"
|
actionLabel="新增租户"
|
||||||
onCreate={openCreateDialog}
|
onCreate={openCreateDialog}
|
||||||
@@ -256,8 +258,8 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
setWalletUser(user);
|
setWalletUser(user);
|
||||||
setWalletForm({
|
setWalletForm({
|
||||||
currency: wallet?.currency ?? 'resource',
|
currency: wallet?.currency ?? 'resource',
|
||||||
balance: String(wallet?.balance ?? 0),
|
amount: '',
|
||||||
reason: '',
|
reason: defaultRechargeReason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,9 +292,9 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
if (!walletUser) return;
|
if (!walletUser) return;
|
||||||
const balance = Number(walletForm.balance);
|
const amount = Number(walletForm.amount);
|
||||||
if (!Number.isFinite(balance) || balance < 0) {
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
setLocalError('余额必须是非负数字');
|
setLocalError('充值金额必须大于 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!walletForm.reason.trim()) {
|
if (!walletForm.reason.trim()) {
|
||||||
@@ -300,15 +302,15 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await props.onSetUserWalletBalance(walletUser.id, {
|
await props.onRechargeUserWalletBalance(walletUser.id, {
|
||||||
currency: walletForm.currency,
|
currency: walletForm.currency,
|
||||||
balance,
|
amount,
|
||||||
reason: walletForm.reason.trim(),
|
reason: walletForm.reason.trim(),
|
||||||
idempotencyKey: newIdempotencyKey(),
|
idempotencyKey: newIdempotencyKey(),
|
||||||
});
|
});
|
||||||
closeWalletDialog();
|
closeWalletDialog();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLocalError(err instanceof Error ? err.message : '更新余额失败');
|
setLocalError(err instanceof Error ? err.message : '充值用户余额失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,11 +321,12 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pageStack">
|
<div className="pageStack">
|
||||||
|
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
||||||
<IdentityHeader
|
<IdentityHeader
|
||||||
count={props.data.users.length}
|
count={props.data.users.length}
|
||||||
description="支持本地用户闭环,也保留 server-main 用户同步字段。"
|
description="支持本地用户闭环,也保留 server-main 用户同步字段。"
|
||||||
icon={<UserRound size={17} />}
|
icon={<UserRound size={17} />}
|
||||||
message={localError || props.operationMessage}
|
message={identityHeaderMessage(props)}
|
||||||
title="用户管理"
|
title="用户管理"
|
||||||
actionLabel="新增用户"
|
actionLabel="新增用户"
|
||||||
onCreate={openCreateDialog}
|
onCreate={openCreateDialog}
|
||||||
@@ -400,18 +403,19 @@ export function UsersPanel(props: IdentityPanelProps) {
|
|||||||
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
|
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
|
||||||
/>
|
/>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
ariaLabel="修改用户余额"
|
ariaLabel="充值用户余额"
|
||||||
className="identityDialog walletDialog"
|
className="identityDialog walletDialog"
|
||||||
eyebrow="Wallet Adjustment"
|
eyebrow="Wallet Adjustment"
|
||||||
footer={<WalletDialogFooter loading={props.state === 'loading'} onCancel={closeWalletDialog} />}
|
footer={<WalletDialogFooter loading={props.state === 'loading'} onCancel={closeWalletDialog} />}
|
||||||
open={Boolean(walletUser)}
|
open={Boolean(walletUser)}
|
||||||
title={`修改余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
|
title={`充值余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
|
||||||
onClose={closeWalletDialog}
|
onClose={closeWalletDialog}
|
||||||
onSubmit={submitWallet}
|
onSubmit={submitWallet}
|
||||||
>
|
>
|
||||||
<Label>币种<Input size="sm" value={walletForm.currency} onChange={(event) => setWalletForm({ ...walletForm, currency: event.target.value })} placeholder="resource" /></Label>
|
<Label>币种<Input size="sm" value={walletForm.currency} onChange={(event) => setWalletForm({ ...walletForm, currency: event.target.value })} placeholder="resource" /></Label>
|
||||||
<Label>目标余额<Input size="sm" value={walletForm.balance} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, balance: event.target.value })} /></Label>
|
<Label>充值金额<Input size="sm" value={walletForm.amount} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, amount: event.target.value })} placeholder="100" /></Label>
|
||||||
<Label className="spanTwo">调整原因<Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
|
<Label className="spanTwo">当前余额<Input size="sm" value={walletUser ? walletSummary(walletUser) : '0 resource'} disabled /></Label>
|
||||||
|
<Label className="spanTwo">充值原因<Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -466,11 +470,12 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pageStack">
|
<div className="pageStack">
|
||||||
|
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
|
||||||
<IdentityHeader
|
<IdentityHeader
|
||||||
count={props.data.userGroups.length}
|
count={props.data.userGroups.length}
|
||||||
description="用户组承载充值折扣、计费折扣、限流和额度策略。"
|
description="用户组承载充值折扣、计费折扣、限流和额度策略。"
|
||||||
icon={<UsersRound size={17} />}
|
icon={<UsersRound size={17} />}
|
||||||
message={localError || props.operationMessage}
|
message={identityHeaderMessage(props)}
|
||||||
title="用户组管理"
|
title="用户组管理"
|
||||||
actionLabel="新增用户组"
|
actionLabel="新增用户组"
|
||||||
onCreate={openCreateDialog}
|
onCreate={openCreateDialog}
|
||||||
@@ -546,10 +551,22 @@ type IdentityPanelProps = {
|
|||||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
|
||||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultRechargeReason = '手动充值';
|
||||||
|
|
||||||
|
function identityHeaderMessage(props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
|
||||||
|
return props.state === 'error' ? '' : props.operationMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityLocalErrorMessage(localError: string, props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
|
||||||
|
if (!localError) return '';
|
||||||
|
if (props.state === 'error' && localError === props.operationMessage) return '';
|
||||||
|
return localError;
|
||||||
|
}
|
||||||
|
|
||||||
function IdentityHeader(props: {
|
function IdentityHeader(props: {
|
||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -621,7 +638,7 @@ function WalletDialogFooter(props: { loading: boolean; onCancel: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} />取消</Button>
|
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} />取消</Button>
|
||||||
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} />保存余额</Button>
|
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} />确认充值</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -715,8 +732,8 @@ function defaultUserForm(tenant?: GatewayTenant): UserForm {
|
|||||||
function defaultWalletForm(): WalletForm {
|
function defaultWalletForm(): WalletForm {
|
||||||
return {
|
return {
|
||||||
currency: 'resource',
|
currency: 'resource',
|
||||||
balance: '0',
|
amount: '',
|
||||||
reason: '',
|
reason: defaultRechargeReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -577,8 +577,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.apiKeyTable .shTableRow {
|
.apiKeyTable .shTableRow {
|
||||||
grid-template-columns: minmax(170px, 1.05fr) minmax(210px, 1.25fr) minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(96px, 0.55fr) minmax(150px, 0.9fr) minmax(74px, 0.4fr);
|
grid-template-columns: minmax(170px, 1.05fr) minmax(210px, 1.2fr) minmax(220px, 1.2fr) minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(150px, 0.85fr) minmax(96px, 0.5fr) minmax(150px, 0.85fr) minmax(74px, 0.38fr);
|
||||||
min-width: 1160px;
|
min-width: 1360px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,6 +620,27 @@
|
|||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeCell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 30px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeBadges {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeCell .shBadge,
|
||||||
|
.apiKeyScopeBadges .shBadge {
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.apiKeyPolicyButton {
|
.apiKeyPolicyButton {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -645,6 +666,52 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeDialogBody {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeEditor {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeOptionGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeOption {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeOption span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeOption strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeOption small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.apiKeyPolicyDialog {
|
.apiKeyPolicyDialog {
|
||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
}
|
}
|
||||||
@@ -714,6 +781,10 @@
|
|||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.formMessage.error {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
.providerCatalogGrid {
|
.providerCatalogGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -2216,8 +2287,13 @@
|
|||||||
.runnerActionGrid,
|
.runnerActionGrid,
|
||||||
.accessPermissionGrid,
|
.accessPermissionGrid,
|
||||||
.accessTreeToolbar,
|
.accessTreeToolbar,
|
||||||
.apiKeyCreateDialogBody,
|
.apiKeyCreateDialogBody,
|
||||||
.apiKeyPolicyDialogBody {
|
.apiKeyScopeDialogBody,
|
||||||
|
.apiKeyPolicyDialogBody {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiKeyScopeOptionGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -502,6 +502,10 @@ export interface CreatedGatewayApiKey {
|
|||||||
secret: string;
|
secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayApiKeyScopeUpdateRequest {
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayableGatewayApiKey extends GatewayApiKey {
|
export interface PlayableGatewayApiKey extends GatewayApiKey {
|
||||||
secret: string;
|
secret: string;
|
||||||
}
|
}
|
||||||
@@ -575,6 +579,14 @@ export interface WalletBalanceAdjustmentRequest {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WalletRechargeRequest {
|
||||||
|
currency?: string;
|
||||||
|
amount: number;
|
||||||
|
reason: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WalletSummaryResponse {
|
export interface WalletSummaryResponse {
|
||||||
accounts: GatewayWalletAccount[];
|
accounts: GatewayWalletAccount[];
|
||||||
primaryAccount: GatewayWalletAccount;
|
primaryAccount: GatewayWalletAccount;
|
||||||
|
|||||||
Reference in New Issue
Block a user