8000bccde3
- 迁移 000047:channels.department_ids(空=全局) + channel_grants 用户级授权 (source=manual/approval 区分来源)。 - 管理端:渠道部门范围配置 + 授权管理(列表/授予/撤销);渠道列表显示范围。 - 门户:我的渠道端点(/api/v1/portal/channels)按部门可见或明确授权返回, 「个人渠道」页新增可使用渠道区(授权方式标识)。 - 审批流:资源申请中的渠道类型通过后自动写 channel_grants(source=approval), 取代'批准记录即授权'的弱语义。 - 端到端验证:部门隔离(demo 无部门看不到)→手动授予→可见→撤销→不可见; 审批通过自动授权。修复 JOIN 列歧义与 uuid/text 比较。
384 lines
14 KiB
Go
384 lines
14 KiB
Go
package portal
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"aigateway.local/core/internal/channel"
|
|
"aigateway.local/core/internal/identity"
|
|
platformid "aigateway.local/core/internal/platform/id"
|
|
"aigateway.local/core/internal/workbench"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("portal resource not found")
|
|
|
|
type Service struct {
|
|
envVars *workbench.EnvVarService
|
|
pool *pgxpool.Pool
|
|
assets *workbench.Service
|
|
tools *workbench.ToolService
|
|
identity *identity.Service
|
|
credentials *RuntimeCredentials
|
|
runtime http.Handler
|
|
gateway http.Handler
|
|
market *workbench.MarketplaceService
|
|
channels *channel.Service
|
|
}
|
|
|
|
func NewService(pool *pgxpool.Pool, assets *workbench.Service, tools *workbench.ToolService, identityService *identity.Service) *Service {
|
|
return &Service{pool: pool, assets: assets, tools: tools, identity: identityService}
|
|
}
|
|
|
|
// SetEnvVarService 启用个人环境变量合并(对话请求未提供的变量用用户配置补充)。
|
|
func (s *Service) SetEnvVarService(service *workbench.EnvVarService) { s.envVars = service }
|
|
|
|
func (s *Service) SetApplicationRuntime(credentials *RuntimeCredentials, runtime http.Handler) {
|
|
s.credentials = credentials
|
|
s.runtime = runtime
|
|
}
|
|
|
|
// SetGateway wires the governed gateway handler so the general chat can call
|
|
// /v1/chat/completions with the user's own runtime credential.
|
|
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
|
|
// marketplace pages can browse, install and manage resources.
|
|
func (s *Service) SetMarketplace(market *workbench.MarketplaceService) {
|
|
s.market = market
|
|
}
|
|
|
|
func visible(departmentIDs []string, departmentID *string) bool {
|
|
if len(departmentIDs) == 0 {
|
|
return true
|
|
}
|
|
if departmentID == nil {
|
|
return false
|
|
}
|
|
for _, id := range departmentIDs {
|
|
if id == *departmentID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) Applications(ctx context.Context, account identity.Account) ([]workbench.Application, error) {
|
|
items, err := s.assets.ListApplications(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]workbench.Application, 0, len(items))
|
|
for _, item := range items {
|
|
if item.Status == "active" && item.PublishedVersion != nil && visible(item.DepartmentIDs, account.DepartmentID) {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) Knowledge(ctx context.Context, account identity.Account) ([]workbench.KnowledgeBase, error) {
|
|
items, err := s.assets.ListKnowledgeBases(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]workbench.KnowledgeBase, 0, len(items))
|
|
for _, item := range items {
|
|
if item.Enabled && visible(item.DepartmentIDs, account.DepartmentID) {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) Tools(ctx context.Context, account identity.Account) ([]workbench.Tool, error) {
|
|
items, err := s.tools.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]workbench.Tool, 0, len(items))
|
|
for _, item := range items {
|
|
if item.Enabled && visible(item.DepartmentIDs, account.DepartmentID) {
|
|
item.EndpointURL = ""
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type PromptView struct {
|
|
workbench.PromptTemplate
|
|
Favorite bool `json:"favorite"`
|
|
}
|
|
|
|
func (s *Service) Prompts(ctx context.Context, account identity.Account) ([]PromptView, error) {
|
|
items, err := s.assets.ListPrompts(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.pool.Query(ctx, `SELECT prompt_id::text FROM gateway.prompt_favorites WHERE portal_user_id=$1`, account.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
favorites := map[string]bool{}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
favorites[id] = true
|
|
}
|
|
result := make([]PromptView, 0, len(items))
|
|
for _, item := range items {
|
|
if item.Enabled && item.Current != nil && visible(item.DepartmentIDs, account.DepartmentID) {
|
|
result = append(result, PromptView{PromptTemplate: item, Favorite: favorites[item.ID]})
|
|
}
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *Service) Prompt(ctx context.Context, account identity.Account, id string) (PromptView, error) {
|
|
item, err := s.assets.GetPrompt(ctx, id)
|
|
if err != nil || !item.Enabled || item.Current == nil || !visible(item.DepartmentIDs, account.DepartmentID) {
|
|
return PromptView{}, ErrNotFound
|
|
}
|
|
var favorite bool
|
|
err = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.prompt_favorites WHERE portal_user_id=$1 AND prompt_id=$2)`, account.ID, id).Scan(&favorite)
|
|
return PromptView{PromptTemplate: item, Favorite: favorite}, err
|
|
}
|
|
|
|
func (s *Service) SetFavorite(ctx context.Context, account identity.Account, id string, value bool) error {
|
|
if _, err := s.Prompt(ctx, account, id); err != nil {
|
|
return err
|
|
}
|
|
if value {
|
|
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.prompt_favorites(portal_user_id,prompt_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, account.ID, id)
|
|
return err
|
|
}
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM gateway.prompt_favorites WHERE portal_user_id=$1 AND prompt_id=$2`, account.ID, id)
|
|
return err
|
|
}
|
|
|
|
type Model struct {
|
|
ProviderCode string `json:"provider_code"`
|
|
ID string `json:"id"`
|
|
OwnedBy string `json:"owned_by"`
|
|
}
|
|
|
|
func (s *Service) Models(ctx context.Context) ([]Model, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT p.code,m.provider_model_id,m.owned_by FROM gateway.provider_models m JOIN gateway.providers p ON p.id=m.provider_id WHERE p.enabled AND m.enabled ORDER BY p.code,m.provider_model_id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Model{}
|
|
for rows.Next() {
|
|
var item Model
|
|
if err := rows.Scan(&item.ProviderCode, &item.ID, &item.OwnedBy); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
type ModelRequest struct {
|
|
ID string `json:"id"`
|
|
PortalUserID string `json:"portal_user_id"`
|
|
UserLogin string `json:"user_login,omitempty"`
|
|
ProviderCode string `json:"provider_code"`
|
|
Model string `json:"model"`
|
|
Reason string `json:"reason"`
|
|
RequestedRPM int `json:"requested_rpm"`
|
|
RequestedMonthlyTokens int64 `json:"requested_monthly_tokens"`
|
|
Status string `json:"status"`
|
|
DecisionNote string `json:"decision_note"`
|
|
DecidedAt *time.Time `json:"decided_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
const modelRequestSelect = `SELECT r.id::text,r.portal_user_id::text,u.account,r.provider_code,r.model,r.reason,r.requested_rpm,r.requested_monthly_tokens,r.status,r.decision_note,r.decided_at,r.created_at,r.updated_at FROM gateway.model_access_requests r JOIN gateway.portal_users u ON u.id=r.portal_user_id`
|
|
|
|
func scanModelRequest(row pgx.Row) (ModelRequest, error) {
|
|
var item ModelRequest
|
|
err := row.Scan(&item.ID, &item.PortalUserID, &item.UserLogin, &item.ProviderCode, &item.Model, &item.Reason, &item.RequestedRPM, &item.RequestedMonthlyTokens, &item.Status, &item.DecisionNote, &item.DecidedAt, &item.CreatedAt, &item.UpdatedAt)
|
|
return item, err
|
|
}
|
|
|
|
func (s *Service) CreateModelRequest(ctx context.Context, account identity.Account, item ModelRequest) (ModelRequest, error) {
|
|
item.ProviderCode = strings.ToLower(strings.TrimSpace(item.ProviderCode))
|
|
item.Model = strings.TrimSpace(item.Model)
|
|
item.Reason = strings.TrimSpace(item.Reason)
|
|
if item.Model == "" || len(item.Model) > 512 || len(item.Reason) > 4000 {
|
|
return ModelRequest{}, errors.New("模型或申请理由格式无效")
|
|
}
|
|
if item.RequestedRPM == 0 {
|
|
item.RequestedRPM = 60
|
|
}
|
|
if item.RequestedRPM < 1 || item.RequestedRPM > 100000 || item.RequestedMonthlyTokens < 0 {
|
|
return ModelRequest{}, errors.New("申请限额无效")
|
|
}
|
|
item.ID, _ = platformid.NewUUID()
|
|
eventID, _ := platformid.NewUUID()
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
_, err = tx.Exec(ctx, `INSERT INTO gateway.model_access_requests(id,portal_user_id,provider_code,model,reason,requested_rpm,requested_monthly_tokens) VALUES($1,$2,$3,$4,$5,$6,$7)`, item.ID, account.ID, item.ProviderCode, item.Model, item.Reason, item.RequestedRPM, item.RequestedMonthlyTokens)
|
|
if err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
payload, _ := json.Marshal(map[string]any{"request_id": item.ID, "portal_user_id": account.ID, "model": item.Model})
|
|
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'model_access.requested',1,'model_access_request',$2,$3)`, eventID, item.ID, payload); err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
return scanModelRequest(s.pool.QueryRow(ctx, modelRequestSelect+` WHERE r.id=$1`, item.ID))
|
|
}
|
|
|
|
func (s *Service) ModelRequests(ctx context.Context, portalUserID, status string) ([]ModelRequest, error) {
|
|
where, args := " WHERE true", []any{}
|
|
if portalUserID != "" {
|
|
args = append(args, portalUserID)
|
|
where += fmt.Sprintf(" AND r.portal_user_id=$%d", len(args))
|
|
}
|
|
if status != "" {
|
|
args = append(args, status)
|
|
where += fmt.Sprintf(" AND r.status=$%d", len(args))
|
|
}
|
|
rows, err := s.pool.Query(ctx, modelRequestSelect+where+` ORDER BY r.created_at DESC LIMIT 500`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ModelRequest{}
|
|
for rows.Next() {
|
|
item, err := scanModelRequest(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Service) DecideModelRequest(ctx context.Context, id, status, note, actorID string) (ModelRequest, error) {
|
|
if status != "approved" && status != "rejected" {
|
|
return ModelRequest{}, errors.New("审批状态无效")
|
|
}
|
|
if len(note) > 4000 {
|
|
return ModelRequest{}, errors.New("审批备注过长")
|
|
}
|
|
eventID, _ := platformid.NewUUID()
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
tag, err := tx.Exec(ctx, `UPDATE gateway.model_access_requests SET status=$2,decision_note=$3,decided_by=$4,decided_at=clock_timestamp(),updated_at=clock_timestamp() WHERE id=$1 AND status='pending'`, id, status, strings.TrimSpace(note), actorID)
|
|
if err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ModelRequest{}, ErrNotFound
|
|
}
|
|
payload, _ := json.Marshal(map[string]any{"request_id": id, "status": status, "actor_id": actorID})
|
|
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'model_access.decided',1,'model_access_request',$2,$3)`, eventID, id, payload); err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return ModelRequest{}, err
|
|
}
|
|
return scanModelRequest(s.pool.QueryRow(ctx, modelRequestSelect+` WHERE r.id=$1`, id))
|
|
}
|
|
|
|
// --- 资源市场 ---
|
|
|
|
// MarketplaceCatalog returns the published resources visible to the portal
|
|
// user. Department-scoped resources only appear when the user belongs to the
|
|
// department; cross-department resources require an install and are managed
|
|
// from the "my installs" surface.
|
|
func (s *Service) MarketplaceCatalog(ctx context.Context, account identity.Account, resourceType, categoryID, tag, query string) ([]workbench.MarketItem, error) {
|
|
if s.market == nil {
|
|
return []workbench.MarketItem{}, nil
|
|
}
|
|
items, err := s.market.Catalog(ctx, resourceType, categoryID, tag, query, 100)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]workbench.MarketItem, 0, len(items))
|
|
for _, item := range items {
|
|
if visible(item.DepartmentIDs, account.DepartmentID) {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// MarketplaceDetail returns a published resource the user is allowed to see.
|
|
func (s *Service) MarketplaceDetail(ctx context.Context, account identity.Account, resourceType, code string) (workbench.MarketItem, json.RawMessage, error) {
|
|
if s.market == nil {
|
|
return workbench.MarketItem{}, nil, ErrNotFound
|
|
}
|
|
item, raw, err := s.market.Detail(ctx, resourceType, code)
|
|
if err != nil {
|
|
return item, nil, err
|
|
}
|
|
if !visible(item.DepartmentIDs, account.DepartmentID) {
|
|
return item, nil, ErrNotFound
|
|
}
|
|
return item, raw, nil
|
|
}
|
|
|
|
// MarketplaceInstall binds a published, visible resource to the portal user's
|
|
// workspace. Cross-department resources the user cannot see cannot be installed.
|
|
func (s *Service) MarketplaceInstall(ctx context.Context, account identity.Account, resourceType, code, permissionLevel string) (bool, error) {
|
|
if s.market == nil {
|
|
return false, ErrNotFound
|
|
}
|
|
item, _, err := s.market.Detail(ctx, resourceType, code)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !visible(item.DepartmentIDs, account.DepartmentID) {
|
|
return false, ErrNotFound
|
|
}
|
|
return s.market.Install(ctx, resourceType, code, account.ID, permissionLevel)
|
|
}
|
|
|
|
func (s *Service) MarketplaceUninstall(ctx context.Context, account identity.Account, resourceType, code string) error {
|
|
if s.market == nil {
|
|
return ErrNotFound
|
|
}
|
|
return s.market.Uninstall(ctx, resourceType, code, account.ID)
|
|
}
|
|
|
|
func (s *Service) MarketplaceInstalled(ctx context.Context, account identity.Account) ([]workbench.MarketItem, error) {
|
|
if s.market == nil {
|
|
return []workbench.MarketItem{}, nil
|
|
}
|
|
return s.market.ListInstalled(ctx, account.ID)
|
|
}
|
|
|
|
func (s *Service) MarketplaceCategories(ctx context.Context) ([]workbench.Category, error) {
|
|
if s.market == nil {
|
|
return []workbench.Category{}, nil
|
|
}
|
|
return s.market.ListCategories(ctx, "")
|
|
}
|