0.11.8: 优化方向落地(流式聊天/操作审计/列表分页/测试补齐)
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 干净。
This commit is contained in:
@@ -102,10 +102,11 @@ func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionChannelManage); !ok {
|
||||
actor, ok := h.require(w, r, identity.PermissionChannelManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(r.Context(), r.PathValue("id")); err != nil {
|
||||
if err := h.service.Delete(r.Context(), r.PathValue("id"), actor.ID); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -267,10 +268,11 @@ func (h *HTTPHandler) grant(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// revokeGrant 撤销用户的渠道使用权限。
|
||||
func (h *HTTPHandler) revokeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionChannelManage); !ok {
|
||||
actor, ok := h.require(w, r, identity.PermissionChannelManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.RevokeGrant(r.Context(), r.PathValue("id"), r.PathValue("user_id")); err != nil {
|
||||
if err := h.service.RevokeGrant(r.Context(), r.PathValue("id"), r.PathValue("user_id"), actor.ID); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"aigateway.local/core/internal/platform/oplog"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -101,12 +102,12 @@ func (s *Service) scan(row pgx.Row) (Channel, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// List 返回渠道列表(不含敏感配置)。
|
||||
// List 返回渠道列表(不含敏感配置)。安全上限 200,管理端一次性渲染。
|
||||
func (s *Service) List(ctx context.Context) ([]Channel, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, channelSelect+` ORDER BY updated_at DESC`)
|
||||
rows, err := s.pool.Query(ctx, channelSelect+` ORDER BY updated_at DESC LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -196,10 +197,15 @@ func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Con
|
||||
if err != nil {
|
||||
return Channel{}, err
|
||||
}
|
||||
oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.save", "channel", id, map[string]any{
|
||||
"code": code, "name": name, "kind": kind, "enabled": enabled,
|
||||
"department_ids": departmentIDs, "has_api_key": apiKey != "",
|
||||
})
|
||||
return s.scan(s.pool.QueryRow(ctx, channelSelect+` WHERE id=$1`, id))
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
// Delete 删除渠道。
|
||||
func (s *Service) Delete(ctx context.Context, id, actorID string) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
@@ -210,6 +216,7 @@ func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.delete", "channel", id, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -498,12 +505,19 @@ func (s *Service) Grant(ctx context.Context, channelID, portalUserID, actorID, s
|
||||
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
|
||||
tag, 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 幂等重复授权(已存在)不重复落审计。
|
||||
if tag.RowsAffected() > 0 {
|
||||
oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.grant", "channel", channelID, map[string]any{"portal_user_id": portalUserID, "source": source})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeGrant 撤销用户的渠道使用权限。
|
||||
func (s *Service) RevokeGrant(ctx context.Context, channelID, portalUserID string) error {
|
||||
func (s *Service) RevokeGrant(ctx context.Context, channelID, portalUserID, actorID string) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
@@ -514,6 +528,7 @@ func (s *Service) RevokeGrant(ctx context.Context, channelID, portalUserID strin
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.revoke_grant", "channel", channelID, map[string]any{"portal_user_id": portalUserID})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// channel 包集成测试:依赖真实 PostgreSQL(WORKBENCH_TEST_DATABASE_URL)。
|
||||
// 覆盖渠道 CRUD、配置加解密、部门可见性与用户级授权(手动/审批)、
|
||||
// 撤销后不可见、以及管理操作审计落库。
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"aigateway.local/core/internal/platform/config"
|
||||
"aigateway.local/core/internal/platform/cryptox"
|
||||
"aigateway.local/core/internal/platform/database"
|
||||
)
|
||||
|
||||
const (
|
||||
channelActorID = "33333333-3333-4333-8333-333333333333"
|
||||
channelUserID = "44444444-4444-4444-8444-444444444444"
|
||||
channelDeptID = "55555555-5555-4555-8555-555555555555"
|
||||
)
|
||||
|
||||
func newChannelTestService(t *testing.T) (*Service, *cryptox.Keyring, context.Context) {
|
||||
t.Helper()
|
||||
databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("WORKBENCH_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
cipher, err := cryptox.NewKeyring("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", 1, "", "channel-config")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
return NewService(pool, "http://gateway.invalid", cipher, logger), cipher, ctx
|
||||
}
|
||||
|
||||
func seedChannelUsers(t *testing.T, s *Service) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'channel-test-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, channelActorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.departments(id,code,name,active) VALUES($1,'integration-dept','集成测试部门',true) ON CONFLICT(id) DO NOTHING`, channelDeptID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,name,role,permissions,password_hash,auth_source,active) VALUES($1,'channel-test-user','渠道测试用户','member','{}','test','local',true) ON CONFLICT(id) DO NOTHING`, channelUserID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelLifecycleAndGrants(t *testing.T) {
|
||||
s, _, ctx := newChannelTestService(t)
|
||||
cleanup := func() {
|
||||
_, _ = s.pool.Exec(ctx, `DELETE FROM gateway.channel_grants WHERE channel_id IN (SELECT id FROM gateway.channels WHERE code='integration-channel')`)
|
||||
_, _ = s.pool.Exec(ctx, `DELETE FROM gateway.admin_op_logs WHERE resource_type='channel' AND resource_id IN (SELECT id::text FROM gateway.channels WHERE code='integration-channel')`)
|
||||
_, _ = s.pool.Exec(ctx, `DELETE FROM gateway.channels WHERE code='integration-channel'`)
|
||||
_, _ = s.pool.Exec(ctx, `DELETE FROM gateway.admin_op_logs WHERE resource_type='channel'`)
|
||||
_, _ = s.pool.Exec(ctx, `DELETE FROM gateway.portal_users WHERE id=$1`, channelUserID)
|
||||
_, _ = s.pool.Exec(ctx, `DELETE FROM gateway.departments WHERE id=$1`, channelDeptID)
|
||||
_, _ = s.pool.Exec(ctx, `DELETE FROM gateway.admin_accounts WHERE id=$1`, channelActorID)
|
||||
}
|
||||
// 先清旧数据,再播种用户/部门(否则刚插入的数据会被清理)。
|
||||
cleanup()
|
||||
seedChannelUsers(t, s)
|
||||
defer cleanup()
|
||||
|
||||
// 1. 创建渠道(webhook, 部门可见)。
|
||||
cfg := Config{InboundToken: "secret-token"}
|
||||
created, err := s.Save(ctx, "", "integration-channel", "集成测试渠道", "webhook", cfg, json.RawMessage(`{"provider":"test","model":"m1"}`), []string{channelDeptID}, "", true, channelActorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2. 配置可解密且令牌正确。
|
||||
decrypted, err := s.DecryptConfig(created)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decrypted.InboundToken != "secret-token" {
|
||||
t.Fatalf("InboundToken = %q", decrypted.InboundToken)
|
||||
}
|
||||
// 3. 部门成员可见:未分配部门时不可见,挂到所属部门后可见。
|
||||
visible, err := s.VisibleChannelsForUser(ctx, channelUserID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(visible) != 0 {
|
||||
t.Fatalf("未分配部门用户不应可见部门渠道, got %d items", len(visible))
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE gateway.portal_users SET department_id=$1 WHERE id=$2`, channelDeptID, channelUserID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deptID := channelDeptID
|
||||
visible, err = s.VisibleChannelsForUser(ctx, channelUserID, &deptID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(visible) != 1 || visible[0].Code != "integration-channel" {
|
||||
t.Fatalf("部门用户应可见渠道, got %d items", len(visible))
|
||||
}
|
||||
// 4. 手动授权另一个用户。
|
||||
if err := s.Grant(ctx, created.ID, channelUserID, channelActorID, "manual"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, err := s.ListGrants(ctx, created.ID)
|
||||
if err != nil || len(grants) != 1 || grants[0].Source != "manual" {
|
||||
t.Fatalf("授权列表 = %+v err=%v", grants, err)
|
||||
}
|
||||
// 5. 撤销授权并清空部门后不可见(证明授权撤销与部门隔离同时生效)。
|
||||
if err := s.RevokeGrant(ctx, created.ID, channelUserID, channelActorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE gateway.portal_users SET department_id=NULL WHERE id=$1`, channelUserID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visible, err = s.VisibleChannelsForUser(ctx, channelUserID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(visible) != 0 {
|
||||
t.Fatalf("撤销授权且无部门后应不可见, got %d items", len(visible))
|
||||
}
|
||||
// 6. 审批来源授权(重复 grant 幂等),授权后重新可见。
|
||||
if err := s.Grant(ctx, created.ID, channelUserID, channelActorID, "approval"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Grant(ctx, created.ID, channelUserID, channelActorID, "approval"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, err = s.ListGrants(ctx, created.ID)
|
||||
if err != nil || len(grants) != 1 || grants[0].Source != "approval" {
|
||||
t.Fatalf("幂等授权后 grants = %+v err=%v", grants, err)
|
||||
}
|
||||
visible, err = s.VisibleChannelsForUser(ctx, channelUserID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(visible) != 1 || visible[0].Code != "integration-channel" {
|
||||
t.Fatalf("审批授权后应可见渠道, got %d items", len(visible))
|
||||
}
|
||||
// 7. 删除渠道(级联清授权)。
|
||||
if err := s.Delete(ctx, created.ID, channelActorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, err = s.ListGrants(ctx, created.ID)
|
||||
if err != nil || len(grants) != 0 {
|
||||
t.Fatalf("删除后授权应清空, got %d err=%v", len(grants), err)
|
||||
}
|
||||
// 8. 管理操作审计落库:save/grant(manual)/grant(approval)/revoke_grant/
|
||||
// delete 各一条,幂等重复授权不重复记录。
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway.admin_op_logs WHERE resource_type='channel' AND resource_id=$1`, created.ID).Scan(&total); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("操作审计条数 = %d, want 5 (save/grant×2/revoke_grant/delete)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelListLimitAndNotFound(t *testing.T) {
|
||||
s, _, ctx := newChannelTestService(t)
|
||||
if _, err := s.GetByCode(ctx, "no-such-channel"); err != ErrNotFound {
|
||||
t.Fatalf("GetByCode 不存在 err = %v, want ErrNotFound", err)
|
||||
}
|
||||
items, err := s.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = items // List 带 LIMIT 200,不因数据量失败即可
|
||||
}
|
||||
Reference in New Issue
Block a user