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:
LLMGuardX Dev
2026-08-13 16:15:02 +08:00
parent 58535fda7b
commit ea78ef5674
25 changed files with 1271 additions and 56 deletions
+6 -4
View File
@@ -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
}
+21 -6
View File
@@ -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,不因数据量失败即可
}