merge: candidate member-visibility-dialogue-ctx-2026-09-28 into dev
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -94,11 +95,44 @@ func (s *Session) LastAccessed() time.Time {
|
||||
return s.LastAccessedAt
|
||||
}
|
||||
|
||||
// SetProjectID 设置当前会话绑定的项目 ID。
|
||||
// SetProjectID 设置当前会话绑定的项目 ID,并同步协调器读取的 UserCtx.Project.ID。
|
||||
// 协调器派发任务只解析字符串项目 ID;只改会话字段会让对话注入和阶段门禁看到 0。
|
||||
func (s *Session) SetProjectID(pid int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ProjectID = pid
|
||||
s.syncProjectIDLocked()
|
||||
}
|
||||
|
||||
// bindCoordinatorIdentity 在每轮派发前用已授权的会话身份覆盖协调器上下文。
|
||||
// 会话 ID 供工作区追溯;数字用户 ID 供阶段门禁。阶段键故意不收窄对话采集,
|
||||
// 个人会话与项目成员共享对话都进入任务上下文,只滤掉私密与噪声。
|
||||
func (s *Session) bindCoordinatorIdentity() {
|
||||
if s == nil || s.UserCtx == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.syncProjectIDLocked()
|
||||
if s.UserID > 0 && parseUserID(s.UserCtx.User.ID) <= 0 {
|
||||
s.UserCtx.User.ID = strconv.FormatInt(s.UserID, 10)
|
||||
}
|
||||
if s.ID != "" {
|
||||
s.UserCtx.ChatSessionID = s.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) syncProjectIDLocked() {
|
||||
if s.UserCtx == nil {
|
||||
return
|
||||
}
|
||||
if s.ProjectID > 0 {
|
||||
s.UserCtx.Project.ID = strconv.FormatInt(s.ProjectID, 10)
|
||||
return
|
||||
}
|
||||
if _, err := strconv.ParseInt(strings.TrimSpace(s.UserCtx.Project.ID), 10, 64); err == nil {
|
||||
s.UserCtx.Project.ID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Project 读取当前会话绑定的项目 ID。
|
||||
@@ -465,9 +499,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40)
|
||||
}
|
||||
s.EnsureDocResolver()
|
||||
if s.UserCtx != nil {
|
||||
s.UserCtx.ChatSessionID = s.ID
|
||||
}
|
||||
s.bindCoordinatorIdentity()
|
||||
coordRes, err := s.Coordinator.ProcessIntent(ctx, recRes, s.UserCtx)
|
||||
if err != nil {
|
||||
if streamer != nil {
|
||||
@@ -506,6 +538,8 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
s.UserCtx = coordRes.UpdatedContext
|
||||
}
|
||||
|
||||
s.augmentWorkflowGuidance(ctx, userInput, coordRes)
|
||||
|
||||
if coordRes.ClearExpectation {
|
||||
_ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
const stageAdvanceGuidance = "该文档所属阶段尚未激活。请先完成当前阶段并推进后再编制。"
|
||||
|
||||
const researchFirstHint = "当前还没有完成调研报告。调研是可行性评估的前置输入。是否先进行调研?"
|
||||
|
||||
func stageAccessDenied(reply string) bool {
|
||||
return strings.Contains(reply, "workflow stage access denied") || strings.Contains(reply, "stage access denied")
|
||||
}
|
||||
|
||||
// workflowPrerequisiteHint 在用户要写可行性评估、而调研仍是模板或尚未建账时,建议先调研。
|
||||
// docs 为 nil 表示台账未知,不猜测。
|
||||
func workflowPrerequisiteHint(userInput string, docs []docgov.WorkflowDocumentSummary, known bool) (string, string) {
|
||||
if !known || !strings.Contains(userInput, "可行性") {
|
||||
return "", ""
|
||||
}
|
||||
ready, key := researchMaterialReady(docs)
|
||||
if ready {
|
||||
return "", ""
|
||||
}
|
||||
if key == "" {
|
||||
key = "bd_research"
|
||||
}
|
||||
return researchFirstHint, key
|
||||
}
|
||||
|
||||
func isResearchTaskKey(key string) bool {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "research", "bd_research":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func researchMaterialReady(docs []docgov.WorkflowDocumentSummary) (ready bool, taskKey string) {
|
||||
for _, doc := range docs {
|
||||
if !isResearchTaskKey(doc.TaskKey) {
|
||||
continue
|
||||
}
|
||||
taskKey = doc.TaskKey
|
||||
if !doc.IsTemplateOnly {
|
||||
return true, taskKey
|
||||
}
|
||||
}
|
||||
return false, taskKey
|
||||
}
|
||||
|
||||
func researchFirstExpectation(taskKey string) *intent.PendingExpectation {
|
||||
if taskKey == "" {
|
||||
taskKey = "bd_research"
|
||||
}
|
||||
opt := intent.ExpectationOption{
|
||||
OptionID: "1",
|
||||
Title: "先进行调研",
|
||||
Description: "先生成调研报告,再编写可行性评估",
|
||||
Intent: intent.IntentTriggerTask,
|
||||
Slots: map[string]string{intent.SlotTargetTask: taskKey},
|
||||
}
|
||||
talk := intent.ExpectationOption{
|
||||
OptionID: "2",
|
||||
Title: "先补充信息,暂不调研",
|
||||
Intent: intent.IntentCasualChat,
|
||||
}
|
||||
return &intent.PendingExpectation{
|
||||
ExpectationID: fmt.Sprintf("research-first-%d", time.Now().UnixNano()),
|
||||
ExpectedIntent: intent.IntentTriggerTask,
|
||||
TargetAction: "confirm_trigger_task",
|
||||
Prompt: researchFirstHint,
|
||||
AffirmativeWord: "好",
|
||||
DeclineWord: "先不",
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
ContextSnippet: "可行性评估的前置调研尚未完成",
|
||||
DefaultOption: &opt,
|
||||
Options: []intent.ExpectationOption{opt, talk},
|
||||
AllowAffirmative: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) listWorkflowDocs(ctx context.Context) ([]docgov.WorkflowDocumentSummary, bool) {
|
||||
if s == nil || s.Actions == nil || s.Actions.DocGov == nil || s.Project() <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
docs, err := s.Actions.DocGov.ListWorkflowDocumentsByProject(ctx, s.Project())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return docs, true
|
||||
}
|
||||
|
||||
func (s *Session) augmentWorkflowGuidance(ctx context.Context, userInput string, res *coordinator.CoordinationResult) {
|
||||
if s == nil || res == nil {
|
||||
return
|
||||
}
|
||||
if stageAccessDenied(res.NaturalReply) && !strings.Contains(res.NaturalReply, stageAdvanceGuidance) {
|
||||
res.NaturalReply = strings.TrimSpace(res.NaturalReply) + "\n\n" + stageAdvanceGuidance
|
||||
}
|
||||
docs, known := s.listWorkflowDocs(ctx)
|
||||
hint, taskKey := workflowPrerequisiteHint(userInput, docs, known)
|
||||
if hint == "" {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(res.NaturalReply, "是否先进行调研") {
|
||||
res.NaturalReply = strings.TrimSpace(res.NaturalReply) + "\n\n" + hint
|
||||
}
|
||||
if res.ClearExpectation || res.NextExpectation != nil {
|
||||
return
|
||||
}
|
||||
exp := researchFirstExpectation(taskKey)
|
||||
res.NextExpectation = exp
|
||||
if res.UpdatedContext != nil && res.UpdatedContext.PendingExpectation == nil {
|
||||
res.UpdatedContext.PendingExpectation = exp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
func TestSetProjectIDSyncsCoordinatorProject(t *testing.T) {
|
||||
sess := NewSession("sess-p6022-u74", &coordinator.UserProjectContext{}, nil, nil)
|
||||
sess.SetProjectID(6022)
|
||||
if sess.Project() != 6022 {
|
||||
t.Fatalf("session project=%d", sess.Project())
|
||||
}
|
||||
if sess.UserCtx.Project.ID != "6022" {
|
||||
t.Fatalf("coordinator project=%q", sess.UserCtx.Project.ID)
|
||||
}
|
||||
sess.SetProjectID(0)
|
||||
if sess.UserCtx.Project.ID != "" {
|
||||
t.Fatalf("cleared project=%q", sess.UserCtx.Project.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindCoordinatorIdentityUsesNumericUser(t *testing.T) {
|
||||
sess := NewSession("sess-p6022-u74", &coordinator.UserProjectContext{
|
||||
User: coordinator.UserInfo{ID: "u_sess-p6022-u74"},
|
||||
}, nil, nil)
|
||||
sess.UserID = 74
|
||||
sess.SetProjectID(6022)
|
||||
sess.bindCoordinatorIdentity()
|
||||
if sess.UserCtx.User.ID != "74" {
|
||||
t.Fatalf("user=%q", sess.UserCtx.User.ID)
|
||||
}
|
||||
if sess.UserCtx.ChatSessionID != "sess-p6022-u74" {
|
||||
t.Fatalf("session=%q", sess.UserCtx.ChatSessionID)
|
||||
}
|
||||
if sess.UserCtx.Project.ID != "6022" {
|
||||
t.Fatalf("project=%q", sess.UserCtx.Project.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowPrerequisiteHintSuggestsResearch(t *testing.T) {
|
||||
docs := []docgov.WorkflowDocumentSummary{
|
||||
{TaskKey: "bd_research", IsTemplateOnly: true, Title: "调研报告"},
|
||||
{TaskKey: "bd_feasibility_dev", IsTemplateOnly: true},
|
||||
}
|
||||
hint, key := workflowPrerequisiteHint("开始生成设计开发可行性评估报告", docs, true)
|
||||
if hint == "" || key != "bd_research" {
|
||||
t.Fatalf("hint=%q key=%q", hint, key)
|
||||
}
|
||||
ready, _ := researchMaterialReady([]docgov.WorkflowDocumentSummary{
|
||||
{TaskKey: "bd_research", IsTemplateOnly: false},
|
||||
})
|
||||
if !ready {
|
||||
t.Fatal("finished research should be ready")
|
||||
}
|
||||
if h, _ := workflowPrerequisiteHint("今天天气怎么样", docs, true); h != "" {
|
||||
t.Fatalf("unrelated hint=%q", h)
|
||||
}
|
||||
if h, _ := workflowPrerequisiteHint("开始生成可行性评估", nil, false); h != "" {
|
||||
t.Fatalf("unknown ledger hint=%q", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAugmentWorkflowGuidanceSetsExpectationAndStageNote(t *testing.T) {
|
||||
sess := NewSession("sess-1", &coordinator.UserProjectContext{}, nil, nil)
|
||||
res := &coordinator.CoordinationResult{
|
||||
NaturalReply: "任务执行失败: bizdev: workflow stage access denied: task \"bd_feasibility_dev\"",
|
||||
UpdatedContext: sess.UserCtx,
|
||||
}
|
||||
sess.augmentWorkflowGuidance(context.Background(), "开始生成设计开发可行性评估报告", res)
|
||||
if !strings.Contains(res.NaturalReply, stageAdvanceGuidance) {
|
||||
t.Fatalf("reply=%q", res.NaturalReply)
|
||||
}
|
||||
exp := researchFirstExpectation("bd_research")
|
||||
if exp.DefaultOption == nil || exp.DefaultOption.Slots[intent.SlotTargetTask] != "bd_research" {
|
||||
t.Fatalf("expectation=%+v", exp)
|
||||
}
|
||||
if !exp.AllowAffirmative {
|
||||
t.Fatal("affirmative should start research")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ func TestPrepareWorkspaceWritesDialogueContext(t *testing.T) {
|
||||
TurnsMD: "# turns\n\n[2026-09-24 15:00] 张工(engineer/member) 用户: 器械类型:眼科OCT\n",
|
||||
FactsJSON: []byte(`[{"key":"device_type","value":"眼科OCT"}]`),
|
||||
ConfirmationsMD: "# 裁决\n\n无冲突\n",
|
||||
TurnCount: 2,
|
||||
ExcludedPrivate: 1,
|
||||
ExcludedNoAccess: 0,
|
||||
ExcludedNoise: 3,
|
||||
},
|
||||
WorkflowSteps: []squad.StepSpec{{
|
||||
ID: "s1", Title: "整理", Role: "organizer", Mode: "write",
|
||||
@@ -45,8 +49,63 @@ func TestPrepareWorkspaceWritesDialogueContext(t *testing.T) {
|
||||
if !strings.Contains(string(readme), "collab_dialogue") {
|
||||
t.Fatalf("README missing collab_dialogue: %s", readme)
|
||||
}
|
||||
dlgReadme, _ := os.ReadFile(filepath.Join(dir, "context", "dialogue", "README.md"))
|
||||
if !strings.Contains(string(dlgReadme), "采集统计:保留 2 轮;滤除私密 1、阶段不可见 0、噪声 3") {
|
||||
t.Fatalf("dialogue README missing collection stats: %s", dlgReadme)
|
||||
}
|
||||
taskMD, _ := os.ReadFile(filepath.Join(dir, "tasks", "01_organizer", "TASK.md"))
|
||||
if !strings.Contains(string(taskMD), "context/dialogue/") {
|
||||
t.Fatalf("TASK.md missing dialogue nav: %s", taskMD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareWorkspaceExplainsMissingDialogue(t *testing.T) {
|
||||
t.Setenv("DIALOGUE_CTX_INJECTION", "")
|
||||
unbound := prepareDialogueWorkspace(t, squad.WorkspacePrepareRequest{})
|
||||
readme, err := os.ReadFile(filepath.Join(unbound, "context", "dialogue", "README.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(readme), "任务未绑定项目") {
|
||||
t.Fatalf("unbound readme=%s", readme)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(unbound, "context", "dialogue", "turns.md")); err == nil {
|
||||
t.Fatal("unbound project must not pretend turns were filtered")
|
||||
}
|
||||
|
||||
bound := prepareDialogueWorkspace(t, squad.WorkspacePrepareRequest{ProjectID: 6022})
|
||||
readme, err = os.ReadFile(filepath.Join(bound, "context", "dialogue", "README.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(readme), "没有对话包") {
|
||||
t.Fatalf("bound readme=%s", readme)
|
||||
}
|
||||
|
||||
t.Setenv("DIALOGUE_CTX_INJECTION", "off")
|
||||
disabled := prepareDialogueWorkspace(t, squad.WorkspacePrepareRequest{ProjectID: 6022})
|
||||
readme, err = os.ReadFile(filepath.Join(disabled, "context", "dialogue", "README.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(readme), "开关已关闭") {
|
||||
t.Fatalf("disabled readme=%s", readme)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareDialogueWorkspace(t *testing.T, req squad.WorkspacePrepareRequest) string {
|
||||
t.Helper()
|
||||
req.WorkDir = t.TempDir()
|
||||
req.TaskKey = "research"
|
||||
req.StageTitle = "调研报告"
|
||||
req.ProjectName = "测试项目"
|
||||
req.CustomerName = "客户"
|
||||
req.WorkflowSteps = []squad.StepSpec{{
|
||||
ID: "s1", Title: "整理", Role: "organizer", Mode: "write",
|
||||
SubWorkspace: "tasks/01_organizer", Status: "pending",
|
||||
}}
|
||||
if _, err := squad.NewWorkspaceEngine().PrepareWorkspace(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req.WorkDir
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ type DialoguePackage struct {
|
||||
TurnsMD string
|
||||
FactsJSON []byte
|
||||
ConfirmationsMD string
|
||||
TurnCount int
|
||||
ExcludedPrivate int
|
||||
ExcludedNoAccess int
|
||||
ExcludedNoise int
|
||||
}
|
||||
|
||||
// Workspace 表示已初始化的工作区句柄
|
||||
|
||||
@@ -182,7 +182,7 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar
|
||||
})
|
||||
|
||||
// 8. dialogue/ — project member dialogue (digest + turns + facts)
|
||||
if err := e.writeDialogueContext(ctxDir, req.DialoguePackage, &contextItems); err != nil {
|
||||
if err := e.writeDialogueContext(ctxDir, req.ProjectID, req.DialoguePackage, &contextItems); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -210,13 +210,13 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar
|
||||
return os.WriteFile(filepath.Join(ctxDir, "README.md"), []byte(readmeContent), 0o644)
|
||||
}
|
||||
|
||||
func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, pkg *DialoguePackage, items *[]ContextItem) error {
|
||||
func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, projectID int64, pkg *DialoguePackage, items *[]ContextItem) error {
|
||||
dlgDir := filepath.Join(ctxDir, "dialogue")
|
||||
if err := os.MkdirAll(dlgDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if pkg == nil {
|
||||
_ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte("# 项目对话说明\n\n本任务未注入项目成员对话上下文(开关关闭或无可共享对话)。\n"), 0o644)
|
||||
if reason := dialogueOmitReason(projectID, pkg); reason != "" {
|
||||
_ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte(reason), 0o644)
|
||||
return nil
|
||||
}
|
||||
digest := pkg.DigestMD
|
||||
@@ -239,7 +239,9 @@ func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, pkg *DialoguePacka
|
||||
_ = os.WriteFile(filepath.Join(dlgDir, "turns.md"), []byte(turns), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dlgDir, "facts.json"), facts, 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dlgDir, "CONFIRMATIONS.md"), []byte(confirmations), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte("# 项目成员对话上下文\n\n- `digest.md`:按日纪要\n- `turns.md`:近窗带元数据原文\n- `facts.json`:对话沉淀事实候选\n- `CONFIRMATIONS.md`:冲突裁决审计\n\n权威度 `collab_dialogue`:低于黑板与已批准文档。\n"), 0o644)
|
||||
readme := "# 项目成员对话上下文\n\n- `digest.md`:按日纪要\n- `turns.md`:近窗带元数据原文\n- `facts.json`:对话沉淀事实候选\n- `CONFIRMATIONS.md`:冲突裁决审计\n\n权威度 `collab_dialogue`:低于黑板与已批准文档。\n"
|
||||
readme += dialogueCollectionStats(pkg)
|
||||
_ = os.WriteFile(filepath.Join(dlgDir, "README.md"), []byte(readme), 0o644)
|
||||
|
||||
*items = append(*items, ContextItem{
|
||||
File: "context/dialogue/",
|
||||
@@ -249,3 +251,37 @@ func (e *WorkspaceEngine) writeDialogueContext(ctxDir string, pkg *DialoguePacka
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func dialogueInjectionEnabled() bool {
|
||||
switch strings.TrimSpace(strings.ToLower(os.Getenv("DIALOGUE_CTX_INJECTION"))) {
|
||||
case "off", "false", "0", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// dialogueOmitReason 区分未注入的三种原因。已有对话包时返回空,调用方写入正文。
|
||||
func dialogueOmitReason(projectID int64, pkg *DialoguePackage) string {
|
||||
if pkg != nil {
|
||||
return ""
|
||||
}
|
||||
if !dialogueInjectionEnabled() {
|
||||
return "# 项目对话说明\n\n未注入项目成员对话:对话注入开关已关闭(DIALOGUE_CTX_INJECTION)。\n"
|
||||
}
|
||||
if projectID <= 0 {
|
||||
return "# 项目对话说明\n\n未注入项目成员对话:任务未绑定项目,无法收集个人会话与项目成员共享对话。\n"
|
||||
}
|
||||
return "# 项目对话说明\n\n未注入项目成员对话:项目已绑定,但本轮没有对话包(采集失败或调用方未传入)。这不表示对话已被判定为无用信息。\n"
|
||||
}
|
||||
|
||||
func dialogueCollectionStats(pkg *DialoguePackage) string {
|
||||
if pkg == nil {
|
||||
return ""
|
||||
}
|
||||
if pkg.TurnCount == 0 && pkg.ExcludedPrivate == 0 && pkg.ExcludedNoAccess == 0 && pkg.ExcludedNoise == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("\n采集统计:保留 %d 轮;滤除私密 %d、阶段不可见 %d、噪声 %d。仅当这些过滤后仍有内容时才写入 turns.md。\n",
|
||||
pkg.TurnCount, pkg.ExcludedPrivate, pkg.ExcludedNoAccess, pkg.ExcludedNoise)
|
||||
}
|
||||
|
||||
+3
-1
@@ -472,7 +472,9 @@ export default function App() {
|
||||
|
||||
// 基于当前登录用户与 RBAC 规则过滤可见交付物 (私密文档严格按 creatorId 隔离,单文档覆盖优先于工作流基线)
|
||||
const visibleDeliverables = useMemo(() => {
|
||||
return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles);
|
||||
return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles, {
|
||||
projectMember: true,
|
||||
});
|
||||
}, [currentProjectDeliverables, currentUser, roles]);
|
||||
|
||||
// 单文档可见性覆盖保存处理
|
||||
|
||||
@@ -112,8 +112,21 @@ test('用例 3: 常规工作流基线可见性 (workflow:view_all) 判定', () =
|
||||
// 工程师有 workflow:view_all -> 可见
|
||||
assert.equal(isDeliverableVisible(sampleUsers.engineer, DEFAULT_ROLES, normalDoc), true);
|
||||
|
||||
// 外部审计员无 workflow:view_all 且不是责任人 -> 不可见
|
||||
// 外部审计员无 workflow:view_all 且不是项目成员 -> 不可见
|
||||
assert.equal(isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, normalDoc), false);
|
||||
|
||||
// 同一访客作为项目成员打开本项目 -> 可见流程受控文档
|
||||
assert.equal(
|
||||
isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, normalDoc, { projectMember: true }),
|
||||
true,
|
||||
);
|
||||
const memberVisible = filterVisibleDeliverablesWithRBAC(
|
||||
[normalDoc],
|
||||
sampleUsers.guest,
|
||||
DEFAULT_ROLES,
|
||||
{ projectMember: true },
|
||||
);
|
||||
assert.equal(memberVisible.length, 1);
|
||||
});
|
||||
|
||||
test('用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截)', () => {
|
||||
@@ -138,6 +151,12 @@ test('用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可
|
||||
// 王总监虽为研发总监,但非三权责任人 -> 被单文档覆盖拦截 (不可见)
|
||||
assert.equal(isDeliverableVisible(sampleUsers.director, DEFAULT_ROLES, restrictedDoc), false);
|
||||
|
||||
// 项目成员身份不覆盖「仅责任人」收紧
|
||||
assert.equal(
|
||||
isDeliverableVisible(sampleUsers.guest, DEFAULT_ROLES, restrictedDoc, { projectMember: true }),
|
||||
false,
|
||||
);
|
||||
|
||||
// 系统管理员超级权限仍可见
|
||||
assert.equal(isDeliverableVisible(sampleUsers.admin, DEFAULT_ROLES, restrictedDoc), true);
|
||||
});
|
||||
|
||||
+14
-5
@@ -108,12 +108,14 @@ export function hasPermission(
|
||||
* - designated_only: 仅责任人可见 (非责任人已在步骤4排除,直接拒绝)
|
||||
* - role_whitelist: 仅白名单内角色可见
|
||||
* - public: 全员可见
|
||||
* 6. 回退继承工作流基线:用户角色需具备 workflow:view_all 权限
|
||||
* 6. 项目成员:已打开的项目内,流程受控文档对 active 成员可见(非成员不传 projectMember)
|
||||
* 7. 回退继承工作流基线:用户角色需具备 workflow:view_all 权限
|
||||
*/
|
||||
export function isDeliverableVisible(
|
||||
user: UserProfile,
|
||||
roles: RoleDefinition[],
|
||||
doc: DeliverableItem
|
||||
doc: DeliverableItem,
|
||||
options?: { projectMember?: boolean },
|
||||
): boolean {
|
||||
// 步骤 1:个人私密文档 (private) 绝对隔离
|
||||
if (doc.category === 'private') {
|
||||
@@ -166,7 +168,13 @@ export function isDeliverableVisible(
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤 6:回退继承工作流默认基线规则(文档可见性仍用角色矩阵作 UI 展示兜底)
|
||||
// 步骤 6:项目成员可见本项目流程受控文档。
|
||||
// 非成员仍回退到 workflow:view_all;单文档覆盖(步骤 5)优先于成员基线。
|
||||
if (doc.category === 'workflow' && options?.projectMember) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 步骤 7:回退继承工作流默认基线规则(文档可见性仍用角色矩阵作 UI 展示兜底)
|
||||
return hasPermission(user, roles, 'workflow:view_all', capabilitiesFromRole(roles, user.roleId));
|
||||
}
|
||||
|
||||
@@ -176,9 +184,10 @@ export function isDeliverableVisible(
|
||||
export function filterVisibleDeliverablesWithRBAC(
|
||||
deliverables: DeliverableItem[],
|
||||
user: UserProfile,
|
||||
roles: RoleDefinition[]
|
||||
roles: RoleDefinition[],
|
||||
options?: { projectMember?: boolean },
|
||||
): DeliverableItem[] {
|
||||
return deliverables.filter((doc) => isDeliverableVisible(user, roles, doc));
|
||||
return deliverables.filter((doc) => isDeliverableVisible(user, roles, doc, options));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user