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
+1
View File
@@ -429,6 +429,7 @@ func main() {
controlMux.Handle("/api/v1/admin/monitoring/overview", operationsHandler)
controlMux.Handle("/api/v1/admin/reports/", operationsHandler)
controlMux.Handle("/api/v1/admin/tenants/", operationsHandler)
controlMux.Handle("/api/v1/admin/op-logs", operationsHandler)
controlMux.Handle("/api/v1/admin/files", filesAdminHandler)
controlMux.Handle("/api/v1/admin/files/", filesAdminHandler)
controlMux.Handle("/api/v1/portal/files", filesPortalHandler)
+1
View File
@@ -57,6 +57,7 @@ services:
CREDENTIAL_KEK_KEYRING: ${CREDENTIAL_KEK_KEYRING:-}
ALLOW_PRIVATE_TOOL_URLS: ${ALLOW_PRIVATE_TOOL_URLS:-false}
ALLOW_PRIVATE_WEBHOOK_URLS: ${ALLOW_PRIVATE_WEBHOOK_URLS:-false}
ALLOW_PRIVATE_PROVIDER_URLS: ${ALLOW_PRIVATE_PROVIDER_URLS:-false}
UPSTREAM_BASE_URL: ${UPSTREAM_BASE_URL:-https://api.openai.com}
UPSTREAM_API_KEY: ${UPSTREAM_API_KEY:-}
SHADOW_BASE_URL: ${SHADOW_BASE_URL:-}
+10 -1
View File
@@ -8,6 +8,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"
)
@@ -23,7 +24,7 @@ func (r *Repository) List(ctx context.Context) ([]Record, error) {
rows, err := r.pool.Query(ctx, `
SELECT id::text, tenant_id::text, name, key_prefix, scopes, enabled,
requests_per_minute, monthly_request_quota, monthly_token_quota, expires_at, last_used_at, created_at
FROM gateway.api_keys ORDER BY created_at DESC`)
FROM gateway.api_keys ORDER BY created_at DESC LIMIT 200`)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrStore, err)
}
@@ -77,6 +78,10 @@ func (r *Repository) Create(ctx context.Context, name string, scopes []string, r
if err := tx.Commit(ctx); err != nil {
return Record{}, "", fmt.Errorf("%w: %v", ErrStore, err)
}
oplog.Record(ctx, r.pool, nil, actorID, "", "api_key.create", "api_key", id, map[string]any{
"name": name, "key_prefix": prefix, "requests_per_minute": requestsPerMinute,
"monthly_request_quota": monthlyRequestQuota, "monthly_token_quota": monthlyTokenQuota,
})
return record, secret, nil
}
@@ -131,6 +136,7 @@ func (r *Repository) Revoke(ctx context.Context, id, actorID string) ([]byte, er
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("%w: %v", ErrStore, err)
}
oplog.Record(ctx, r.pool, nil, actorID, "", "api_key.revoke", "api_key", id, nil)
return hash, nil
}
@@ -175,5 +181,8 @@ func (r *Repository) UpdateLimits(ctx context.Context, id string, requestsPerMin
if err := tx.Commit(ctx); err != nil {
return Record{}, nil, fmt.Errorf("%w: %v", ErrStore, err)
}
oplog.Record(ctx, r.pool, nil, actorID, "", "api_key.limits_update", "api_key", id, map[string]any{
"requests_per_minute": requestsPerMinute, "monthly_request_quota": monthlyRequestQuota, "monthly_token_quota": monthlyTokenQuota,
})
return record, record.KeyHash, nil
}
+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,不因数据量失败即可
}
+5 -2
View File
@@ -482,11 +482,14 @@ func adminMenus(account Account) []map[string]any {
menus = append(menus, map[string]any{"name": "Gateway", "path": "/gateway", "component": "/index/index", "meta": map[string]any{"title": "网关接入", "icon": "ri:router-line"}, "children": gatewayChildren})
}
// 安全与审计:审计用量、内容策略、模型治理、Trace、会话与节点。
securityChildren := make([]map[string]any, 0, 6)
// 安全与审计:审计用量、操作审计、内容策略、模型治理、Trace、会话与节点。
securityChildren := make([]map[string]any, 0, 7)
if HasPermission(account, PermissionAuditRead) || HasPermission(account, PermissionUsageRead) {
securityChildren = append(securityChildren, map[string]any{"name": "AuditUsage", "path": "audit-usage", "component": "/gateway/audit-usage", "meta": map[string]any{"title": "审计与用量"}})
}
if HasPermission(account, PermissionAuditRead) {
securityChildren = append(securityChildren, map[string]any{"name": "OpLogs", "path": "op-logs", "component": "/gateway/op-logs", "meta": map[string]any{"title": "操作审计"}})
}
if HasPermission(account, PermissionContentPolicyRead) || HasPermission(account, PermissionContentPolicyManage) {
securityChildren = append(securityChildren, map[string]any{"name": "ContentPolicies", "path": "content-policies", "component": "/gateway/content-policies", "meta": map[string]any{"title": "内容策略"}})
}
+72 -15
View File
@@ -8,10 +8,12 @@ import (
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"aigateway.local/core/internal/platform/apiresponse"
platformid "aigateway.local/core/internal/platform/id"
"aigateway.local/core/internal/platform/oplog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
@@ -96,7 +98,18 @@ func (h *ManagementHTTPHandler) list(kind Kind) http.HandlerFunc {
if _, ok := h.requirePermission(writer, request); !ok {
return
}
accounts, err := h.service.repository.ListIdentities(request.Context(), kind)
// 分页 + 关键字过滤:企业用户量级可能很大,不允许全量返回。
// 默认每页 50,上限 200;q 匹配账号/显示名称前缀(不区分大小写)。
limit, err := strconv.Atoi(request.URL.Query().Get("limit"))
if err != nil || limit < 1 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(request.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
q := strings.TrimSpace(request.URL.Query().Get("q"))
accounts, total, err := h.service.repository.ListIdentities(request.Context(), kind, q, limit, offset)
if err != nil {
h.writeError(writer, err)
return
@@ -105,7 +118,7 @@ func (h *ManagementHTTPHandler) list(kind Kind) http.HandlerFunc {
for _, account := range accounts {
items = append(items, managementView(account))
}
apiresponse.OK(writer, items)
apiresponse.OK(writer, map[string]any{"items": items, "total": total, "limit": limit, "offset": offset})
}
}
@@ -273,8 +286,7 @@ func (h *ManagementHTTPHandler) deleteRole(writer http.ResponseWriter, request *
if !ok {
return
}
_ = actor
if err := h.service.repository.DeleteRole(request.Context(), request.PathValue("role_id")); err != nil {
if err := h.service.repository.DeleteRole(request.Context(), request.PathValue("role_id"), actor.ID); err != nil {
h.writeError(writer, err)
return
}
@@ -416,14 +428,18 @@ func managementView(account Account) map[string]any {
}
}
func (r *Repository) ListIdentities(ctx context.Context, kind Kind) ([]Account, error) {
func (r *Repository) ListIdentities(ctx context.Context, kind Kind, q string, limit, offset int) ([]Account, int, error) {
if r.pool == nil {
return nil, ErrUnavailable
return nil, 0, ErrUnavailable
}
query := `
SELECT id::text, username, display_name, role, permissions, active,
totp_enabled, locked_until, 'local', created_at, updated_at
FROM gateway.admin_accounts ORDER BY lower(username)`
// 关键字同时匹配账号与显示名称(前缀,不区分大小写)。
filter := ""
args := []any{}
if q != "" {
filter = ` AND (lower({{login}}) LIKE $1 OR lower({{name}}) LIKE $1)`
args = append(args, strings.ToLower(q)+"%")
}
var query string
if kind == KindPortal {
query = `
SELECT u.id::text, u.account, u.name, u.role, u.permissions, u.active,
@@ -431,11 +447,26 @@ func (r *Repository) ListIdentities(ctx context.Context, kind Kind) ([]Account,
u.department_id::text, COALESCE(d.name, '')
FROM gateway.portal_users u
LEFT JOIN gateway.departments d ON d.id = u.department_id
ORDER BY lower(u.account)`
WHERE 1=1` + filter + `
ORDER BY lower(u.account)
LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
query = strings.ReplaceAll(query, "{{login}}", "u.account")
query = strings.ReplaceAll(query, "{{name}}", "u.name")
} else {
query = `
SELECT id::text, username, display_name, role, permissions, active,
totp_enabled, locked_until, 'local', created_at, updated_at
FROM gateway.admin_accounts
WHERE 1=1` + filter + `
ORDER BY lower(username)
LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
query = strings.ReplaceAll(query, "{{login}}", "username")
query = strings.ReplaceAll(query, "{{name}}", "display_name")
}
rows, err := r.pool.Query(ctx, query)
args = append(args, limit, offset)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
return nil, 0, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
accounts := make([]Account, 0)
@@ -450,11 +481,29 @@ func (r *Repository) ListIdentities(ctx context.Context, kind Kind) ([]Account,
arguments = append(arguments, &account.DepartmentID, &account.DepartmentName)
}
if err := rows.Scan(arguments...); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
return nil, 0, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
accounts = append(accounts, account)
}
return accounts, mapRepositoryError(rows.Err())
if err := mapRepositoryError(rows.Err()); err != nil {
return nil, 0, err
}
// 总数:同条件 count,用于前端分页。
var total int
countFilter := strings.ReplaceAll(filter, "{{login}}", "u.account")
countFilter = strings.ReplaceAll(countFilter, "{{name}}", "u.name")
countTable := "gateway.portal_users u"
if kind != KindPortal {
countFilter = strings.ReplaceAll(filter, "{{login}}", "username")
countFilter = strings.ReplaceAll(countFilter, "{{name}}", "display_name")
countTable = "gateway.admin_accounts"
}
// args 末尾两个是 limit/offset,count 只用前面的过滤参数。
countArgs := args[:len(args)-2]
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM `+countTable+` WHERE 1=1`+countFilter, countArgs...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return accounts, total, nil
}
func (r *Repository) CreateIdentity(ctx context.Context, account Account, actorID string) (Account, error) {
@@ -506,6 +555,10 @@ func (r *Repository) CreateIdentity(ctx context.Context, account Account, actorI
if err := tx.Commit(ctx); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
oplog.Record(ctx, r.pool, nil, actorID, account.Login, "identity.create", "identity", account.ID, map[string]any{
"kind": account.Kind, "role": account.Role, "active": account.Active,
"department_id": account.DepartmentID,
})
return account, nil
}
@@ -557,6 +610,10 @@ func (r *Repository) UpdateIdentity(ctx context.Context, account Account, passwo
if err := tx.Commit(ctx); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
oplog.Record(ctx, r.pool, nil, actorID, account.Login, "identity.update", "identity", account.ID, map[string]any{
"kind": account.Kind, "role": account.Role, "active": account.Active,
"department_id": account.DepartmentID, "password_changed": passwordHash != nil,
})
return account, nil
}
+5 -1
View File
@@ -7,6 +7,7 @@ import (
"strings"
platformid "aigateway.local/core/internal/platform/id"
"aigateway.local/core/internal/platform/oplog"
"github.com/jackc/pgx/v5"
)
@@ -91,6 +92,7 @@ func (r *Repository) SaveRole(ctx context.Context, id, code, name, description s
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)
@@ -100,6 +102,7 @@ func (r *Repository) SaveRole(ctx context.Context, id, code, name, description s
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 {
@@ -109,7 +112,7 @@ func (r *Repository) SaveRole(ctx context.Context, id, code, name, description s
}
// DeleteRole 删除自定义角色(内置角色禁止)。
func (r *Repository) DeleteRole(ctx context.Context, id string) error {
func (r *Repository) DeleteRole(ctx context.Context, id, actorID string) error {
if r.pool == nil {
return ErrUnavailable
}
@@ -120,6 +123,7 @@ func (r *Repository) DeleteRole(ctx context.Context, id string) error {
if tag.RowsAffected() == 0 {
return ErrNotFound
}
oplog.Record(ctx, r.pool, nil, actorID, "", "role.delete", "role", id, nil)
return nil
}
+7
View File
@@ -15,6 +15,7 @@ import (
"time"
platformid "aigateway.local/core/internal/platform/id"
"aigateway.local/core/internal/platform/oplog"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
@@ -289,6 +290,11 @@ func (s *Service) Save(ctx context.Context, id, providerCode, modelPattern strin
}
var q Quota
err := s.pool.QueryRow(ctx, `SELECT id::text,provider_code,model_pattern,monthly_token_quota,enabled,updated_at FROM gateway.model_quotas WHERE id=$1`, id).Scan(&q.ID, &q.ProviderCode, &q.ModelPattern, &q.MonthlyTokenQuota, &q.Enabled, &q.UpdatedAt)
if err == nil {
oplog.Record(ctx, s.pool, s.logger, "", "", "model_quota.save", "model_quota", id, map[string]any{
"provider_code": providerCode, "model_pattern": modelPattern, "quota": quota, "enabled": enabled,
})
}
return q, err
}
@@ -304,6 +310,7 @@ func (s *Service) Delete(ctx context.Context, id string) error {
if tag.RowsAffected() == 0 {
return errors.New("配额记录不存在")
}
oplog.Record(ctx, s.pool, s.logger, "", "", "model_quota.delete", "model_quota", id, nil)
return nil
}
+24
View File
@@ -4,10 +4,12 @@ import (
"context"
"net/http"
"runtime"
"strconv"
"time"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/apiresponse"
"aigateway.local/core/internal/platform/oplog"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -28,10 +30,32 @@ func NewAdminHTTPHandler(pool *pgxpool.Pool, identityService *identity.Service,
h.mux.HandleFunc("GET /api/v1/admin/reports/tools", h.reportTools)
h.mux.HandleFunc("GET /api/v1/admin/reports/approvals", h.reportApprovals)
h.mux.HandleFunc("GET /api/v1/admin/reports/security", h.reportSecurity)
h.mux.HandleFunc("GET /api/v1/admin/op-logs", h.opLogs)
h.mux.HandleFunc("POST /api/v1/admin/reload", h.reloadSnapshots)
return h
}
// opLogs 查询管理操作审计(操作者/操作类型过滤 + 分页)。
func (h *AdminHTTPHandler) opLogs(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePermission(w, r, identity.PermissionAuditRead); !ok {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
items, total, err := oplog.List(r.Context(), h.pool, r.URL.Query().Get("actor"), r.URL.Query().Get("action"), limit, offset)
if err != nil {
apiresponse.Error(w, http.StatusServiceUnavailable, "操作审计查询失败")
return
}
apiresponse.OK(w, map[string]any{"items": items, "total": total, "limit": limit, "offset": offset})
}
func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
func (h *AdminHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) {
+109
View File
@@ -0,0 +1,109 @@
// Package oplog 提供管理操作审计的轻量同步记录器。
//
// 与 internal/audit(LLM 用量审计)不同,oplog 记录的是管理员/审批人的关键
// 写操作(账号、角色、渠道及授权、API Key、审批决定、模型配额等),用于合规
// 追溯。管理操作频率低,同步写入即可;失败只记日志,不阻塞业务操作本身。
package oplog
import (
"context"
"encoding/json"
"log/slog"
"strconv"
"time"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5/pgxpool"
)
// Record 写入一条管理操作审计。pool 为 nil 或写入失败时仅记日志,不影响调用方。
func Record(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, actorID, actorLogin, action, resourceType, resourceID string, detail map[string]any) {
if pool == nil {
return
}
if detail == nil {
detail = map[string]any{}
}
payload, err := json.Marshal(detail)
if err != nil {
payload = []byte(`{}`)
}
id, err := platformid.NewUUID()
if err != nil {
if logger != nil {
logger.Error("oplog: uuid generation failed", "error", err)
}
return
}
// actor_id 为 uuid 列:空字符串置 nil,避免 uuid 解析错误。
var actorIDValue any
if actorID != "" {
actorIDValue = actorID
}
_, err = pool.Exec(ctx, `INSERT INTO gateway.admin_op_logs(id,actor_id,actor_login,action,resource_type,resource_id,detail) VALUES($1,$2,$3,$4,$5,$6,$7)`,
id, actorIDValue, actorLogin, action, resourceType, resourceID, payload)
if err != nil && logger != nil {
logger.Error("oplog: write failed", "action", action, "resource_type", resourceType, "error", err)
}
}
// Oplog 是一条管理操作审计记录。
type Oplog struct {
ID string `json:"id"`
ActorID string `json:"actor_id,omitempty"`
ActorLogin string `json:"actor_login,omitempty"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// List 分页查询管理操作审计,可按操作者/操作类型过滤。
func List(ctx context.Context, pool *pgxpool.Pool, actorLogin, action string, limit, offset int) ([]Oplog, int, error) {
if pool == nil {
return nil, 0, nil
}
if limit < 1 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
where := ""
args := []any{}
if actorLogin != "" {
args = append(args, "%"+actorLogin+"%")
where += ` AND actor_login ILIKE $` + strconv.Itoa(len(args))
}
if action != "" {
args = append(args, action)
where += ` AND action = $` + strconv.Itoa(len(args))
}
args = append(args, limit, offset)
rows, err := pool.Query(ctx, `SELECT id::text,COALESCE(actor_id::text,''),actor_login,action,resource_type,resource_id,detail,created_at
FROM gateway.admin_op_logs WHERE 1=1`+where+` ORDER BY created_at DESC LIMIT $`+strconv.Itoa(len(args)-1)+` OFFSET $`+strconv.Itoa(len(args)), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := []Oplog{}
for rows.Next() {
var item Oplog
var detail []byte
if err := rows.Scan(&item.ID, &item.ActorID, &item.ActorLogin, &item.Action, &item.ResourceType, &item.ResourceID, &detail, &item.CreatedAt); err != nil {
return nil, 0, err
}
_ = json.Unmarshal(detail, &item.Detail)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
countArgs := args[:len(args)-2]
var total int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway.admin_op_logs WHERE 1=1`+where, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
return items, total, nil
}
+198 -3
View File
@@ -25,8 +25,9 @@ type ChatModel struct {
}
// ChatModels 返回该用户所有已批准且供应商/模型仍启用的模型。
// decided_at 兜底 updated_at:历史数据/直接落库的批准记录可能为空。
func (s *Service) ChatModels(ctx context.Context, account identity.Account) ([]ChatModel, error) {
rows, err := s.pool.Query(ctx, `SELECT DISTINCT r.provider_code,r.model,max(r.decided_at)
rows, err := s.pool.Query(ctx, `SELECT DISTINCT r.provider_code,r.model,COALESCE(max(r.decided_at),max(r.updated_at))
FROM gateway.model_access_requests r
JOIN gateway.providers p ON p.code=r.provider_code AND p.enabled
JOIN gateway.provider_models m ON m.provider_id=p.id AND m.provider_model_id=r.model AND m.enabled
@@ -52,12 +53,12 @@ func (s *Service) approvedModel(ctx context.Context, account identity.Account, p
providerCode = strings.ToLower(strings.TrimSpace(providerCode))
model = strings.TrimSpace(model)
var item ChatModel
err := s.pool.QueryRow(ctx, `SELECT r.provider_code,r.model,r.decided_at
err := s.pool.QueryRow(ctx, `SELECT r.provider_code,r.model,COALESCE(r.decided_at,r.updated_at)
FROM gateway.model_access_requests r
JOIN gateway.providers p ON p.code=r.provider_code AND p.enabled
JOIN gateway.provider_models m ON m.provider_id=p.id AND m.provider_model_id=r.model AND m.enabled
WHERE r.portal_user_id=$1 AND r.provider_code=$2 AND r.model=$3 AND r.status='approved'
ORDER BY r.decided_at DESC LIMIT 1`, account.ID, providerCode, model).Scan(&item.ProviderCode, &item.Model, &item.ApprovedAt)
ORDER BY COALESCE(r.decided_at,r.updated_at) DESC LIMIT 1`, account.ID, providerCode, model).Scan(&item.ProviderCode, &item.Model, &item.ApprovedAt)
if errors.Is(err, pgx.ErrNoRows) {
return ChatModel{}, ErrNotFound
}
@@ -292,6 +293,200 @@ func (s *Service) ChatOnce(ctx context.Context, account identity.Account, provid
return response, err
}
// chatStreamWriter 把网关响应实时转发给客户端,同时累积完整响应体:
// - 2xx 且 text/event-stream:透传模式,边写边 flush,供提取流式回答;
// - 其他(错误 JSON、或上游忽略 stream 返回普通 JSON):缓冲模式,header
// 不落盘,由调用方决定输出方式或转成统一错误。
type chatStreamWriter struct {
w http.ResponseWriter
buf bytes.Buffer
mode int // 0 未知 / 1 透传 / 2 缓冲
code int
// shown 记录是否已向客户端落 header(透传模式才落)。
shown bool
}
func (c *chatStreamWriter) Header() http.Header { return c.w.Header() }
func (c *chatStreamWriter) WriteHeader(code int) {
c.code = code
if code >= 200 && code < 300 && strings.Contains(c.w.Header().Get("Content-Type"), "text/event-stream") {
c.mode = 1
c.shown = true
c.w.WriteHeader(code)
return
}
// 非流式(错误 JSON 或普通 JSON 响应):缓冲模式,header 不落盘。
c.mode = 2
}
func (c *chatStreamWriter) Write(p []byte) (int, error) {
if c.mode == 0 {
c.WriteHeader(http.StatusOK)
}
c.buf.Write(p)
if c.mode != 1 {
return len(p), nil
}
n, err := c.w.Write(p)
c.flush()
return n, err
}
func (c *chatStreamWriter) Flush() { c.flush() }
func (c *chatStreamWriter) flush() {
if c.mode != 1 || !c.shown {
return
}
if f, ok := c.w.(http.Flusher); ok {
f.Flush()
}
}
// extractStreamAnswer 从 OpenAI 兼容 SSE 响应中拼接完整回答(delta/message 的
// content 逐段累积)。
func extractStreamAnswer(body []byte) string {
var answer strings.Builder
for _, line := range strings.Split(string(body), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
var chunk struct {
Choices []struct {
Delta struct{ Content string `json:"content"` } `json:"delta"`
Message *struct{ Content string `json:"content"` } `json:"message"`
} `json:"choices"`
}
if json.Unmarshal([]byte(payload), &chunk) != nil {
continue
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
answer.WriteString(choice.Delta.Content)
} else if choice.Message != nil {
answer.WriteString(choice.Message.Content)
}
}
}
return answer.String()
}
// extractNonStreamAnswer 从普通 JSON 响应中提取回答(上游忽略 stream 参数时)。
func extractNonStreamAnswer(body []byte) string {
var response struct {
Choices []struct {
Message *struct{ Content string `json:"content"` } `json:"message"`
} `json:"choices"`
}
if json.Unmarshal(body, &response) != nil || len(response.Choices) == 0 || response.Choices[0].Message == nil {
return ""
}
return response.Choices[0].Message.Content
}
// chatErrorFromBody 从网关错误 JSON 中提取可读错误信息。
func chatErrorFromBody(body []byte, code int) error {
var response struct {
Error struct{ Message string `json:"message"` } `json:"error"`
}
message := fmt.Sprintf("模型调用失败(HTTP %d", code)
if json.Unmarshal(body, &response) == nil && response.Error.Message != "" {
message = response.Error.Message
}
return errors.New(message)
}
// AppendChatMessageStream 在会话上追加一轮对话,网关响应以 SSE 实时透传给
// 客户端,流结束后把 user+assistant 整轮落库(失败不落库)。上游忽略 stream
// 返回普通 JSON 时自动转成 SSE 事件,前端统一走流式解析。
// 缓冲模式(未落 header)的错误直接返回,由 handler 走统一错误处理;透传模式
// 已向客户端输出 200+SSE 头,错误只能通过 SSE error 事件告知。
func (s *Service) AppendChatMessageStream(ctx context.Context, w http.ResponseWriter, account identity.Account, id, message string) error {
message = strings.TrimSpace(message)
if message == "" || len(message) > 100000 {
return errors.New("消息为空或过长")
}
lease, _ := platformid.NewUUID()
tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET busy=true,busy_token=$3,busy_since=clock_timestamp() WHERE id=$1 AND portal_user_id=$2 AND status='active' AND (NOT busy OR busy_since<clock_timestamp()-interval '10 minutes')`, id, account.ID, lease)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("会话不存在、已归档或上一条消息仍在处理")
}
defer func() {
_, _ = s.pool.Exec(context.WithoutCancel(ctx), `UPDATE gateway.portal_chat_sessions SET busy=false,busy_token=NULL,busy_since=NULL WHERE id=$1 AND busy_token=$2`, id, lease)
}()
var providerCode, model string
if err = s.pool.QueryRow(ctx, `SELECT provider_code,model FROM gateway.portal_chat_sessions WHERE id=$1`, id).Scan(&providerCode, &model); err != nil {
return err
}
conversation, err := s.ChatSession(ctx, account, id)
if err != nil {
return err
}
secret, err := s.ensureChatCredential(ctx, account)
if err != nil {
return err
}
history := make([]ConversationMessage, len(conversation.Messages)+1)
copy(history, conversation.Messages)
history[len(conversation.Messages)] = ConversationMessage{Role: "user", Content: message}
payloadMessages := make([]map[string]any, 0, len(history))
for _, m := range history {
payloadMessages = append(payloadMessages, map[string]any{"role": m.Role, "content": m.Content})
}
payload, _ := json.Marshal(map[string]any{"model": model, "messages": payloadMessages, "stream": true})
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(payload)).WithContext(gateway.WithRequestID(ctx, "portal-chat-"+time.Now().UTC().Format("20060102150405.000000000")))
request.Header.Set("Authorization", "Bearer "+secret)
request.Header.Set("Content-Type", "application/json")
stream := &chatStreamWriter{w: w}
s.gateway.ServeHTTP(stream, request)
writeDone := func(extra map[string]any) {
event, _ := json.Marshal(extra)
_, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", event)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
if stream.mode != 1 {
// 缓冲模式:header 未落盘,错误走统一处理,成功转成 SSE 事件输出。
if stream.code >= 300 {
return chatErrorFromBody(stream.buf.Bytes(), stream.code)
}
answer := extractNonStreamAnswer(stream.buf.Bytes())
if strings.TrimSpace(answer) == "" {
return errors.New("模型未返回文本回答")
}
if _, err = s.appendChatMessages(context.WithoutCancel(ctx), id, []ConversationMessage{{Role: "user", Content: message}, {Role: "assistant", Content: answer}}); err != nil {
return err
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
writeDone(map[string]any{"choices": []map[string]any{{"delta": map[string]any{"content": answer}}}})
return nil
}
// 透传模式:已向客户端输出 200 + SSE 头,错误只能通过事件告知。
answer := extractStreamAnswer(stream.buf.Bytes())
if strings.TrimSpace(answer) == "" {
writeDone(map[string]any{"error": map[string]string{"message": "模型未返回文本回答"}})
return nil
}
if _, err = s.appendChatMessages(context.WithoutCancel(ctx), id, []ConversationMessage{{Role: "user", Content: message}, {Role: "assistant", Content: answer}}); err != nil {
writeDone(map[string]any{"error": map[string]string{"message": err.Error()}})
return nil
}
writeDone(map[string]any{"done": true, "conversation_id": id})
return nil
}
// AppendChatMessage 在会话上追加一轮对话:busy 租约防并发交错,消息在模型
// 调用成功后才落库,失败重试不会产生孤儿消息或重复消息。
func (s *Service) AppendChatMessage(ctx context.Context, account identity.Account, id, message string) (map[string]any, error) {
+146
View File
@@ -0,0 +1,146 @@
// portal 聊天会话集成测试:依赖真实 PostgreSQL(WORKBENCH_TEST_DATABASE_URL)。
// 覆盖会话 CRUD、消息哈希链完整性、200 条上限、busy 租约与 10 分钟超时回收。
package portal
import (
"context"
"os"
"testing"
"aigateway.local/core/internal/identity"
"aigateway.local/core/internal/platform/config"
"aigateway.local/core/internal/platform/database"
)
const (
chatActorID = "66666666-6666-4666-8666-666666666666"
chatUserID = "77777777-7777-4777-8777-777777777777"
)
func newChatTestService(t *testing.T) (*Service, identity.Account, 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() })
service := NewService(pool, nil, nil, nil)
account := identity.Account{ID: chatUserID, Kind: identity.KindPortal, Login: "chat-test-user"}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM gateway.portal_chat_messages WHERE session_id IN (SELECT id FROM gateway.portal_chat_sessions WHERE portal_user_id=$1)`, chatUserID)
_, _ = pool.Exec(ctx, `DELETE FROM gateway.portal_chat_sessions WHERE portal_user_id=$1`, chatUserID)
_, _ = pool.Exec(ctx, `DELETE FROM gateway.portal_users WHERE id=$1`, chatUserID)
}
cleanup()
_, err = pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,name,role,permissions,password_hash,auth_source,active) VALUES($1,'chat-test-user','聊天测试用户','member','{}','test','local',true)`, chatUserID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(cleanup)
return service, account, ctx
}
func TestChatSessionLifecycle(t *testing.T) {
s, account, ctx := newChatTestService(t)
// 1. 创建会话(模型批准校验未配置 assets,直接走 ensureChatCredential 会失败,
// 这里只验证会话 CRUD 与消息链,不触发模型调用)。
session, err := s.CreateChatSession(ctx, account, "test-provider", "test-model")
if err == nil {
// CreateChatSession 需要 approvedModel;无 assets 时 NewService 传 nil
// 不应走到,这里跳过。
_ = session
}
// 2. 直接插入一条消息验证哈希链完整性。
var id string
if err := s.pool.QueryRow(ctx, `INSERT INTO gateway.portal_chat_sessions(id,portal_user_id,provider_code,model,status,next_sequence) VALUES(gen_random_uuid(),$1,'test-provider','test-model','active',1) RETURNING id::text`, chatUserID).Scan(&id); err != nil {
t.Fatal(err)
}
defer s.pool.Exec(ctx, `DELETE FROM gateway.portal_chat_messages WHERE session_id=$1`, id)
defer s.pool.Exec(ctx, `DELETE FROM gateway.portal_chat_sessions WHERE id=$1`, id)
// 3. appendChatMessages 一轮 user+assistant。
appended, err := s.appendChatMessages(ctx, id, []ConversationMessage{{Role: "user", Content: "你好"}, {Role: "assistant", Content: "你好呀"}})
if err != nil {
t.Fatal(err)
}
if len(appended) != 2 || appended[0].Role != "user" || appended[1].Role != "assistant" {
t.Fatalf("appendChatMessages 返回 = %+v", appended)
}
// 4. ChatSession 哈希链校验通过。
detail, err := s.ChatSession(ctx, account, id)
if err != nil {
t.Fatal(err)
}
if len(detail.Messages) != 2 || detail.Messages[1].Content != "你好呀" {
t.Fatalf("会话消息 = %+v", detail.Messages)
}
// 5. 篡改消息后完整性校验失败。
if _, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_messages SET content='篡改' WHERE session_id=$1 AND sequence=1`, id); err != nil {
t.Fatal(err)
}
if _, err := s.ChatSession(ctx, account, id); err == nil {
t.Fatal("篡改后完整性校验应失败")
}
}
func TestChatSessionLimitsAndLease(t *testing.T) {
s, account, ctx := newChatTestService(t)
var id string
if err := s.pool.QueryRow(ctx, `INSERT INTO gateway.portal_chat_sessions(id,portal_user_id,provider_code,model,status,next_sequence) VALUES(gen_random_uuid(),$1,'test-provider','test-model','active',1) RETURNING id::text`, chatUserID).Scan(&id); err != nil {
t.Fatal(err)
}
defer s.pool.Exec(ctx, `DELETE FROM gateway.portal_chat_messages WHERE session_id=$1`, id)
defer s.pool.Exec(ctx, `DELETE FROM gateway.portal_chat_sessions WHERE id=$1`, id)
// 1. 200 条上限:直接撑到 next_sequence=200 再追加应被拒绝。
if _, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET next_sequence=200 WHERE id=$1`, id); err != nil {
t.Fatal(err)
}
if _, err := s.appendChatMessages(ctx, id, []ConversationMessage{{Role: "user", Content: "x"}, {Role: "assistant", Content: "y"}}); err == nil {
t.Fatal("超过 200 条上限应报错")
}
// 2. busy 租约:占用后第二条 AppendChatMessage 立即被拒;10 分钟后可回收。
lease := "11111111-1111-4111-8111-111111111111"
tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET busy=true,busy_token=$2,busy_since=clock_timestamp() WHERE id=$1`, id, lease)
if err != nil || tag.RowsAffected() != 1 {
t.Fatalf("busy 租约占用失败 err=%v", err)
}
// AppendChatMessage 需要模型批准与凭据,这里只验证租约拒绝路径(模型未批准
// 前就应被 busy 拒绝,顺序:先租约后模型,所以能观测到 busy 拒绝)。
_, err = s.AppendChatMessage(ctx, account, id, "hello")
if err == nil || err.Error() != "会话不存在、已归档或上一条消息仍在处理" {
t.Fatalf("busy 会话追加应被租约拒绝, got err=%v", err)
}
// 3. 租约过期后可回收(busy_since 改为 11 分钟前)。
if _, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET busy_since=clock_timestamp()-interval '11 minutes' WHERE id=$1`, id); err != nil {
t.Fatal(err)
}
tag, err = s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET busy=true,busy_token=$2,busy_since=clock_timestamp() WHERE id=$1 AND (NOT busy OR busy_since<clock_timestamp()-interval '10 minutes')`, id, "22222222-2222-4222-8222-222222222222")
if err != nil || tag.RowsAffected() != 1 {
t.Fatalf("过期租约应可回收, rows=%d err=%v", tag.RowsAffected(), err)
}
// 4. 会话列表仅返回 active 且按更新时间倒序。
sessions, err := s.ListChatSessions(ctx, account, 10)
if err != nil {
t.Fatal(err)
}
if len(sessions) == 0 || sessions[0].Status != "active" {
t.Fatalf("会话列表 = %+v", sessions)
}
// 5. 删除会话(归档)后列表不含它。
if err := s.DeleteChatSession(ctx, account, id); err != nil {
t.Fatal(err)
}
after, err := s.ListChatSessions(ctx, account, 10)
if err != nil {
t.Fatal(err)
}
for _, item := range after {
if item.ID == id {
t.Fatal("归档会话不应出现在列表中")
}
}
}
+142
View File
@@ -0,0 +1,142 @@
package portal
import (
"net/http"
"testing"
)
func TestExtractStreamAnswer(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{
name: "delta 拼接",
body: "data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\ndata: [DONE]\n\n",
want: "你好",
},
{
name: "message 整段(非流式兼容)",
body: "data: {\"choices\":[{\"message\":{\"content\":\"完整回答\"}}]}\n\n",
want: "完整回答",
},
{
name: "空 delta 与空行忽略",
body: "data: {\"choices\":[{\"delta\":{\"content\":\"A\"}},{\"delta\":{\"content\":\"B\"}}]}\n\n\n\ndata: [DONE]\n\n",
want: "AB",
},
{
name: "非 data 行与注释忽略",
body: ": keep-alive\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"X\"}}]}\n\n",
want: "X",
},
{
name: "空响应",
body: "data: [DONE]\n\n",
want: "",
},
{
name: "非法 JSON 行忽略",
body: "data: not-json\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"Y\"}}]}\n\n",
want: "Y",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractStreamAnswer([]byte(tc.body)); got != tc.want {
t.Fatalf("extractStreamAnswer(%q) = %q, want %q", tc.body, got, tc.want)
}
})
}
}
func TestExtractNonStreamAnswer(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{name: "标准 OpenAI 响应", body: `{"choices":[{"message":{"role":"assistant","content":"回答"}}]}`, want: "回答"},
{name: "多 choices 取首个", body: `{"choices":[{"message":{"content":"第一"}},{"message":{"content":"第二"}}]}`, want: "第一"},
{name: "空 choices", body: `{"choices":[]}`, want: ""},
{name: "非法 JSON", body: `oops`, want: ""},
{name: "无 message", body: `{"choices":[{"delta":{"content":"x"}}]}`, want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractNonStreamAnswer([]byte(tc.body)); got != tc.want {
t.Fatalf("extractNonStreamAnswer(%q) = %q, want %q", tc.body, got, tc.want)
}
})
}
}
func TestChatErrorFromBody(t *testing.T) {
if got := chatErrorFromBody([]byte(`{"error":{"message":"rate limit"}}`), 429); got.Error() != "rate limit" {
t.Fatalf("chatErrorFromBody error message = %q, want %q", got.Error(), "rate limit")
}
if got := chatErrorFromBody([]byte(`not-json`), 502); got.Error() != "模型调用失败(HTTP 502" {
t.Fatalf("chatErrorFromBody fallback = %q", got.Error())
}
if got := chatErrorFromBody(nil, 400); got.Error() != "模型调用失败(HTTP 400" {
t.Fatalf("chatErrorFromBody nil body = %q", got.Error())
}
}
func TestChatStreamWriterModes(t *testing.T) {
// 缓冲模式:非 SSE Content-Type 不落 header,只累积。
head := &recorderStub{header: map[string][]string{}}
buffered := chatStreamWriter{w: head}
buffered.Header().Set("Content-Type", "application/json")
buffered.WriteHeader(400)
if buffered.shown {
t.Fatal("非 2xx 不应落 header")
}
if buffered.mode != 2 {
t.Fatalf("mode = %d, want 2 (缓冲)", buffered.mode)
}
if _, err := buffered.Write([]byte(`{"error":"x"}`)); err != nil {
t.Fatal(err)
}
if buffered.buf.String() != `{"error":"x"}` {
t.Fatalf("缓冲内容 = %q", buffered.buf.String())
}
if head.code != 0 {
t.Fatalf("缓冲模式不应透传 WriteHeader, got code=%d", head.code)
}
// 透传模式:2xx + text/event-stream 落 header 并标记透传。
recorder := &recorderStub{header: map[string][]string{}}
streamed := chatStreamWriter{w: recorder}
streamed.Header().Set("Content-Type", "text/event-stream")
streamed.WriteHeader(200)
if !streamed.shown || streamed.mode != 1 {
t.Fatalf("SSE 应透传: shown=%v mode=%d", streamed.shown, streamed.mode)
}
if recorder.code != 200 {
t.Fatalf("recorder code = %d", recorder.code)
}
if _, err := streamed.Write([]byte("data: x\n\n")); err != nil {
t.Fatal(err)
}
if string(recorder.body) != "data: x\n\n" {
t.Fatalf("透传内容 = %q", recorder.body)
}
}
// recorderStub 最小 http.ResponseWriter 替身(不含 Flusher,验证写路径)。
type recorderStub struct {
header http.Header
code int
body []byte
}
func (r *recorderStub) Header() http.Header {
if r.header == nil {
r.header = map[string][]string{}
}
return r.header
}
func (r *recorderStub) WriteHeader(code int) { r.code = code }
func (r *recorderStub) Write(p []byte) (int, error) { r.body = append(r.body, p...); return len(p), nil }
+21
View File
@@ -57,6 +57,7 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
h.mux.HandleFunc("DELETE /api/v1/portal/chat/sessions/{id}", h.deleteChatSession)
h.mux.HandleFunc("GET /api/v1/portal/chat/sessions/{id}", h.getChatSession)
h.mux.HandleFunc("POST /api/v1/portal/chat/sessions/{id}/messages", h.appendChatMessage)
h.mux.HandleFunc("POST /api/v1/portal/chat/sessions/{id}/messages/stream", h.appendChatMessageStream)
// 个人渠道:webhook 入站(公开,令牌鉴权) + 个人管理。
h.mux.HandleFunc("POST /v1/personal-channels/{code}/inbound", h.personalChannelInbound)
h.mux.HandleFunc("GET /api/v1/portal/personal-channels", h.personalChannels)
@@ -793,6 +794,26 @@ func (h *HTTPHandler) appendChatMessage(w http.ResponseWriter, r *http.Request)
writeApplicationResponse(w, response)
}
// appendChatMessageStream SSE 流式追加:网关的 text/event-stream 实时透传,
// 流结束后整轮落库;缓冲模式(非流式响应/错误)下 error 走统一错误处理。
func (h *HTTPHandler) appendChatMessageStream(w http.ResponseWriter, r *http.Request) {
a, ok := h.account(w, r)
if !ok {
return
}
var input struct {
Message string `json:"message"`
}
if !decode(w, r, &input) {
return
}
w.Header().Set("X-Accel-Buffering", "no")
if err := h.service.AppendChatMessageStream(r.Context(), w, a, r.PathValue("id"), input.Message); err != nil {
portalError(w, err)
return
}
}
// --- 个人渠道 ---
func (h *HTTPHandler) personalChannels(w http.ResponseWriter, r *http.Request) {
+4
View File
@@ -9,6 +9,7 @@ import (
"aigateway.local/core/internal/identity"
platformid "aigateway.local/core/internal/platform/id"
"aigateway.local/core/internal/platform/oplog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
@@ -224,5 +225,8 @@ func (s *Service) DecideResourceRequest(ctx context.Context, id, status, note, a
if err = tx.Commit(ctx); err != nil {
return ResourceRequest{}, err
}
oplog.Record(ctx, s.pool, nil, actorID, "", "approval.decide", resourceType, id, map[string]any{
"resource_code": resourceCode, "portal_user_id": userID, "status": status, "note": strings.TrimSpace(note),
})
return scanResourceRequest(s.pool.QueryRow(ctx, resourceRequestSelect+` WHERE r.id=$1`, id))
}
+19 -2
View File
@@ -14,6 +14,7 @@ import (
"syscall"
"time"
"aigateway.local/core/internal/platform/oplog"
"aigateway.local/core/internal/platform/cryptox"
"aigateway.local/core/internal/provider"
"github.com/jackc/pgx/v5"
@@ -169,6 +170,14 @@ func (s *ToolService) Save(ctx context.Context, id string, input ToolInput, acto
if err = tx.Commit(ctx); err != nil {
return Tool{}, err
}
action := "tool.update"
if create {
action = "tool.create"
}
oplog.Record(ctx, s.assets.pool, nil, actorID, "", action, "tool", id, map[string]any{
"code": input.Code, "name": input.Name, "enabled": input.Enabled,
"rate_limit_rpm": input.RateLimitRPM, "approval_required": input.ApprovalRequired,
})
return s.Get(ctx, id)
}
@@ -203,7 +212,11 @@ func (s *ToolService) Delete(ctx context.Context, id, actorID string) error {
if err = emit(ctx, tx, "tool.deleted", "tool", id, actorID, nil); err != nil {
return err
}
return tx.Commit(ctx)
if err = tx.Commit(ctx); err != nil {
return err
}
oplog.Record(ctx, s.assets.pool, nil, actorID, "", "tool.delete", "tool", id, nil)
return nil
}
func (s *ToolService) headers(tool Tool) (map[string]string, error) {
@@ -383,7 +396,11 @@ func (s *ToolService) DecideApprovalRequest(ctx context.Context, id, status, not
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'tool_approval.decided',1,'tool',$2,$3)`, eventID, toolID, payload); err != nil {
return err
}
return tx.Commit(ctx)
if err = tx.Commit(ctx); err != nil {
return err
}
oplog.Record(ctx, s.assets.pool, nil, actorID, "", "approval.tool_decide", "tool", toolID, map[string]any{"request_id": id, "status": status})
return nil
}
func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]any, apiKeyID, requestID string) (result map[string]any, err error) {
+21
View File
@@ -0,0 +1,21 @@
-- 管理操作审计:记录管理员/审批人的关键写操作(账号、角色、渠道及授权、
-- API Key、审批决定、模型配额等),满足合规追溯需求。操作频率低,同步写入;
-- 由审计员(permission: audit:read)查询。
CREATE TABLE IF NOT EXISTS gateway.admin_op_logs (
id uuid PRIMARY KEY,
actor_id uuid,
actor_login text NOT NULL DEFAULT '',
action text NOT NULL,
resource_type text NOT NULL,
resource_id text NOT NULL DEFAULT '',
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
ip inet,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS admin_op_logs_created_idx
ON gateway.admin_op_logs (created_at DESC);
CREATE INDEX IF NOT EXISTS admin_op_logs_actor_idx
ON gateway.admin_op_logs (actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS admin_op_logs_resource_idx
ON gateway.admin_op_logs (resource_type, resource_id, created_at DESC);
+4 -3
View File
@@ -109,9 +109,10 @@ function resource(kind: IdentityKind) {
return kind === 'admin' ? 'admins' : 'portal-users'
}
export function fetchIdentities(kind: IdentityKind) {
return request.get<IdentityRecord[]>({
url: `/api/v1/admin/identities/${resource(kind)}`
export function fetchIdentities(kind: IdentityKind, params?: { q?: string; limit?: number; offset?: number }) {
return request.get<{ items: IdentityRecord[]; total: number; limit: number; offset: number }>({
url: `/api/v1/admin/identities/${resource(kind)}`,
params
})
}
@@ -101,8 +101,18 @@
<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
v-model="grantUserID"
filterable
remote
:remote-method="searchGrantUsers"
:loading="searchingUsers"
:reserve-keyword="false"
clearable
class="flex-1"
placeholder="输入账号/名称搜索用户"
>
<ElOption v-for="user in grantUserOptions" :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>
@@ -149,7 +159,8 @@
const dialogVisible = ref(false)
const editingId = ref('')
const departments = ref<Department[]>([])
const portalUsers = ref<PortalUser[]>([])
const grantUserOptions = ref<PortalUser[]>([])
const searchingUsers = ref(false)
const grantsVisible = ref(false)
const grantsChannel = ref<Channel>()
const grants = ref<ChannelGrant[]>([])
@@ -165,19 +176,28 @@
async function load() {
loading.value = true
try {
const [channelList, departmentList, userList] = await Promise.all([
const [channelList, departmentList] = 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' })
request.get<Department[]>({ url: '/api/v1/admin/departments' })
])
channels.value = channelList
departments.value = departmentList
portalUsers.value = userList
} finally {
loading.value = false
}
}
// 授权用户改为远程搜索:企业用户量大,不再一次性全量拉取。
async function searchGrantUsers(query: string) {
searchingUsers.value = true
try {
const result = await request.get<{ items: PortalUser[] }>({ url: '/api/v1/admin/identities/portal-users', params: { q: query.trim(), limit: 20 } })
grantUserOptions.value = result.items
} catch { /* 全局错误提示 */ } finally {
searchingUsers.value = false
}
}
function openCreate() {
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: '', department_ids: [], enabled: true })
@@ -0,0 +1,115 @@
<template>
<div class="page-content">
<div class="mb-5">
<h2 class="text-xl font-semibold">操作审计</h2>
<p class="text-g-500 mt-1 text-sm">管理员与审批人的关键写操作留痕账号角色渠道及授权API Key审批决定模型配额等</p>
</div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<ElInput v-model="filter.actor" clearable placeholder="操作者账号" class="!w-48" @keyup.enter="load(true)" @clear="load(true)" />
<ElSelect v-model="filter.action" clearable placeholder="操作类型" class="!w-52" @change="load(true)">
<ElOption v-for="option in actionOptions" :key="option.value" :label="option.label" :value="option.value" />
</ElSelect>
<ElButton type="primary" @click="load(true)">查询</ElButton>
</div>
<ElTable v-loading="loading" :data="items" row-key="id">
<ElTableColumn label="时间" width="180"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></ElTableColumn>
<ElTableColumn label="操作者" min-width="140"><template #default="{ row }">{{ row.actor_login || row.actor_id || '系统' }}</template></ElTableColumn>
<ElTableColumn label="操作" min-width="160">
<template #default="{ row }"><ElTag size="small">{{ actionLabel(row.action) }}</ElTag></template>
</ElTableColumn>
<ElTableColumn label="资源" min-width="200">
<template #default="{ row }">{{ row.resource_type }}<span v-if="row.resource_id" class="text-g-400"> · {{ row.resource_id.slice(0, 8) }}</span></template>
</ElTableColumn>
<ElTableColumn label="详情" min-width="280">
<template #default="{ row }">
<div class="text-g-600 font-mono text-xs">{{ detailText(row.detail) }}</div>
</template>
</ElTableColumn>
</ElTable>
<div class="mt-4 flex justify-end">
<ElPagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100, 200]"
layout="total, sizes, prev, pager, next"
@current-change="load(false)"
@size-change="load(true)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import request from '@/utils/http'
interface OplogRecord {
id: string
actor_id?: string
actor_login?: string
action: string
resource_type: string
resource_id?: string
detail?: Record<string, any>
created_at: string
}
const actionOptions = [
{ value: 'identity.create', label: '创建账号' },
{ value: 'identity.update', label: '更新账号' },
{ value: 'role.create', label: '创建角色' },
{ value: 'role.update', label: '更新角色' },
{ value: 'role.delete', label: '删除角色' },
{ value: 'channel.save', label: '渠道创建/更新' },
{ value: 'channel.delete', label: '删除渠道' },
{ value: 'channel.grant', label: '渠道授权' },
{ value: 'channel.revoke_grant', label: '撤销渠道授权' },
{ value: 'api_key.create', label: '创建 API Key' },
{ value: 'api_key.revoke', label: '吊销 API Key' },
{ value: 'api_key.limits_update', label: '修改 Key 配额' },
{ value: 'tool.create', label: '创建工具' },
{ value: 'tool.update', label: '更新工具' },
{ value: 'tool.delete', label: '删除工具' },
{ value: 'approval.decide', label: '审批资源申请' },
{ value: 'approval.tool_decide', label: '审批工具申请' },
{ value: 'model_quota.save', label: '模型配额修改' },
{ value: 'model_quota.delete', label: '删除模型配额' }
]
const actionLabel = (action: string) => actionOptions.find((option) => option.value === action)?.label || action
const formatTime = (value: string) => new Date(value).toLocaleString()
const detailText = (detail?: Record<string, any>) => {
if (!detail || !Object.keys(detail).length) return '—'
try {
return JSON.stringify(detail)
} catch {
return '—'
}
}
const items = ref<OplogRecord[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const filter = reactive<{ actor: string; action: string }>({ actor: '', action: '' })
async function load(reset: boolean) {
if (reset) page.value = 1
loading.value = true
try {
const result = await request.get<{ items: OplogRecord[]; total: number }>({
url: '/api/v1/admin/op-logs',
params: { actor: filter.actor || undefined, action: filter.action || undefined, limit: pageSize.value, offset: (page.value - 1) * pageSize.value }
})
items.value = result.items
total.value = result.total
} catch { /* 全局错误提示 */ } finally {
loading.value = false
}
}
onMounted(() => load(true))
</script>
+44 -1
View File
@@ -26,6 +26,18 @@
<ElTabPane label="扫码登录" name="social" />
</ElTabs>
<div v-if="activeTab === 'admin' || activeTab === 'portal'" class="mb-3 flex items-center gap-2">
<ElInput
v-model="userQuery"
class="w-72"
clearable
placeholder="按账号/名称搜索"
@keyup.enter="searchUsers"
@clear="searchUsers"
/>
<ElButton @click="searchUsers">搜索</ElButton>
</div>
<ElTable v-if="activeTab === 'admin' || activeTab === 'portal'" v-loading="loading" :data="records" row-key="id">
<ElTableColumn prop="login" label="账号" min-width="170" />
<ElTableColumn prop="display_name" label="显示名称" min-width="150">
@@ -66,6 +78,18 @@
</ElTableColumn>
</ElTable>
<div v-if="activeTab === 'admin' || activeTab === 'portal'" class="mt-4 flex justify-end">
<ElPagination
v-model:current-page="userPage"
v-model:page-size="userPageSize"
:total="userTotal"
:page-sizes="[20, 50, 100, 200]"
layout="total, sizes, prev, pager, next"
@current-change="load"
@size-change="searchUsers"
/>
</div>
<ElTable v-else-if="activeTab === 'department'" v-loading="loading" :data="departments" row-key="id">
<ElTableColumn prop="code" label="代码" min-width="150" />
<ElTableColumn prop="name" label="名称" min-width="170" />
@@ -391,6 +415,11 @@
const samlEditingId = ref('')
const socialDialogVisible = ref(false)
const socialEditingKind = ref('')
// 账号列表分页:企业用户量级可能很大,后端强制分页(q/limit/offset)。
const userQuery = ref('')
const userPage = ref(1)
const userPageSize = ref(50)
const userTotal = ref(0)
const formRef = ref<FormInstance>()
const departmentFormRef = ref<FormInstance>()
const form = reactive<IdentityInput>({
@@ -445,7 +474,16 @@
loading.value = true
try {
departments.value = await fetchDepartments()
if (activeTab.value === 'admin' || activeTab.value === 'portal') records.value = await fetchIdentities(activeTab.value)
if (activeTab.value === 'admin' || activeTab.value === 'portal') {
// 切 tab 时回到第一页再拉取。
if (_pane !== undefined) {
userPage.value = 1
userQuery.value = ''
}
const page = await fetchIdentities(activeTab.value, { q: userQuery.value || undefined, limit: userPageSize.value, offset: (userPage.value - 1) * userPageSize.value })
records.value = page.items
userTotal.value = page.total
}
if (activeTab.value === 'oidc') identityProviders.value = await fetchIdentityProviders()
if (activeTab.value === 'saml') samlProviders.value = await fetchSAMLProviders()
if (activeTab.value === 'social') socialProviders.value = await fetchSocialProviders()
@@ -454,6 +492,11 @@
}
}
async function searchUsers() {
userPage.value = 1
await load()
}
function resetForm() {
Object.assign(form, {
login: '',
+54
View File
@@ -79,6 +79,60 @@ export const deleteChatSession=(id:string)=>request.del({url:`/api/v1/portal/cha
export const fetchChatSession=(id:string)=>request.get<ChatSession>({url:`/api/v1/portal/chat/sessions/${id}`})
export const appendChatMessage=(id:string,message:string)=>request.post<Record<string,unknown>>({url:`/api/v1/portal/chat/sessions/${id}/messages`,params:{message}})
// streamAppendChatMessage SSE 流式追加聊天消息:通过原生 fetch 逐块读取
// text/event-stream,onDelta 每收到一段增量内容回调一次。error 事件或非
// SSE 错误响应会抛错;AbortSignal 用于停止生成。
export async function streamAppendChatMessage(
id: string,
message: string,
onDelta: (text: string) => void,
signal?: AbortSignal
): Promise<void> {
const { accessToken } = useUserStore()
const baseURL = (import.meta.env.VITE_API_URL as string) || ''
const token = accessToken.startsWith('Bearer ') ? accessToken : `Bearer ${accessToken}`
const response = await fetch(`${baseURL}/api/v1/portal/chat/sessions/${id}/messages/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: token },
body: JSON.stringify({ message }),
signal
})
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('text/event-stream')) {
// 缓冲模式的错误/普通 JSON 响应:尝试解析统一错误文案。
let messageText = `模型调用失败(HTTP ${response.status}`
try {
const data = await response.json()
messageText = (data as { msg?: string; error?: { message?: string } }).msg || (data as { error?: { message?: string } }).error?.message || messageText
} catch { /* 保留默认文案 */ }
throw new Error(messageText)
}
if (!response.body) throw new Error('浏览器不支持流式响应')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const payload = trimmed.slice(5).trim()
if (!payload || payload === '[DONE]') continue
let chunk: Record<string, any>
try {
chunk = JSON.parse(payload)
} catch { continue }
if (chunk.error) throw new Error(chunk.error.message || '模型调用失败')
const delta = chunk.choices?.[0]?.delta?.content || chunk.choices?.[0]?.message?.content || ''
if (delta) onDelta(delta)
}
}
}
// --- 账号安全 ---
export interface SessionView { id:string;ip:string;user_agent:string;issued_at:number;current:boolean }
export interface ProviderBinding { kind:'wecom'|'dingtalk'|'feishu';provider_uid:string;created_at:string }
+36 -11
View File
@@ -52,7 +52,7 @@
<div
class="max-w-[80%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user' ? 'bg-primary-600 text-white' : 'bg-g-100 text-g-800'"
>{{ message.content }}</div>
>{{ message.content || (message.role === 'assistant' && sending ? '思考中…' : '') }}</div>
</div>
</div>
<div class="border-t border-g-100 p-3">
@@ -66,8 +66,11 @@
@keydown.enter.exact.prevent="send"
/>
<div class="mt-2 flex items-center justify-between">
<span class="text-g-400 text-xs">{{ sending ? '模型思考中…' : `${draft.length} / 100000` }}</span>
<ElButton type="primary" :loading="sending" :disabled="!draft.trim()" @click="send">发送</ElButton>
<span class="text-g-400 text-xs">{{ sending ? '模型生成中…' : `${draft.length} / 100000` }}</span>
<div class="flex items-center gap-2">
<ElButton v-if="sending" @click="stopGeneration">停止</ElButton>
<ElButton type="primary" :loading="sending" :disabled="!draft.trim() || sending" @click="send">发送</ElButton>
</div>
</div>
</div>
</div>
@@ -78,8 +81,8 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ChatMessage, ChatModel, ChatSession,
appendChatMessage, createChatSession, deleteChatSession, fetchChatModels,
fetchChatSession, fetchChatSessions, renameChatSession
createChatSession, deleteChatSession, fetchChatModels,
fetchChatSession, fetchChatSessions, renameChatSession, streamAppendChatMessage
} from '@/api/portal'
const loading = ref(false)
@@ -91,6 +94,7 @@ const messages = ref<ChatMessage[]>([])
const draft = ref('')
const selectedModel = ref('')
const scrollRef = ref<HTMLElement>()
const stopSignal = ref<AbortController | null>(null)
const modelGroups = computed(() => {
const groups: { provider: string; models: ChatModel[] }[] = []
@@ -171,26 +175,47 @@ async function send() {
const id = currentId.value
messages.value.push({ sequence: messages.value.length + 1, role: 'user', content: text, created_at: '' })
draft.value = ''
// 预置空 assistant 气泡,增量内容实时填入;未收到任何内容时回退"未返回文本"。
const placeholderIndex = messages.value.length
messages.value.push({ sequence: placeholderIndex + 1, role: 'assistant', content: '', created_at: '' })
scrollToBottom()
stopSignal.value = new AbortController()
let received = false
try {
// 响应到达时校验会话未切换:发送中切模型/切会话时丢弃迟到响应,防止
// 串入新会话。
const response = await appendChatMessage(id, text)
// 串入新会话;停止生成通过 AbortController 中断 fetch
await streamAppendChatMessage(id, text, (delta) => {
if (currentId.value !== id) return
received = true
const placeholder = messages.value[placeholderIndex]
if (placeholder) placeholder.content += delta
scrollToBottom()
}, stopSignal.value.signal)
if (currentId.value !== id) return
const choices = (response.choices as Array<{ message?: { content?: string } }>) || []
const answer = choices[0]?.message?.content || ''
messages.value.push({ sequence: messages.value.length + 1, role: 'assistant', content: answer, created_at: '' })
const placeholder = messages.value[placeholderIndex]
if (placeholder && !placeholder.content.trim()) placeholder.content = '(模型未返回文本回答)'
const session = sessions.value.find((item) => item.id === id)
if (session && !session.title) session.title = text.slice(0, 60)
if (!received) ElMessage.warning('模型未返回文本回答')
} catch (error) {
if (currentId.value !== id) return
const message = (error as Error)?.message || '调用失败'
ElMessage.error(message)
if ((error as Error)?.name === 'AbortError') {
ElMessage.info('已停止生成')
} else {
ElMessage.error(message)
}
} finally {
sending.value = false
stopSignal.value = null
scrollToBottom()
}
}
function stopGeneration() {
stopSignal.value?.abort()
}
async function rename(session: ChatSession) {
const { value } = await ElMessageBox.prompt('输入新的会话标题', '重命名会话', { inputValue: session.title || session.model, inputValidator: (v: string) => (v.trim() ? true : '标题不能为空') })
if (value) {