From d2903a1662f8c7a8aadb362b2f7fbed98450eeb9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 05:33:38 +0000 Subject: [PATCH 1/2] fix(server): enforce principal identity and docgov project access (H1-H3) - H1: log legacy test-principal fallback (path/method/remote_addr) via slog.Warn so silent unauthenticated access is observable - H2: AddComment/Claim now derive actor ID/Name/Role from the auth principal and server-side doc duty resolution (resolveDocGovRole), never from request body; GetDocument/ListComments/ListAuditLogs now require project membership via requireDocGovDocAccess - H3: handleGetChatTurn asserts session ownership; handleListChatSessions and handleGetUserContext reject cross-user user_id (403) and stop defaulting to user id=1 Co-authored-by: Cursor --- internal/server/auth_helpers.go | 63 ++++++++++++++++++++++++++++++ internal/server/auth_middleware.go | 4 ++ internal/server/chat_turn_api.go | 9 +++++ internal/server/handlers.go | 55 ++++++++++++++++++++++++-- 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/internal/server/auth_helpers.go b/internal/server/auth_helpers.go index a1090ee3..407f2133 100644 --- a/internal/server/auth_helpers.go +++ b/internal/server/auth_helpers.go @@ -8,6 +8,7 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" ) // failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。 @@ -135,3 +136,65 @@ func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request, pr } return user, true } + +// requireDocGovDocAccess 校验当前用户可访问指定受控文档(经 doc_id 解析 project_id 后做项目成员校验)。 +func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, docID string) (*auth.UserAccount, bool) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return nil, false + } + if s.gov == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{ + "error_code": "ERR_DOCGOV_UNAVAILABLE", + "message": "docgov engine not initialized", + }) + return nil, false + } + if s.isSystemAdminUser(r.Context(), user) { + return user, true + } + doc, err := s.gov.GetDocument(r.Context(), docID) + if err != nil || doc == nil { + writeJSONResponse(w, http.StatusNotFound, map[string]string{ + "error_code": "ERR_DOCUMENT_NOT_FOUND", + "message": "文档不存在或无权限访问", + }) + return nil, false + } + if doc.ProjectID <= 0 { + // 无项目归属的文档仅管理员可见 + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该文档", + }) + return nil, false + } + if err := s.authorizer.RequireProjectAccess(r.Context(), doc.ProjectID, user.ID); err != nil { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该文档所属项目", + }) + return nil, false + } + return user, true +} + +// resolveDocGovRole 服务端按文档职责分配解析 docgov 角色,不信任客户端自报角色。 +func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) docgov.UserRole { + if user == nil || s.gov == nil { + return docgov.UserRole("") + } + doc, err := s.gov.GetDocument(ctx, docID) + if err != nil || doc == nil { + return docgov.UserRole("") + } + switch user.ID { + case doc.AuthorID: + return docgov.RoleAuthor + case doc.ReviewerID: + return docgov.RoleReviewer + case doc.ApproverID: + return docgov.RoleApprover + } + return docgov.UserRole("") +} diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index 98c5c0b6..08dc9d82 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -1,6 +1,7 @@ package server import ( + "log/slog" "net/http" "strings" ) @@ -48,6 +49,9 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { user, err := s.authenticateUser(r) if (err != nil || user == nil) && s.allowLegacyTestPrincipal() { if fallback, fbErr := s.authSvc.GetUserByID(r.Context(), 1); fbErr == nil && fallback != nil { + slog.Warn("legacy_test_principal_fallback", + "path", r.URL.Path, "method", r.Method, + "remote_addr", r.RemoteAddr, "fallback_user_id", fallback.ID) user = fallback } } diff --git a/internal/server/chat_turn_api.go b/internal/server/chat_turn_api.go index cb8898e7..17392c79 100644 --- a/internal/server/chat_turn_api.go +++ b/internal/server/chat_turn_api.go @@ -10,6 +10,10 @@ import ( // handleGetChatTurn 查询回合状态(断线续接) func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } clientTurnID := r.PathValue("client_turn_id") sessionID := r.URL.Query().Get("session_id") if clientTurnID == "" || sessionID == "" { @@ -23,6 +27,11 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) { return } + // 归属校验:只允许查询本人会话的回合状态 + if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) { + return + } + rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID) if errors.Is(err, ctxmgr.ErrChatTurnNotFound) { writeSafeError(w, r, http.StatusNotFound, "turn not found") diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 51f123c3..692e50d1 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -427,14 +427,26 @@ func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy s // ----------------------------------------------------------------------------- // handleGetUserContext 获取单用户私有上下文与个性化偏好 +// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。 func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } userIDStr := r.URL.Query().Get("user_id") projectIDStr := r.URL.Query().Get("project_id") sessionID := r.URL.Query().Get("session_id") userID, _ := strconv.ParseInt(userIDStr, 10, 64) if userID <= 0 { - userID = 1 + userID = user.ID + } + if userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权查询其他用户的上下文", + }) + return } projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) @@ -544,6 +556,10 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) return } + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + doc, err := s.gov.GetDocument(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error()) @@ -583,7 +599,7 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { return } - doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name) + doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error()) return @@ -868,6 +884,10 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request return } + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + comments, err := s.gov.ListComments(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error()) @@ -889,6 +909,7 @@ type AddCommentRequestDTO struct { } // handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议 +// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。 func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) { var req AddCommentRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -901,14 +922,19 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) return } + user, ok := s.requireDocGovDocAccess(w, r, req.DocID) + if !ok { + return + } + cType := docgov.CommentType(req.CommentType) if cType == "" { cType = docgov.CommentGeneral } comment, err := s.gov.AddComment( - r.Context(), req.DocID, req.UserID, req.UserName, - docgov.UserRole(req.UserRole), req.TargetSection, cType, req.Content, + r.Context(), req.DocID, user.ID, user.Name, + s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content, ) if err != nil { writeDocGovError(w, err) @@ -931,6 +957,10 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques return } + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + logs, err := s.gov.ListAuditLogs(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error()) @@ -945,14 +975,31 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques // ----------------------------------------------------------------------------- // handleListChatSessions 分页查询用户/项目关联的历史会话列表 +// user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。 func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) { if s.ctxMgr == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + userIDStr := r.URL.Query().Get("user_id") userID, _ := strconv.ParseInt(userIDStr, 10, 64) + if userID > 0 && userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权查询其他用户的会话列表", + }) + return + } + // 未显式传 user_id 时默认查本人,而不是任意/默认用户 + if userID <= 0 { + userID = user.ID + } projectIDStr := r.URL.Query().Get("project_id") projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) From 2dd8f46e641f8c545ed9e088106e12c82083b373 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 06:00:30 +0000 Subject: [PATCH 2/2] fix(server): login/register rate limiting + compose secret hygiene (H4/H8/M8) - H4: sliding-window rate limiter (10 attempts / 5min per IP+username) on login and register; successful login resets the counter; pure stdlib, no new dependency - H8: docker-compose.test.yml DB/ADMIN passwords now :?required (align with main); docker-compose.main.yml AUTH_REGISTRATION_ENABLED defaults to false - M8: fix FAV_PUBLISHER_ENABLE -> FAV_PUBLISHER_ENABLED typo so the gate test actually guards the real flag Co-authored-by: Cursor --- docker-compose.main.yml | 2 +- docker-compose.test.yml | 8 +- internal/artifactvault/publisher_gate_test.go | 2 +- internal/server/auth_handlers.go | 27 ++++++- internal/server/login_rate_limit.go | 80 +++++++++++++++++++ internal/server/login_rate_limit_test.go | 46 +++++++++++ internal/server/server.go | 3 + 7 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 internal/server/login_rate_limit.go create mode 100644 internal/server/login_rate_limit_test.go diff --git a/docker-compose.main.yml b/docker-compose.main.yml index d1ebb18a..f21fa10c 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -95,7 +95,7 @@ services: KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production} - AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true} + AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-false} AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false} EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3} diff --git a/docker-compose.test.yml b/docker-compose.test.yml index af9112c7..5538a08b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -14,7 +14,7 @@ services: command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"] environment: POSTGRES_USER: ${SYSTEM_DB_USER:-app} - POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:-test_secret_db_pass} + POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:?required} POSTGRES_DB: ${SYSTEM_DB_NAME:-agent_system_test} ports: - "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432" @@ -82,7 +82,7 @@ services: MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8} DATA_DIR: /app/data # 独占测试业务与对话库 - APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable + APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable # 共享知识库 POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable PI_AGENT_RUNNER_URL: http://pi-agent:18866 @@ -97,7 +97,7 @@ services: RERANKER_MODEL: ${RERANKER_MODEL:-bge-reranker-v2-m3-free} RERANKER_API_KEY: ${RERANKER_API_KEY:-} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin-secret-password-123} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required} AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true} AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} @@ -152,7 +152,7 @@ services: HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}" EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}" DATA_DIR: /app/data - APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable + APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable ports: - "127.0.0.1:${EVENTS_HTTP_PORT:-8089}:${EVENTS_HTTP_PORT:-8089}" healthcheck: diff --git a/internal/artifactvault/publisher_gate_test.go b/internal/artifactvault/publisher_gate_test.go index b8c5f473..0ec7d095 100644 --- a/internal/artifactvault/publisher_gate_test.go +++ b/internal/artifactvault/publisher_gate_test.go @@ -13,7 +13,7 @@ func TestFAVDegradedReadDefaultOff(t *testing.T) { } func TestPublisherEnableDefaultOff(t *testing.T) { - if os.Getenv("FAV_PUBLISHER_ENABLE") == "1" { + if os.Getenv("FAV_PUBLISHER_ENABLED") == "1" { t.Fatal("test env must not force publisher on") } } diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 6e0f12df..c4fd8db1 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -36,7 +36,7 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) { return nil, errors.New("missing auth token") } -// handleAuthLogin 用户名密码登录 +// handleAuthLogin 用户名密码登录(限流:连续爆破触发 429) func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` @@ -47,6 +47,17 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil { + key := s.loginLimiter.key(r, req.Username) + if !s.loginLimiter.allow(key) { + writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{ + "error_code": "ERR_LOGIN_RATE_LIMITED", + "message": "登录尝试过于频繁,请稍后再试", + }) + return + } + } + session, err := s.authSvc.Login(r.Context(), req.Username, req.Password) if err != nil { errMsg := err.Error() @@ -62,11 +73,15 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil { + s.loginLimiter.reset(s.loginLimiter.key(r, req.Username)) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(session) } -// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客) +// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客,限流防批量注册) func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { var req auth.RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -74,6 +89,14 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil && !s.loginLimiter.allow(s.loginLimiter.key(r, req.Username)) { + writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{ + "error_code": "ERR_REGISTER_RATE_LIMITED", + "message": "注册尝试过于频繁,请稍后再试", + }) + return + } + resp, err := s.authSvc.Register(r.Context(), req) if err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) diff --git a/internal/server/login_rate_limit.go b/internal/server/login_rate_limit.go new file mode 100644 index 00000000..fcd66983 --- /dev/null +++ b/internal/server/login_rate_limit.go @@ -0,0 +1,80 @@ +package server + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +// loginRateLimiter 基于 IP+用户名的滑动窗口登录限流器(纯 stdlib,防爆破/撞库)。 +// 窗口内超过 maxAttempts 次尝试即拒绝,窗口过期后自动重置。 +type loginRateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + maxAttempts int + window time.Duration +} + +func newLoginRateLimiter(maxAttempts int, window time.Duration) *loginRateLimiter { + if maxAttempts <= 0 { + maxAttempts = 10 + } + if window <= 0 { + window = 5 * time.Minute + } + return &loginRateLimiter{ + attempts: make(map[string][]time.Time), + maxAttempts: maxAttempts, + window: window, + } +} + +// allow 判断 key 本次尝试是否放行;放行则记录一次尝试。 +func (l *loginRateLimiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + cutoff := now.Add(-l.window) + + // 滑出窗口的尝试丢弃 + ts := l.attempts[key] + kept := ts[:0] + for _, t := range ts { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= l.maxAttempts { + l.attempts[key] = kept + return false + } + l.attempts[key] = append(kept, now) + return true +} + +// reset 认证成功后清空该 key 的计数(避免正常用户被自己的历史失败累积误伤)。 +func (l *loginRateLimiter) reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, key) +} + +// clientIP 取客户端 IP(优先 X-Forwarded-For 首跳,回落 RemoteAddr)。 +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if first, _, _ := strings.Cut(xff, ","); strings.TrimSpace(first) != "" { + return strings.TrimSpace(first) + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func (l *loginRateLimiter) key(r *http.Request, username string) string { + return clientIP(r) + "|" + strings.ToLower(strings.TrimSpace(username)) +} diff --git a/internal/server/login_rate_limit_test.go b/internal/server/login_rate_limit_test.go new file mode 100644 index 00000000..a0cbbebe --- /dev/null +++ b/internal/server/login_rate_limit_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "testing" + "time" +) + +func TestLoginRateLimiter_BlocksAfterMaxAttempts(t *testing.T) { + l := newLoginRateLimiter(3, time.Minute) + key := "1.2.3.4|alice" + for i := 0; i < 3; i++ { + if !l.allow(key) { + t.Fatalf("attempt %d should be allowed", i+1) + } + } + if l.allow(key) { + t.Fatalf("4th attempt within window should be blocked") + } +} + +func TestLoginRateLimiter_ResetClearsWindow(t *testing.T) { + l := newLoginRateLimiter(2, time.Minute) + key := "1.2.3.4|bob" + l.allow(key) + l.allow(key) + if l.allow(key) { + t.Fatalf("should be blocked before reset") + } + l.reset(key) + if !l.allow(key) { + t.Fatalf("should be allowed after reset") + } +} + +func TestLoginRateLimiter_WindowExpiry(t *testing.T) { + l := newLoginRateLimiter(1, 10*time.Millisecond) + key := "1.2.3.4|carol" + l.allow(key) + if l.allow(key) { + t.Fatalf("should be blocked within window") + } + time.Sleep(20 * time.Millisecond) + if !l.allow(key) { + t.Fatalf("should be allowed after window expiry") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c98f2976..08ad0661 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,6 +79,7 @@ type Server struct { vaultPublisherStarted int32 vaultStop chan struct{} reminderStop chan struct{} + loginLimiter *loginRateLimiter } // NewFromRuntime attaches the HTTP adapter to an already-wired composition root. @@ -114,6 +115,8 @@ func NewFromRuntime(rt *app.Runtime) *Server { kb: rt.KB, sysadmin: rt.SysAdmin, intentPlans: rt.IntentPlans, + // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 + loginLimiter: newLoginRateLimiter(10, 5*time.Minute), } s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz()) s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)