package server import ( "context" "fmt" "strings" "unicode" "chat-agent-system/internal/auth" "chat-agent-system/internal/chat" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/workflowscope" ) // bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。 // 读取前先应用与文档列表/正文 API 相同的阶段权限过滤。 func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth.UserAccount) { if sess == nil || sess.UserCtx == nil { return } sess.UserCtx.DocumentSummaryReader = nil projectID := sess.Project() userID := int64(0) if user != nil { userID = user.ID } if projectID <= 0 || userID <= 0 || s.gov == nil { return } sess.UserCtx.DocumentSummaryReader = func(ctx context.Context, target string) (*coordinator.ReadableDocument, error) { if s.authorizer == nil { return nil, fmt.Errorf("project authorization unavailable") } if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil { return nil, err } listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID) if err != nil { return nil, err } docs := s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, listed) // 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。 // 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。 workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" { workflowKey = sess.UserCtx.Project.ActiveWorkflowKey } if workflowscope.CurrentMode() == workflowscope.ModeWeighted && len(scopeTaskKeys) > 0 { // 权重模式把被阶段过滤掉的他流台账加回候选,胜出后再做查看权限校验。 docs = append(append([]docgov.WorkflowDocumentSummary{}, docs...), filterDocsOutsideTaskKeys(listed, scopeTaskKeys)...) } doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys) if err != nil { return nil, err } owner := intent.TaskKeyWorkflowOwner(doc.TaskKey) cross := workflowKey != "" && owner != "" && owner != workflowKey && (len(scopeTaskKeys) == 0 || !projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey)) if cross && !s.crossWorkflowViewAllowed(ctx, projectID, userID, doc.TaskKey) { title := doc.Title if title == "" { title = doc.TaskKey } return nil, &coordinator.ErrDocViewDenied{ Message: fmt.Sprintf("您无权查看【%s】的《%s》。", intent.WorkflowDisplayName(owner), title), } } content, err := s.loadControlledDocumentContent(ctx, doc.DocID) if err != nil { return nil, err } return &coordinator.ReadableDocument{ DocID: doc.DocID, Title: doc.Title, TaskKey: doc.TaskKey, LifecycleState: string(doc.LifecycleState), Version: doc.Version, Markdown: content.Markdown, IsTemplateOnly: doc.IsTemplateOnly || content.IsTemplateOnly, WorkflowKey: owner, ActiveWorkflow: workflowKey, CrossWorkflow: cross, }, nil } } // activeWorkflowScopeForDocs 读取项目激活工作流键与任务集合;失败时返回空(= 不过滤)。 func (s *Server) activeWorkflowScopeForDocs(ctx context.Context, projectID int64) (string, map[string]bool) { if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 { return "", nil } workflowKey, taskKeys, err := s.instSvc.ActiveWorkflowScope(ctx, projectID) if err != nil { return "", nil } return workflowKey, taskKeys } // taskKeyOutsideActiveWorkflow 判断任务键是否落在激活工作流之外。 // 未激活 / 任务键未知归属时放行(存量兼容);跨流时返回引导话术。 func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int64, taskKey string) (bool, string) { taskKey = strings.TrimSpace(taskKey) if taskKey == "" || projectID <= 0 { return false, "" } workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) if workflowKey == "" { return false, "" } if len(scopeTaskKeys) > 0 { if projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) { return false, "" } owner := intent.TaskKeyWorkflowOwner(taskKey) if workflowscope.BlocksWrite(workflowKey, owner, taskKey) { return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) } return false, "" } owner := intent.TaskKeyWorkflowOwner(taskKey) if workflowscope.BlocksWrite(workflowKey, owner, taskKey) { return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) } return false, "" } // workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。 func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string]bool) string { if activeKey != "" && len(scopeTaskKeys) > 0 && projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) { return activeKey } if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" { return owner } return activeKey } // errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。 type errDocWrongWorkflow struct { Title string TaskKey string OwnerWorkflow string ActiveWorkflow string } func (e *errDocWrongWorkflow) Error() string { if e == nil { return "document belongs to another workflow" } owners := []string{} if e.OwnerWorkflow != "" { owners = []string{e.OwnerWorkflow} } doc := e.Title if doc == "" { doc = e.TaskKey } return intent.WorkflowScopeGuidanceQuestion(doc, owners, e.ActiveWorkflow) } func (e *errDocWrongWorkflow) AsCoordinatorErr() error { return &coordinator.ErrWrongWorkflow{Message: e.Error()} } // selectReadableWorkflowDocument 在可读文档池中解析目标。 // scopeTaskKeys 非空时先在激活流池内匹配;未命中但精确/归一化匹配到被剔除的他流文档时返回 ErrWrongWorkflow。 // scopeTaskKeys 为空(未激活 / planning)时保持现状:在全量池内匹配。 func selectReadableWorkflowDocument( docs []docgov.WorkflowDocumentSummary, target string, activeWorkflow string, scopeTaskKeys map[string]bool, ) (*docgov.WorkflowDocumentSummary, error) { if workflowscope.CurrentMode() == workflowscope.ModeWeighted && activeWorkflow != "" && len(scopeTaskKeys) > 0 && strings.TrimSpace(target) != "" { return selectReadableWeighted(docs, target, activeWorkflow, scopeTaskKeys) } return selectReadableStrict(docs, target, activeWorkflow, scopeTaskKeys) } func selectReadableWeighted( docs []docgov.WorkflowDocumentSummary, target string, activeWorkflow string, scopeTaskKeys map[string]bool, ) (*docgov.WorkflowDocumentSummary, error) { candidates := make([]workflowscope.Candidate, 0, len(docs)) byID := make(map[string]docgov.WorkflowDocumentSummary, len(docs)) for _, doc := range docs { quality := workflowscope.BestQuality(target, doc.DocID, doc.TaskKey, doc.Title) if quality <= 0 { continue } owner := "" if projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey) { owner = activeWorkflow } else if inferred := intent.TaskKeyWorkflowOwner(doc.TaskKey); inferred != "" { owner = inferred } id := doc.DocID if id == "" { id = doc.TaskKey } candidates = append(candidates, workflowscope.Candidate{ ID: id, Label: doc.Title, Owner: owner, Quality: quality, }) byID[id] = doc } decision := workflowscope.Decide(candidates, activeWorkflow, workflowscope.ActionRead) switch decision.Kind { case workflowscope.KindInScope, workflowscope.KindCrossAllow: doc := byID[decision.Winner.ID] return &doc, nil case workflowscope.KindCrossBlock: doc := byID[decision.Winner.ID] owner := decision.Winner.Owner if owner == "" { owner = intent.TaskKeyWorkflowOwner(doc.TaskKey) } return nil, (&errDocWrongWorkflow{ Title: doc.Title, TaskKey: doc.TaskKey, OwnerWorkflow: owner, ActiveWorkflow: activeWorkflow, }).AsCoordinatorErr() default: return nil, fmt.Errorf("document not found in readable scope") } } func selectReadableStrict( docs []docgov.WorkflowDocumentSummary, target string, activeWorkflow string, scopeTaskKeys map[string]bool, ) (*docgov.WorkflowDocumentSummary, error) { if len(docs) == 0 { return nil, fmt.Errorf("no readable workflow documents") } targetKey := normalizeDocumentLookupKey(target) if targetKey == "" { inScope := filterDocsByTaskKeys(docs, scopeTaskKeys) pool := inScope if len(scopeTaskKeys) == 0 { pool = docs } if len(pool) == 1 { return &pool[0], nil } return nil, fmt.Errorf("document target required") } inScope := filterDocsByTaskKeys(docs, scopeTaskKeys) pool := inScope if len(scopeTaskKeys) == 0 { pool = docs } if hit := matchDocumentInPool(pool, target, targetKey); hit != nil { return hit, nil } // 激活流池未命中:检查是否精确/归一化命中被剔除的他流文档。 if len(scopeTaskKeys) > 0 { outOfScope := filterDocsOutsideTaskKeys(docs, scopeTaskKeys) if hit := matchDocumentInPool(outOfScope, target, targetKey); hit != nil { owner := intent.TaskKeyWorkflowOwner(hit.TaskKey) return nil, (&errDocWrongWorkflow{ Title: hit.Title, TaskKey: hit.TaskKey, OwnerWorkflow: owner, ActiveWorkflow: activeWorkflow, }).AsCoordinatorErr() } } return nil, fmt.Errorf("document not found in readable scope") } func filterDocsByTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary { if len(taskKeys) == 0 { return docs } out := make([]docgov.WorkflowDocumentSummary, 0, len(docs)) for _, d := range docs { if projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) { out = append(out, d) } } return out } func filterDocsOutsideTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary { if len(taskKeys) == 0 { return nil } out := make([]docgov.WorkflowDocumentSummary, 0) for _, d := range docs { if !projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) { out = append(out, d) } } return out } func matchDocumentInPool(docs []docgov.WorkflowDocumentSummary, target, targetKey string) *docgov.WorkflowDocumentSummary { for i := range docs { if target == docs[i].DocID || target == docs[i].TaskKey || target == docs[i].Title { return &docs[i] } } for i := range docs { keys := []string{ normalizeDocumentLookupKey(docs[i].DocID), normalizeDocumentLookupKey(docs[i].TaskKey), normalizeDocumentLookupKey(docs[i].Title), } for _, key := range keys { if key != "" && (key == targetKey || strings.Contains(key, targetKey) || strings.Contains(targetKey, key)) { return &docs[i] } } } return nil } func normalizeDocumentLookupKey(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.NewReplacer("收集", "", "采集", "", "正文", "", "文档", "", "《", "", "》", "").Replace(value) return strings.Map(func(r rune) rune { if unicode.IsSpace(r) || unicode.IsPunct(r) { return -1 } return r }, value) }