ea78ef5674
P1-聊天 SSE 流式响应:
- 新增 POST /chat/sessions/{id}/messages/stream:网关 text/event-stream 实时
透传,流结束整轮落库(哈希链),上游忽略 stream 返回普通 JSON 时自动转
SSE 事件,非 2xx 错误缓冲后走统一错误处理(不落 header);
- 前端 fetch+ReadableStream 解析 SSE,占位气泡实时填充,支持停止生成
(AbortController),切会话丢弃迟到增量防串扰。
P2-管理操作审计(admin_op_logs):
- 新表+oplog 包(同步写,失败不阻塞业务);管理端查询端点
GET /api/v1/admin/op-logs(操作者/类型过滤+分页,audit:read);
- 埋点:渠道 save/delete/grant(幂等重复不重复记)/revoke_grant、账号
create/update、角色 CRUD、API Key create/revoke/limits、工具
save/delete、审批决定(资源/工具)、模型配额;管理端「操作审计」菜单。
P2-列表分页与安全上限:
- 用户/管理员列表 q+limit+offset 分页(默认 50 上限 200),渠道授权弹窗
改远程搜索,不再全量拉取 portal-users;api_keys/channels List 加
LIMIT 200 防全表扫描。
健壮性:
- ChatModels/approvedModel 对 decided_at 为 NULL 的历史批准记录
COALESCE 兜底,修复 NULL scan 报错;
- docker-compose 补 ALLOW_PRIVATE_PROVIDER_URLS 透传(默认 false)。
测试:
- portal: 流式解析/错误提取/stream writer 模式单测,会话生命周期/哈希链
完整性/200 条上限/busy 租约回收集成测试;
- channel: CRUD+加解密+部门可见性+授权撤销+幂等+审计落库集成测试。
全部通过;全量 go vet 干净。
137 lines
5.0 KiB
Go
137 lines
5.0 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
platformid "aigateway.local/core/internal/platform/id"
|
|
"aigateway.local/core/internal/platform/oplog"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Role 是一个自定义角色定义(内置角色在代码中,不落库)。
|
|
type Role struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Permissions []string `json:"permissions"`
|
|
Builtin bool `json:"builtin"`
|
|
CreatedBy *string `json:"created_by,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// ListRoles 返回全部角色(内置 + 自定义)。
|
|
func (r *Repository) ListRoles(ctx context.Context) ([]Role, error) {
|
|
if r.pool == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
rows, err := r.pool.Query(ctx, `SELECT id::text,code,name,description,permissions,builtin,created_by::text,created_at,updated_at FROM gateway.roles ORDER BY builtin DESC,created_at`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
defer rows.Close()
|
|
items := []Role{}
|
|
for rows.Next() {
|
|
var item Role
|
|
var createdBy *string
|
|
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Description, &item.Permissions, &item.Builtin, &createdBy, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
item.CreatedBy = createdBy
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
// 合并代码内置角色(带可读名称)。
|
|
builtinNames := map[string]string{"superadmin": "超级管理员", "operator": "运维操作员", "auditor": "审计员", "member": "普通成员"}
|
|
for code, permissions := range rolePermissions {
|
|
items = append(items, Role{ID: "builtin:" + code, Code: code, Name: builtinNames[code], Permissions: permissions, Builtin: true})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// FindRole 按 code 查询角色;builtin 角色由代码返回。
|
|
func (r *Repository) FindRole(ctx context.Context, code string) (Role, error) {
|
|
code = strings.ToLower(strings.TrimSpace(code))
|
|
if permissions, ok := rolePermissions[code]; ok {
|
|
return Role{Code: code, Name: code, Permissions: permissions, Builtin: true}, nil
|
|
}
|
|
var item Role
|
|
var createdBy *string
|
|
err := r.pool.QueryRow(ctx, `SELECT id::text,code,name,description,permissions,builtin,created_by::text,created_at,updated_at FROM gateway.roles WHERE code=$1`, code).Scan(&item.ID, &item.Code, &item.Name, &item.Description, &item.Permissions, &item.Builtin, &createdBy, &item.CreatedAt, &item.UpdatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Role{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Role{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
item.CreatedBy = createdBy
|
|
return item, nil
|
|
}
|
|
|
|
// SaveRole 创建或更新自定义角色;内置角色禁止修改。
|
|
func (r *Repository) SaveRole(ctx context.Context, id, code, name, description string, permissions []string, actorID string, create bool) (Role, error) {
|
|
if r.pool == nil {
|
|
return Role{}, ErrUnavailable
|
|
}
|
|
code = strings.ToLower(strings.TrimSpace(code))
|
|
if _, builtin := rolePermissions[code]; builtin {
|
|
return Role{}, errors.New("内置角色不可修改")
|
|
}
|
|
if create {
|
|
id, err := platformid.NewUUID()
|
|
if err != nil {
|
|
return Role{}, err
|
|
}
|
|
_, err = r.pool.Exec(ctx, `INSERT INTO gateway.roles(id,code,name,description,permissions,created_by) VALUES($1,$2,$3,$4,$5,$6)`, id, code, strings.TrimSpace(name), strings.TrimSpace(description), permissions, actorID)
|
|
if err != nil {
|
|
return Role{}, mapRoleError(err)
|
|
}
|
|
oplog.Record(ctx, r.pool, nil, actorID, "", "role.create", "role", id, map[string]any{"code": code, "name": name, "permissions": permissions})
|
|
return r.FindRole(ctx, code)
|
|
}
|
|
tag, err := r.pool.Exec(ctx, `UPDATE gateway.roles SET name=$2,description=$3,permissions=$4,updated_at=clock_timestamp() WHERE id=$1 AND NOT builtin`, id, strings.TrimSpace(name), strings.TrimSpace(description), permissions)
|
|
if err != nil {
|
|
return Role{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return Role{}, ErrNotFound
|
|
}
|
|
oplog.Record(ctx, r.pool, nil, actorID, "", "role.update", "role", id, map[string]any{"code": code, "name": name, "permissions": permissions})
|
|
var item Role
|
|
item, err = r.FindRole(ctx, code)
|
|
if err != nil {
|
|
return Role{}, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
// DeleteRole 删除自定义角色(内置角色禁止)。
|
|
func (r *Repository) DeleteRole(ctx context.Context, id, actorID string) error {
|
|
if r.pool == nil {
|
|
return ErrUnavailable
|
|
}
|
|
tag, err := r.pool.Exec(ctx, `DELETE FROM gateway.roles WHERE id=$1 AND NOT builtin`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
oplog.Record(ctx, r.pool, nil, actorID, "", "role.delete", "role", id, nil)
|
|
return nil
|
|
}
|
|
|
|
func mapRoleError(err error) error {
|
|
var pgError interface{ Code() string }
|
|
if errors.As(err, &pgError) && pgError.Code() == "23505" {
|
|
return ErrIdentityConflict
|
|
}
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|