0.11.6: 渠道权限管控(部门范围 + 用户级授权)

- 迁移 000047:channels.department_ids(空=全局) + channel_grants 用户级授权
  (source=manual/approval 区分来源)。
- 管理端:渠道部门范围配置 + 授权管理(列表/授予/撤销);渠道列表显示范围。
- 门户:我的渠道端点(/api/v1/portal/channels)按部门可见或明确授权返回,
  「个人渠道」页新增可使用渠道区(授权方式标识)。
- 审批流:资源申请中的渠道类型通过后自动写 channel_grants(source=approval),
  取代'批准记录即授权'的弱语义。
- 端到端验证:部门隔离(demo 无部门看不到)→手动授予→可见→撤销→不可见;
  审批通过自动授权。修复 JOIN 列歧义与 uuid/text 比较。
This commit is contained in:
LLMGuardX Dev
2026-08-13 15:03:39 +08:00
parent 277f80dc82
commit 8000bccde3
10 changed files with 392 additions and 26 deletions
+2
View File
@@ -362,6 +362,7 @@ func main() {
portalService.SetApplicationRuntime(portal.NewRuntimeCredentials(db, apiKeyRepository, applicationKeyCipher), workbenchRuntime) portalService.SetApplicationRuntime(portal.NewRuntimeCredentials(db, apiKeyRepository, applicationKeyCipher), workbenchRuntime)
portalService.SetGateway(governedGateway) portalService.SetGateway(governedGateway)
portalService.SetMarketplace(marketplaceService) portalService.SetMarketplace(marketplaceService)
portalService.SetChannelService(channelService)
portalHandler := portal.NewHTTPHandler(portalService, identityService) portalHandler := portal.NewHTTPHandler(portalService, identityService)
portalAdminHandler := portal.NewAdminHTTPHandler(portalService, identityService) portalAdminHandler := portal.NewAdminHTTPHandler(portalService, identityService)
// License 授权:文件校验 + 账号数管控 + 管理端查看/上传。 // License 授权:文件校验 + 账号数管控 + 管理端查看/上传。
@@ -489,6 +490,7 @@ func main() {
controlMux.Handle("/api/v1/portal/agent-policy", agentPolicyHandler) controlMux.Handle("/api/v1/portal/agent-policy", agentPolicyHandler)
controlMux.Handle("/api/v1/portal/personal-channels", portalHandler) controlMux.Handle("/api/v1/portal/personal-channels", portalHandler)
controlMux.Handle("/api/v1/portal/personal-channels/", portalHandler) controlMux.Handle("/api/v1/portal/personal-channels/", portalHandler)
controlMux.Handle("/api/v1/portal/channels", portalHandler)
controlMux.Handle("/api/v1/portal/digital-employees", portalHandler) controlMux.Handle("/api/v1/portal/digital-employees", portalHandler)
controlMux.Handle("/api/v1/portal/digital-employees/", portalHandler) controlMux.Handle("/api/v1/portal/digital-employees/", portalHandler)
controlMux.Handle("/api/v1/portal/resource-requests", portalHandler) controlMux.Handle("/api/v1/portal/resource-requests", portalHandler)
+59 -8
View File
@@ -24,6 +24,9 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
h.mux.HandleFunc("PUT /api/v1/admin/channels/{id}", h.save) h.mux.HandleFunc("PUT /api/v1/admin/channels/{id}", h.save)
h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}", h.delete) h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}", h.delete)
h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/test", h.test) h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/test", h.test)
h.mux.HandleFunc("GET /api/v1/admin/channels/{id}/grants", h.listGrants)
h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/grants", h.grant)
h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}/grants/{user_id}", h.revokeGrant)
return h return h
} }
@@ -55,13 +58,14 @@ func (h *HTTPHandler) list(w http.ResponseWriter, r *http.Request) {
} }
type channelInput struct { type channelInput struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"` Kind string `json:"kind"`
Config json.RawMessage `json:"config"` Config json.RawMessage `json:"config"`
ModelBinding json.RawMessage `json:"model_binding"` ModelBinding json.RawMessage `json:"model_binding"`
APIKey string `json:"api_key"` DepartmentIDs []string `json:"department_ids"`
Enabled *bool `json:"enabled"` APIKey string `json:"api_key"`
Enabled *bool `json:"enabled"`
} }
func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) { func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) {
@@ -87,7 +91,7 @@ func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) {
if input.Enabled != nil { if input.Enabled != nil {
enabled = *input.Enabled enabled = *input.Enabled
} }
item, err := h.service.Save(r.Context(), r.PathValue("id"), input.Code, input.Name, input.Kind, cfg, input.ModelBinding, input.APIKey, enabled, actor.ID) item, err := h.service.Save(r.Context(), r.PathValue("id"), input.Code, input.Name, input.Kind, cfg, input.ModelBinding, input.DepartmentIDs, input.APIKey, enabled, actor.ID)
if err != nil { if err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error()) apiresponse.Error(w, http.StatusBadRequest, err.Error())
return return
@@ -212,3 +216,50 @@ func (h *InboundHTTPHandler) inbound(w http.ResponseWriter, r *http.Request) {
} }
apiresponse.OK(w, map[string]bool{"accepted": true}) apiresponse.OK(w, map[string]bool{"accepted": true})
} }
// listGrants 渠道用户授权列表。
func (h *HTTPHandler) listGrants(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionNotificationRead); !ok {
return
}
items, err := h.service.ListGrants(r.Context(), r.PathValue("id"))
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "渠道授权查询失败")
return
}
apiresponse.OK(w, items)
}
// grant 直接授予用户渠道使用权限(管理员显式授权,无需走申请流)。
func (h *HTTPHandler) grant(w http.ResponseWriter, r *http.Request) {
actor, ok := h.require(w, r, identity.PermissionNotificationManage)
if !ok {
return
}
var input struct {
PortalUserID string `json:"portal_user_id"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if decoder.Decode(&input) != nil || strings.TrimSpace(input.PortalUserID) == "" {
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.service.Grant(r.Context(), r.PathValue("id"), input.PortalUserID, actor.ID, "manual"); err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]bool{"granted": true})
}
// revokeGrant 撤销用户的渠道使用权限。
func (h *HTTPHandler) revokeGrant(w http.ResponseWriter, r *http.Request) {
if _, ok := h.require(w, r, identity.PermissionNotificationManage); !ok {
return
}
if err := h.service.RevokeGrant(r.Context(), r.PathValue("id"), r.PathValue("user_id")); err != nil {
apiresponse.Error(w, http.StatusBadRequest, err.Error())
return
}
apiresponse.OK(w, map[string]bool{"revoked": true})
}
+124 -7
View File
@@ -39,6 +39,7 @@ type Channel struct {
EncryptedConfig []byte `json:"-"` EncryptedConfig []byte `json:"-"`
ConfigKEKVersion int `json:"-"` ConfigKEKVersion int `json:"-"`
ModelBinding json.RawMessage `json:"model_binding"` ModelBinding json.RawMessage `json:"model_binding"`
DepartmentIDs []string `json:"department_ids"`
HasAPIKey bool `json:"has_api_key"` HasAPIKey bool `json:"has_api_key"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
CreatedBy *string `json:"created_by,omitempty"` CreatedBy *string `json:"created_by,omitempty"`
@@ -84,12 +85,12 @@ func NewService(pool *pgxpool.Pool, gatewayURL string, cipher interface {
} }
} }
const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels` const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,department_ids::text[],octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels`
func (s *Service) scan(row pgx.Row) (Channel, error) { func (s *Service) scan(row pgx.Row) (Channel, error) {
var c Channel var c Channel
var createdBy *string var createdBy *string
err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt) err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.DepartmentIDs, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Channel{}, ErrNotFound return Channel{}, ErrNotFound
} }
@@ -146,7 +147,7 @@ func (s *Service) DecryptConfig(c Channel) (Config, error) {
} }
// Save 创建/更新渠道。 // Save 创建/更新渠道。
func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, apiKey string, enabled bool, actorID string) (Channel, error) { func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, departmentIDs []string, apiKey string, enabled bool, actorID string) (Channel, error) {
if s == nil || s.pool == nil || s.cipher == nil { if s == nil || s.pool == nil || s.cipher == nil {
return Channel{}, ErrUnavailable return Channel{}, ErrUnavailable
} }
@@ -182,13 +183,13 @@ func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Con
if modelBinding == nil { if modelBinding == nil {
modelBinding = json.RawMessage(`{}`) modelBinding = json.RawMessage(`{}`)
} }
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,enabled,created_by) _, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,department_ids,enabled,created_by)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT(code) DO UPDATE SET name=$3,kind=$4,encrypted_config=$5,config_kek_version=$6, ON CONFLICT(code) DO UPDATE SET name=$3,kind=$4,encrypted_config=$5,config_kek_version=$6,
encrypted_api_key=CASE WHEN $7<>'' THEN $7 ELSE gateway.channels.encrypted_api_key END, encrypted_api_key=CASE WHEN $7<>'' THEN $7 ELSE gateway.channels.encrypted_api_key END,
api_key_kek_version=CASE WHEN $7<>'' THEN $8 ELSE gateway.channels.api_key_kek_version END, api_key_kek_version=CASE WHEN $7<>'' THEN $8 ELSE gateway.channels.api_key_kek_version END,
model_binding=$9,enabled=$10,updated_at=clock_timestamp()`, model_binding=$9,department_ids=$10,enabled=$11,updated_at=clock_timestamp()`,
id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, enabled, actorID) id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, departmentIDs, enabled, actorID)
if err != nil { if err != nil {
return Channel{}, err return Channel{}, err
} }
@@ -440,3 +441,119 @@ func DingSign(timestamp int64, secret string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\n%s", timestamp, secret))) sum := sha256.Sum256([]byte(fmt.Sprintf("%d\n%s", timestamp, secret)))
return url.QueryEscape(hex.EncodeToString(sum[:])) return url.QueryEscape(hex.EncodeToString(sum[:]))
} }
// ChannelGrant 是一条用户级渠道授权。
type ChannelGrant struct {
ChannelID string `json:"channel_id"`
ChannelCode string `json:"channel_code"`
ChannelName string `json:"channel_name"`
UserID string `json:"portal_user_id"`
UserLogin string `json:"user_login"`
Source string `json:"source"`
GrantedAt time.Time `json:"created_at"`
}
// ListGrants 返回渠道的用户授权列表。
func (s *Service) ListGrants(ctx context.Context, channelID string) ([]ChannelGrant, error) {
if s == nil || s.pool == nil {
return nil, ErrUnavailable
}
rows, err := s.pool.Query(ctx, `SELECT g.channel_id::text,c.code,c.name,g.portal_user_id::text,u.account,g.source,g.created_at
FROM gateway.channel_grants g JOIN gateway.channels c ON c.id=g.channel_id JOIN gateway.portal_users u ON u.id=g.portal_user_id
WHERE g.channel_id=$1 ORDER BY g.created_at DESC`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ChannelGrant{}
for rows.Next() {
var item ChannelGrant
if err := rows.Scan(&item.ChannelID, &item.ChannelCode, &item.ChannelName, &item.UserID, &item.UserLogin, &item.Source, &item.GrantedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// Grant 授予用户渠道使用权限(幂等)。source: manual(管理员直接授予)或
// approval(资源申请审批通过自动写入)。
func (s *Service) Grant(ctx context.Context, channelID, portalUserID, actorID, source string) error {
if s == nil || s.pool == nil {
return ErrUnavailable
}
if source != "manual" && source != "approval" {
source = "manual"
}
var exists bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.portal_users WHERE id=$1)`, portalUserID).Scan(&exists); err != nil {
return err
}
if !exists {
return ErrNotFound
}
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.channel_grants(channel_id,portal_user_id,granted_by,source) VALUES($1,$2,nullif($3,'')::uuid,$4) ON CONFLICT DO NOTHING`, channelID, portalUserID, actorID, source)
return err
}
// RevokeGrant 撤销用户的渠道使用权限。
func (s *Service) RevokeGrant(ctx context.Context, channelID, portalUserID string) error {
if s == nil || s.pool == nil {
return ErrUnavailable
}
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.channel_grants WHERE channel_id=$1 AND portal_user_id=$2`, channelID, portalUserID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// GrantsForUser 返回用户有明确授权的渠道(供门户"我的渠道")。
func (s *Service) GrantsForUser(ctx context.Context, portalUserID string) ([]Channel, error) {
if s == nil || s.pool == nil {
return nil, ErrUnavailable
}
rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c JOIN gateway.channel_grants g ON g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid WHERE c.enabled ORDER BY c.updated_at DESC`, portalUserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Channel{}
for rows.Next() {
c, err := s.scan(rows)
if err != nil {
return nil, err
}
items = append(items, c)
}
return items, rows.Err()
}
// VisibleChannelsForUser 返回用户可见渠道:部门可见(全局或本部门)或明确授权。
func (s *Service) VisibleChannelsForUser(ctx context.Context, portalUserID string, departmentID *string) ([]Channel, error) {
if s == nil || s.pool == nil {
return nil, ErrUnavailable
}
rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c
WHERE c.enabled AND (
cardinality(c.department_ids)=0
OR ($2::text = ANY(c.department_ids))
OR EXISTS(SELECT 1 FROM gateway.channel_grants g WHERE g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid)
) ORDER BY c.updated_at DESC`, portalUserID, departmentID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Channel{}
for rows.Next() {
c, err := s.scan(rows)
if err != nil {
return nil, err
}
items = append(items, c)
}
return items, rows.Err()
}
+30
View File
@@ -63,6 +63,8 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
h.mux.HandleFunc("POST /api/v1/portal/personal-channels", h.createPersonalChannel) h.mux.HandleFunc("POST /api/v1/portal/personal-channels", h.createPersonalChannel)
h.mux.HandleFunc("POST /api/v1/portal/personal-channels/{id}/token", h.regeneratePersonalToken) h.mux.HandleFunc("POST /api/v1/portal/personal-channels/{id}/token", h.regeneratePersonalToken)
h.mux.HandleFunc("DELETE /api/v1/portal/personal-channels/{id}", h.deletePersonalChannel) h.mux.HandleFunc("DELETE /api/v1/portal/personal-channels/{id}", h.deletePersonalChannel)
// 我的渠道:部门可见或已授权。
h.mux.HandleFunc("GET /api/v1/portal/channels", h.myChannels)
// 数字员工:会话入口 + 调用记录。 // 数字员工:会话入口 + 调用记录。
h.mux.HandleFunc("GET /api/v1/portal/digital-employees", h.digitalEmployees) h.mux.HandleFunc("GET /api/v1/portal/digital-employees", h.digitalEmployees)
h.mux.HandleFunc("POST /api/v1/portal/digital-employees/{code}/chat", h.runDigitalEmployee) h.mux.HandleFunc("POST /api/v1/portal/digital-employees/{code}/chat", h.runDigitalEmployee)
@@ -925,3 +927,31 @@ func (h *HTTPHandler) myEmployeeRuns(w http.ResponseWriter, r *http.Request) {
} }
apiresponse.OK(w, items) apiresponse.OK(w, items)
} }
// --- 我的渠道:部门可见或已授权 ---
func (h *HTTPHandler) myChannels(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
if h.service.channels == nil {
apiresponse.OK(w, map[string]any{"channels": []any{}, "granted_codes": []string{}})
return
}
visible, err := h.service.channels.VisibleChannelsForUser(r.Context(), a.ID, a.DepartmentID)
if err != nil {
portalError(w, err)
return
}
granted, err := h.service.channels.GrantsForUser(r.Context(), a.ID)
if err != nil {
portalError(w, err)
return
}
grantedCodes := make([]string, 0, len(granted))
for _, item := range granted {
grantedCodes = append(grantedCodes, item.Code)
}
apiresponse.OK(w, map[string]any{"channels": visible, "granted_codes": grantedCodes})
}
+20 -4
View File
@@ -180,10 +180,26 @@ func (s *Service) DecideResourceRequest(ctx context.Context, id, status, note, a
if err = tx.QueryRow(ctx, `SELECT portal_user_id::text,resource_type,resource_code FROM gateway.resource_access_requests WHERE id=$1`, id).Scan(&userID, &resourceType, &resourceCode); err != nil { if err = tx.QueryRow(ctx, `SELECT portal_user_id::text,resource_type,resource_code FROM gateway.resource_access_requests WHERE id=$1`, id).Scan(&userID, &resourceType, &resourceCode); err != nil {
return ResourceRequest{}, err return ResourceRequest{}, err
} }
if status == "approved" && resourceType != "channel" && s.market != nil { if status == "approved" {
// 自动安装到申请用户工作区(use 等级)。 switch resourceType {
if _, err = s.market.Install(ctx, resourceType, resourceCode, userID, "use"); err != nil { case "mcp_server", "skill", "digital_employee":
return ResourceRequest{}, err if s.market != nil {
// 自动安装到申请用户工作区(use 等级)。
if _, err = s.market.Install(ctx, resourceType, resourceCode, userID, "use"); err != nil {
return ResourceRequest{}, err
}
}
case "channel":
// 渠道审批通过 = 写入 channel_grants 用户级授权。
if s.channels != nil {
var channelID string
if err = tx.QueryRow(ctx, `SELECT id::text FROM gateway.channels WHERE code=$1`, resourceCode).Scan(&channelID); err != nil {
return ResourceRequest{}, err
}
if err = s.channels.Grant(ctx, channelID, userID, actorID, "approval"); err != nil {
return ResourceRequest{}, err
}
}
} }
} }
payload, _ := json.Marshal(map[string]any{"request_id": id, "portal_user_id": userID, "resource_type": resourceType, "resource_code": resourceCode, "status": status, "actor_id": actorID}) payload, _ := json.Marshal(map[string]any{"request_id": id, "portal_user_id": userID, "resource_type": resourceType, "resource_code": resourceCode, "status": status, "actor_id": actorID})
+6
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"aigateway.local/core/internal/channel"
"aigateway.local/core/internal/identity" "aigateway.local/core/internal/identity"
platformid "aigateway.local/core/internal/platform/id" platformid "aigateway.local/core/internal/platform/id"
"aigateway.local/core/internal/workbench" "aigateway.local/core/internal/workbench"
@@ -28,6 +29,7 @@ type Service struct {
runtime http.Handler runtime http.Handler
gateway http.Handler gateway http.Handler
market *workbench.MarketplaceService market *workbench.MarketplaceService
channels *channel.Service
} }
func NewService(pool *pgxpool.Pool, assets *workbench.Service, tools *workbench.ToolService, identityService *identity.Service) *Service { func NewService(pool *pgxpool.Pool, assets *workbench.Service, tools *workbench.ToolService, identityService *identity.Service) *Service {
@@ -46,6 +48,10 @@ func (s *Service) SetApplicationRuntime(credentials *RuntimeCredentials, runtime
// /v1/chat/completions with the user's own runtime credential. // /v1/chat/completions with the user's own runtime credential.
func (s *Service) SetGateway(gateway http.Handler) { s.gateway = gateway } func (s *Service) SetGateway(gateway http.Handler) { s.gateway = gateway }
// SetChannelService wires the channel service for approval auto-grant and the
// portal "my channels" visibility endpoint.
func (s *Service) SetChannelService(service *channel.Service) { s.channels = service }
// SetMarketplace wires the resource-marketplace service into the portal so the // SetMarketplace wires the resource-marketplace service into the portal so the
// marketplace pages can browse, install and manage resources. // marketplace pages can browse, install and manage resources.
func (s *Service) SetMarketplace(market *workbench.MarketplaceService) { func (s *Service) SetMarketplace(market *workbench.MarketplaceService) {
+22
View File
@@ -0,0 +1,22 @@
-- 000047_channel_grants.sql — 渠道权限管控:部门范围 + 用户级授权。
-- channels.department_ids:空数组 = 全局渠道(所有部门可见);非空 = 仅列出的
-- 部门可见。channel_grants:渠道对具体门户用户的显式授权(管理员直接授予或
-- 审批资源申请通过后自动写入)。
ALTER TABLE gateway.channels
ADD COLUMN IF NOT EXISTS department_ids uuid[] NOT NULL DEFAULT '{}'::uuid[];
COMMENT ON COLUMN gateway.channels.department_ids IS
'Departments allowed to see and use this channel. Empty array = global channel.';
CREATE TABLE IF NOT EXISTS gateway.channel_grants (
channel_id uuid NOT NULL REFERENCES gateway.channels(id) ON DELETE CASCADE,
portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE,
granted_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL,
source varchar(16) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'approval')),
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (channel_id, portal_user_id)
);
CREATE INDEX IF NOT EXISTS channel_grants_user_idx
ON gateway.channel_grants (portal_user_id, created_at DESC);
@@ -24,6 +24,12 @@
<ElTag :type="row.has_api_key ? 'success' : 'info'">{{ row.has_api_key ? '已配置' : '未配置' }}</ElTag> <ElTag :type="row.has_api_key ? 'success' : 'info'">{{ row.has_api_key ? '已配置' : '未配置' }}</ElTag>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="部门范围" min-width="150">
<template #default="{ row }">
<ElTag v-if="!row.department_ids || !row.department_ids.length" type="info">全局</ElTag>
<ElTag v-else type="primary">{{ row.department_ids.length }} 个部门</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="90"> <ElTableColumn label="状态" width="90">
<template #default="{ row }"> <template #default="{ row }">
<ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag> <ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag>
@@ -32,6 +38,7 @@
<ElTableColumn label="操作" width="200" fixed="right"> <ElTableColumn label="操作" width="200" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<ElButton link type="success" :loading="testingId === row.id" @click="test(row)">测试</ElButton> <ElButton link type="success" :loading="testingId === row.id" @click="test(row)">测试</ElButton>
<ElButton link type="primary" @click="openGrants(row)">授权</ElButton>
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton> <ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" @click="remove(row)">删除</ElButton> <ElButton link type="danger" @click="remove(row)">删除</ElButton>
</template> </template>
@@ -58,6 +65,12 @@
<ElInput v-model="form.binding_model" placeholder="模型名,如 gpt-4o-mini" /> <ElInput v-model="form.binding_model" placeholder="模型名,如 gpt-4o-mini" />
</div> </div>
</ElFormItem> </ElFormItem>
<ElFormItem label="部门范围">
<ElSelect v-model="form.department_ids" multiple filterable class="w-full" placeholder="留空 = 全局渠道(所有部门可见)">
<ElOption v-for="department in departments" :key="department.id" :label="department.name" :value="department.id" />
</ElSelect>
<div class="text-g-400 text-xs">仅列出的部门可见此渠道;用户级授权在授权中单独管理</div>
</ElFormItem>
<ElFormItem label="网关 API Key"> <ElFormItem label="网关 API Key">
<ElInput v-model="form.api_key" type="password" show-password :placeholder="editingId ? '留空不更换' : '必填'" /> <ElInput v-model="form.api_key" type="password" show-password :placeholder="editingId ? '留空不更换' : '必填'" />
</ElFormItem> </ElFormItem>
@@ -82,6 +95,25 @@
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton> <ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
</template> </template>
</ElDialog> </ElDialog>
<ElDialog v-model="grantsVisible" :title="`渠道授权 · ${grantsChannel?.name || ''}`" width="640px">
<div class="mb-3 flex gap-2">
<ElSelect v-model="grantUserID" filterable class="flex-1" placeholder="选择门户用户">
<ElOption v-for="user in portalUsers" :key="user.id" :label="`${user.display_name || user.login} (${user.login})`" :value="user.id" />
</ElSelect>
<ElButton type="primary" :loading="granting" :disabled="!grantUserID" @click="grant">授予</ElButton>
</div>
<ElTable :data="grants" row-key="portal_user_id" max-height="360">
<ElTableColumn prop="user_login" label="用户" min-width="160" />
<ElTableColumn label="来源" width="100">
<template #default="{ row }"><ElTag size="small" :type="row.source === 'approval' ? 'warning' : 'primary'">{{ row.source === 'approval' ? '审批' : '手动' }}</ElTag></template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="授权时间" width="180" />
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="{ row }"><ElButton link type="danger" @click="revoke(row)">撤销</ElButton></template>
</ElTableColumn>
</ElTable>
</ElDialog>
</div> </div>
</template> </template>
@@ -95,9 +127,13 @@
name: string name: string
kind: string kind: string
model_binding: Record<string, any> model_binding: Record<string, any>
department_ids: string[]
has_api_key: boolean has_api_key: boolean
enabled: boolean enabled: boolean
} }
interface ChannelGrant { channel_id: string; channel_code: string; channel_name: string; portal_user_id: string; user_login: string; source: string; created_at: string }
interface Department { id: string; name: string }
interface PortalUser { id: string; login: string; display_name: string }
const kindMap: Record<string, string> = { webhook: 'Webhook', wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' } const kindMap: Record<string, string> = { webhook: 'Webhook', wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' }
const kindLabel = (kind: string) => kindMap[kind] || kind const kindLabel = (kind: string) => kindMap[kind] || kind
@@ -109,15 +145,30 @@
const testingId = ref('') const testingId = ref('')
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref('') const editingId = ref('')
const departments = ref<Department[]>([])
const portalUsers = ref<PortalUser[]>([])
const grantsVisible = ref(false)
const grantsChannel = ref<Channel>()
const grants = ref<ChannelGrant[]>([])
const grantUserID = ref('')
const granting = ref(false)
const form = reactive({ const form = reactive({
code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '',
inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '' inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '',
department_ids: [] as string[]
}) })
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
channels.value = await request.get<Channel[]>({ url: '/api/v1/admin/channels' }) const [channelList, departmentList, userList] = await Promise.all([
request.get<Channel[]>({ url: '/api/v1/admin/channels' }),
request.get<Department[]>({ url: '/api/v1/admin/departments' }),
request.get<PortalUser[]>({ url: '/api/v1/admin/identities/portal-users' })
])
channels.value = channelList
departments.value = departmentList
portalUsers.value = userList
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -125,7 +176,7 @@
function openCreate() { function openCreate() {
editingId.value = '' editingId.value = ''
Object.assign(form, { code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '' }) Object.assign(form, { code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '', department_ids: [] })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -133,7 +184,8 @@
editingId.value = row.id editingId.value = row.id
Object.assign(form, { Object.assign(form, {
code: row.code, name: row.name, kind: row.kind, api_key: '', code: row.code, name: row.name, kind: row.kind, api_key: '',
binding_provider: row.model_binding?.provider || '', binding_model: row.model_binding?.model || '' binding_provider: row.model_binding?.provider || '', binding_model: row.model_binding?.model || '',
department_ids: [...(row.department_ids || [])]
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -160,6 +212,7 @@
const payload = { const payload = {
code: form.code, name: form.name, kind: form.kind, config, code: form.code, name: form.name, kind: form.kind, config,
model_binding: { provider: form.binding_provider || undefined, model: form.binding_model || undefined }, model_binding: { provider: form.binding_provider || undefined, model: form.binding_model || undefined },
department_ids: form.department_ids,
api_key: form.api_key api_key: form.api_key
} }
if (editingId.value) { if (editingId.value) {
@@ -186,6 +239,38 @@
} }
} }
async function openGrants(row: Channel) {
grantsChannel.value = row
grantUserID.value = ''
grants.value = await request.get<ChannelGrant[]>({ url: `/api/v1/admin/channels/${row.id}/grants` })
grantsVisible.value = true
}
async function grant() {
if (!grantUserID.value || !grantsChannel.value) return
granting.value = true
try {
await request.post({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants`, params: { portal_user_id: grantUserID.value } })
ElMessage.success('已授予')
grantUserID.value = ''
grants.value = await request.get<ChannelGrant[]>({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants` })
} finally {
granting.value = false
}
}
async function revoke(row: ChannelGrant) {
try {
await ElMessageBox.confirm(`撤销「${row.user_login}」的渠道使用权限?`, '撤销授权', { type: 'warning' })
} catch {
return
}
if (!grantsChannel.value) return
await request.del({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants/${row.portal_user_id}` })
grants.value = grants.value.filter((item) => item.portal_user_id !== row.portal_user_id)
ElMessage.success('已撤销')
}
async function remove(row: Channel) { async function remove(row: Channel) {
try { try {
await ElMessageBox.confirm(`确认删除渠道「${row.name}」?`, '删除渠道', { type: 'warning' }) await ElMessageBox.confirm(`确认删除渠道「${row.name}」?`, '删除渠道', { type: 'warning' })
+4
View File
@@ -114,3 +114,7 @@ export const fetchMyEmployeeRuns=(limit=20)=>request.get<EmployeeRun[]>({url:'/a
export interface AgentPolicy { auto_approve_tools:boolean;rate_limit_multiplier:number } export interface AgentPolicy { auto_approve_tools:boolean;rate_limit_multiplier:number }
export const fetchAgentPolicy=()=>request.get<AgentPolicy>({url:'/api/v1/portal/agent-policy'}) export const fetchAgentPolicy=()=>request.get<AgentPolicy>({url:'/api/v1/portal/agent-policy'})
export const setAgentPolicy=(params:AgentPolicy)=>request.put<{saved:boolean}>({url:'/api/v1/portal/agent-policy',params}) export const setAgentPolicy=(params:AgentPolicy)=>request.put<{saved:boolean}>({url:'/api/v1/portal/agent-policy',params})
// --- 我的渠道(部门可见或已授权) ---
export interface VisibleChannel { id:string;code:string;name:string;kind:string;model_binding?:Record<string,unknown>;department_ids:string[];enabled:boolean }
export const fetchMyChannels=()=>request.get<{channels:VisibleChannel[];granted_codes:string[]}>({url:'/api/v1/portal/channels'})
@@ -28,6 +28,31 @@
</ElTableColumn> </ElTableColumn>
</ElTable> </ElTable>
<ElCard shadow="never" class="mb-5">
<template #header>
<div class="flex items-center justify-between">
<div class="font-medium">可使用渠道</div>
<span class="text-g-400 text-xs">部门可见或已获授权需要更多渠道请到我的申请发起申请</span>
</div>
</template>
<ElTable v-loading="loading" :data="visibleChannels" row-key="code">
<ElTableColumn prop="name" label="名称" min-width="150" />
<ElTableColumn prop="code" label="代码" width="150" />
<ElTableColumn label="类型" width="110">
<template #default="{ row }">{{ kindLabel(row.kind) }}</template>
</ElTableColumn>
<ElTableColumn label="绑定模型" min-width="170">
<template #default="{ row }">{{ row.model_binding?.model || '—' }}</template>
</ElTableColumn>
<ElTableColumn label="授权方式" width="110">
<template #default="{ row }">
<ElTag :type="grantedCodes.includes(row.code) ? 'success' : 'info'">{{ grantedCodes.includes(row.code) ? '已授权' : '部门可见' }}</ElTag>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-if="!visibleChannels.length && !loading" description="暂无可用渠道" :image-size="60" />
</ElCard>
<ElDialog v-model="visible" title="新建 Webhook 渠道" width="560px"> <ElDialog v-model="visible" title="新建 Webhook 渠道" width="560px">
<ElForm label-width="100px"> <ElForm label-width="100px">
<ElFormItem label="名称" required><ElInput v-model="form.name" maxlength="128" placeholder="例如 报表机器人" /></ElFormItem> <ElFormItem label="名称" required><ElInput v-model="form.name" maxlength="128" placeholder="例如 报表机器人" /></ElFormItem>
@@ -69,9 +94,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
ChatModel, PersonalChannel, ChatModel, PersonalChannel, VisibleChannel,
createPersonalChannel, deletePersonalChannel, fetchChatModels, createPersonalChannel, deletePersonalChannel, fetchChatModels,
fetchPersonalChannels, regeneratePersonalToken fetchMyChannels, fetchPersonalChannels, regeneratePersonalToken
} from '@/api/portal' } from '@/api/portal'
const loading = ref(false) const loading = ref(false)
@@ -79,6 +104,8 @@ const saving = ref(false)
const visible = ref(false) const visible = ref(false)
const inboundVisible = ref(false) const inboundVisible = ref(false)
const items = ref<PersonalChannel[]>([]) const items = ref<PersonalChannel[]>([])
const visibleChannels = ref<VisibleChannel[]>([])
const grantedCodes = ref<string[]>([])
const models = ref<ChatModel[]>([]) const models = ref<ChatModel[]>([])
const selectedModel = ref('') const selectedModel = ref('')
const current = ref<PersonalChannel>() const current = ref<PersonalChannel>()
@@ -97,10 +124,16 @@ const modelGroups = computed(() => {
return groups return groups
}) })
const kindMap: Record<string, string> = { webhook: 'Webhook', wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' }
const kindLabel = (kind: string) => kindMap[kind] || kind
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
items.value = await fetchPersonalChannels() const [personal, visible] = await Promise.all([fetchPersonalChannels(), fetchMyChannels()])
items.value = personal
visibleChannels.value = visible.channels || []
grantedCodes.value = visible.granted_codes || []
} finally { } finally {
loading.value = false loading.value = false
} }