From 033462a4e690d573daa05c79e1f237f6b66a77b5 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 03:45:16 +0000 Subject: [PATCH] fix(authz): separate capability readiness from configured flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理台不再把开关值当成能力已接入。lineage 读取必须落在 URL 项目内,并按两端文档可见性裁剪,跨项目或不可见文档只返回空列表。 Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 3 + ...-20-feature-readiness-and-lineage-authz.md | 57 +++++++++ internal/projectworkflow/service.go | 11 +- internal/projectworkflow/service_test.go | 44 +++++++ internal/projectworkflow/store.go | 43 +++++++ internal/server/lineage_access.go | 109 +++++++++++++++++ internal/server/lineage_access_test.go | 46 ++++++++ internal/server/project_handlers.go | 34 +++--- internal/server/project_workflow_api_test.go | 67 +++++++++++ internal/sysadmin/features.go | 111 +++++++++++++++--- internal/sysadmin/service_test.go | 43 +++++++ .../console/sections/FeaturesSection.tsx | 51 +++++++- web/src/types/systemAdmin.ts | 9 ++ 13 files changed, 588 insertions(+), 40 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-20-feature-readiness-and-lineage-authz.md create mode 100644 internal/server/lineage_access.go create mode 100644 internal/server/lineage_access_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 92cdb2e2..fd5330d4 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -15,6 +15,8 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor-Auto | 2026-09-20-feature-readiness-and-lineage-authz | feature/capability-enablement-impact-plan | - | sysadmin+lineage | done | +| Codex | 2026-09-20-capability-enablement-impact-plan | feature/capability-enablement-impact-plan | - | capability-impact-research | done | | Cursor | audit-b1-data-consistency | fix/audit-b1-data-consistency | - | docgov+workflow+chat | done | | Cursor | audit-b2-authz-hardening | fix/audit-b2-authz-hardening | - | authz | done | | Cursor | audit-b3-sse-events | fix/audit-b3-sse-events | - | chat+events | done | @@ -176,6 +178,7 @@ | 测试报告 | [test-reports/](../test-reports/) | | dev→test 晋升前复核 | [research](./research/2026-09-19-pre-test-promotion-review.md)(2026-09-19 只读;静态门禁过,容器未 rebuild,暂不合并) | | D7 盘点(W7) | [audit](../audit/2026-09-17-d7-data-inventory.md) · [报告](../test-reports/2026-09-17-w7-d7-inventory-test-report.md) · ADR 仍 `pending-compliance-signoff` | +| 功能启用与文档影响分析 | [调研](./research/2026-09-20-capability-enablement-impact-research.md) · [整改方案](../remediation/2026-09-20-capability-enablement-impact-plan.md) · [方案审阅](./research/2026-09-20-capability-enablement-impact-plan-review.md)(2026-09-20;未启用开关、未改业务代码) | | Wave 7 编排确认 | [决策记录](./short-term/2026-09-18-wave7-roadmap-orchestration.md) | | Agent 手册 | [AGENTS.md](../../AGENTS.md) | | 意图双标签 busy 交接 | [./handoffs/2026-09-15-conversation-intent-dual-tab.md](./handoffs/2026-09-15-conversation-intent-dual-tab.md) | diff --git a/.doc/ops/short-term/2026-09-20-feature-readiness-and-lineage-authz.md b/.doc/ops/short-term/2026-09-20-feature-readiness-and-lineage-authz.md new file mode 100644 index 00000000..81d69be7 --- /dev/null +++ b/.doc/ops/short-term/2026-09-20-feature-readiness-and-lineage-authz.md @@ -0,0 +1,57 @@ +--- +title: "能力就绪度与 lineage 鉴权" +status: done +created: 2026-09-20 +updated: 2026-09-20 +owner: Cursor-Auto +branch: feature/capability-enablement-impact-plan +worktree: "-" +mutex: sysadmin+lineage +--- + +# 能力就绪度与 lineage 鉴权 + +## 目标 + +管理台把配置值与实现成熟度、适配器就绪度分开;lineage 读取必须落在 URL 项目内,并按逐文档可见性裁剪,跨项目或不可见文档不泄漏标识。 + +## 范围 + +- Foundation 0:功能目录增加 `implementationState`、`adapterReadiness`、`configuredValue` / `effectiveValue`、`rolloutScope`。 +- lineage GET:`project_id` 约束、查询文档属于该项目、两端文档可见性过滤;空结果与“不可见”使用同一空列表,不区分存在性。 + +## 非目标 + +- 不打开 test/main 开关,不升 `full`,不接 `SEMANTIC_SUMMARY_REPLACE`。 +- 不实现 `trace_reviewer` 端点(待审核)。 +- 不建项目追踪五表,不决定 D7 留存与源文档删除传播。 +- 不占用集成槽,不重启共享容器。 + +## 步骤 + +1. [x] 能力目录与管理台展示成熟度,配置值不能冒充可用。 +2. [x] lineage 查询按项目与文档可见性过滤,并补跨项目负例。 +3. [x] `go test` 覆盖上述契约。 + +## 验收标准 + +- [x] `SEMANTIC_SUMMARY_REPLACE=true` 时实现状态仍为 `placeholder`,适配器 `unavailable`。 +- [x] 用项目 A 的 URL 查询项目 B 的 `doc_id` 得到空列表,响应体不含 B 的上游文档 ID。 +- [x] 项目 B 自己的 URL 仍能读到已登记且两端都可解析的血缘。 + +## 相关链接 + +- 整改方案: [2026-09-20-capability-enablement-impact-plan.md](../../remediation/2026-09-20-capability-enablement-impact-plan.md) +- 调研: [2026-09-20-capability-enablement-impact-research.md](../research/2026-09-20-capability-enablement-impact-research.md) + +## 证据 / 备注 + +2026-09-20 本地 `go test`(未占集成槽、未重启容器): + +- `TestFeatures_ConfiguredValueDoesNotImplyReadiness` PASS +- `TestGetLineageForProjectDownstream_ExcludesOtherProject` PASS +- `TestProjectLineage_ForeignDocDoesNotLeak` PASS +- `TestProjectWorkflow_TestManagerAndLineageStale` PASS +- `TestFilterLineageEdges_DropsForeignAndHiddenEndpoints` PASS + +管理台表格改在 `web/src/components/admin/console/sections/FeaturesSection.tsx`。本波未重建 dev 容器,页面验收留到占槽冒烟。 diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index c4722eec..73471a7f 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -259,11 +259,20 @@ func (s *Service) RecordLineage(ctx context.Context, lineage *DocumentLineage) e return s.store.RecordLineage(ctx, lineage) } -// GetLineageForDownstream 查询下游文档所依赖的上游版本信息 +// GetLineageForDownstream 查询下游文档所依赖的上游版本信息。 +// 内部失效传播仍用此方法;HTTP 读取必须走 GetLineageForProjectDownstream。 func (s *Service) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) { return s.store.GetLineageForDownstream(ctx, docID, version) } +// GetLineageForProjectDownstream 只返回 URL 项目内的血缘边。 +func (s *Service) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { + if projectID <= 0 || strings.TrimSpace(docID) == "" { + return nil, nil + } + return s.store.GetLineageForProjectDownstream(ctx, projectID, strings.TrimSpace(docID), version) +} + // CheckAndPropagateStale 当上游文档更新且内容哈希变化时,通知并将关联下游文档标记为 stale func (s *Service) CheckAndPropagateStale( ctx context.Context, diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 2bea9f7a..1f767d70 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -231,6 +231,50 @@ func TestProjectWorkflowService_LineageAndStale(t *testing.T) { } } +func TestGetLineageForProjectDownstream_ExcludesOtherProject(t *testing.T) { + ctx := context.Background() + svc := NewService(NewStore(nil), NewDefaultCatalog()) + creator := &auth.UserAccount{ID: 101, Username: "zhangsan", Name: "张工"} + projA, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "项目A"}) + if err != nil { + t.Fatal(err) + } + projB, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "项目B"}) + if err != nil { + t.Fatal(err) + } + downstreamB := "doc_projB_test_manager" + upstreamB := "doc_projB_system_reqs" + if err := svc.RecordLineage(ctx, &DocumentLineage{ + ProjectID: projB.ID, + DownstreamDocID: downstreamB, + DownstreamVersion: 1, + UpstreamDocID: upstreamB, + UpstreamVersion: 1, + }); err != nil { + t.Fatal(err) + } + + fromA, err := svc.GetLineageForProjectDownstream(ctx, projA.ID, downstreamB, 0) + if err != nil { + t.Fatal(err) + } + if len(fromA) != 0 { + t.Fatalf("project A must not see project B lineage, got %+v", fromA) + } + fromB, err := svc.GetLineageForProjectDownstream(ctx, projB.ID, downstreamB, 0) + if err != nil { + t.Fatal(err) + } + if len(fromB) != 1 || fromB[0].UpstreamDocID != upstreamB { + t.Fatalf("project B should see its own edge, got %+v", fromB) + } + unscoped, err := svc.GetLineageForDownstream(ctx, downstreamB, 0) + if err != nil || len(unscoped) != 1 { + t.Fatalf("internal unscoped lookup remains for stale propagation, got %v %+v", err, unscoped) + } +} + func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) { ctx := context.Background() svc := NewService(NewStore(nil), NewDefaultCatalog()) diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 4b909857..72b9f712 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -30,6 +30,8 @@ type Store interface { RecordLineage(ctx context.Context, lineage *DocumentLineage) error GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) + // GetLineageForProjectDownstream 只返回属于该项目的边,避免用他项目 doc_id 读出血缘。 + GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) } @@ -535,6 +537,34 @@ func (s *pgStore) GetLineageForDownstream(ctx context.Context, docID string, ver return list, rows.Err() } +func (s *pgStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { + query := ` + SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, + upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at + FROM document_lineage + WHERE project_id = $1 AND downstream_doc_id = $2 AND ($3 <= 0 OR downstream_version = $3) + ORDER BY id DESC + ` + rows, err := s.pool.Query(ctx, query, projectID, docID, version) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []*DocumentLineage + for rows.Next() { + l := &DocumentLineage{} + if err := rows.Scan( + &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, + &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, + ); err != nil { + return nil, err + } + list = append(list, l) + } + return list, rows.Err() +} + func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { query := ` SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, @@ -933,6 +963,19 @@ func (m *memStore) GetLineageForDownstream(ctx context.Context, docID string, ve return list, nil } +func (m *memStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*DocumentLineage + for _, l := range m.lineages { + if l.ProjectID == projectID && l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) { + cpy := *l + list = append(list, &cpy) + } + } + return list, nil +} + func (m *memStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/server/lineage_access.go b/internal/server/lineage_access.go new file mode 100644 index 00000000..0b197d42 --- /dev/null +++ b/internal/server/lineage_access.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectworkflow" +) + +// filterLineageEdges 丢掉不属于该项目,或任一端文档对当前主体不可见的边。 +// 不可见端整行省略,避免通过 ID、标题或数量泄漏。 +func filterLineageEdges(rows []*projectworkflow.DocumentLineage, projectID int64, visible func(docID string) bool) []*projectworkflow.DocumentLineage { + out := make([]*projectworkflow.DocumentLineage, 0, len(rows)) + for _, row := range rows { + if row == nil || row.ProjectID != projectID { + continue + } + if visible == nil || !visible(row.DownstreamDocID) || !visible(row.UpstreamDocID) { + continue + } + out = append(out, row) + } + return out +} + +func writeEmptyLineage(w http.ResponseWriter) { + writeJSONResponse(w, http.StatusOK, []*projectworkflow.DocumentLineage{}) +} + +// lineageDocVisible 判断 docID 是否属于 projectID,且当前用户可查看其任务。 +// 无法证明归属时返回 false,调用方应给出与“没有血缘”相同的空结果。 +func (s *Server) lineageDocVisible(ctx context.Context, projectID, userID int64, docID string) bool { + docID = strings.TrimSpace(docID) + if s == nil || projectID <= 0 || docID == "" { + return false + } + taskKey, placed, ok := s.lineageDocPlacement(ctx, projectID, docID) + if !ok || !placed { + return false + } + if taskKey == "" { + return true + } + return s.allowWorkflowDocTaskAccess(ctx, projectID, userID, taskKey) +} + +func (s *Server) lineageDocPlacement(ctx context.Context, projectID int64, docID string) (taskKey string, placed bool, ok bool) { + if s.gov != nil { + doc, err := s.gov.GetDocument(ctx, docID) + if err != nil && !errors.Is(err, docgov.ErrDocumentNotFound) { + return "", false, false + } + if err == nil && doc != nil { + if doc.ProjectID != projectID { + return "", false, true + } + return doc.TaskKey, true, true + } + } + if pid, task, parsed := parseCanonicalWorkflowDocID(docID); parsed { + if pid != projectID { + return "", false, true + } + return task, true, true + } + if pid, parsed := parseHyphenProjectDocID(docID); parsed { + if pid != projectID { + return "", false, true + } + return "", true, true + } + return "", false, true +} + +func parseHyphenProjectDocID(docID string) (int64, bool) { + const prefix = "doc-proj" + if !strings.HasPrefix(docID, prefix) { + return 0, false + } + rest := strings.TrimPrefix(docID, prefix) + idx := strings.IndexByte(rest, '-') + if idx <= 0 || idx >= len(rest)-1 { + return 0, false + } + projectID, err := strconv.ParseInt(rest[:idx], 10, 64) + if err != nil || projectID <= 0 { + return 0, false + } + return projectID, true +} + +// lineageTestManagerAlias 仅在两种 test_manager 文档 ID 写法之间互换,不把任意空查询改写成别的文档。 +func lineageTestManagerAlias(projectID int64, docID string) (string, bool) { + canonical := fmt.Sprintf("doc_proj%d_test_manager", projectID) + hyphen := fmt.Sprintf("doc-proj%d-test_manager", projectID) + switch docID { + case canonical: + return hyphen, true + case hyphen: + return canonical, true + default: + return "", false + } +} diff --git a/internal/server/lineage_access_test.go b/internal/server/lineage_access_test.go new file mode 100644 index 00000000..33023f3c --- /dev/null +++ b/internal/server/lineage_access_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "testing" + + "chat-agent-system/internal/projectworkflow" +) + +func TestFilterLineageEdges_DropsForeignAndHiddenEndpoints(t *testing.T) { + t.Parallel() + rows := []*projectworkflow.DocumentLineage{ + {ProjectID: 1, DownstreamDocID: "doc_proj1_test_manager", UpstreamDocID: "doc_proj1_system_reqs"}, + {ProjectID: 2, DownstreamDocID: "doc_proj1_test_manager", UpstreamDocID: "doc_proj2_secret"}, + {ProjectID: 1, DownstreamDocID: "doc_proj1_test_manager", UpstreamDocID: "doc_proj1_hidden"}, + } + visible := map[string]bool{ + "doc_proj1_test_manager": true, + "doc_proj1_system_reqs": true, + } + got := filterLineageEdges(rows, 1, func(docID string) bool { return visible[docID] }) + if len(got) != 1 || got[0].UpstreamDocID != "doc_proj1_system_reqs" { + t.Fatalf("only the visible in-project edge should remain, got %+v", got) + } +} + +func TestLineageTestManagerAlias_DoesNotRewriteArbitraryDocs(t *testing.T) { + t.Parallel() + if alt, ok := lineageTestManagerAlias(9, "doc_proj9_intake"); ok || alt != "" { + t.Fatalf("arbitrary doc must not alias, got %q ok=%v", alt, ok) + } + alt, ok := lineageTestManagerAlias(9, "doc_proj9_test_manager") + if !ok || alt != "doc-proj9-test_manager" { + t.Fatalf("canonical test_manager alias = %q ok=%v", alt, ok) + } +} + +func TestParseHyphenProjectDocID(t *testing.T) { + t.Parallel() + id, ok := parseHyphenProjectDocID("doc-proj42-test_manager") + if !ok || id != 42 { + t.Fatalf("got (%d,%v)", id, ok) + } + if _, ok := parseHyphenProjectDocID("doc_proj42_test_manager"); ok { + t.Fatal("underscore form is not a hyphen id") + } +} diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 3da5d98b..4c0e055f 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -276,7 +276,8 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re writeJSONResponse(w, http.StatusOK, runs) } -// handleGetProjectLineage 查询项目文档血缘关系及失效状态 +// handleGetProjectLineage 查询项目文档血缘关系及失效状态。 +// doc_id 必须属于 URL 中的项目;不可见或不属于该项目时返回空列表,不暴露对象是否存在。 func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") projectID, _ := strconv.ParseInt(idStr, 10, 64) @@ -284,40 +285,33 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`) return } - if _, ok := s.requireProjectAccess(w, r, projectID); !ok { + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { return } docID := strings.TrimSpace(r.URL.Query().Get("doc_id")) if docID == "" { - idStr := r.PathValue("id") - id, _ := strconv.ParseInt(idStr, 10, 64) - if id > 0 { - docID = fmt.Sprintf("doc_proj%d_test_manager", id) - } + docID = fmt.Sprintf("doc_proj%d_test_manager", projectID) } - if docID == "" { - writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"doc_id is required"}`) + if !s.lineageDocVisible(r.Context(), projectID, user.ID, docID) { + writeEmptyLineage(w) return } - lineages, err := s.pwSvc.GetLineageForDownstream(r.Context(), docID, 0) + lineages, err := s.pwSvc.GetLineageForProjectDownstream(r.Context(), projectID, docID, 0) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) return } if len(lineages) == 0 { - idStr := r.PathValue("id") - if id, _ := strconv.ParseInt(idStr, 10, 64); id > 0 { - hyphenDocID := fmt.Sprintf("doc-proj%d-test_manager", id) - if hyphenDocID != docID { - if fb, fbErr := s.pwSvc.GetLineageForDownstream(r.Context(), hyphenDocID, 0); fbErr == nil && len(fb) > 0 { - lineages = fb - } + if alt, aliasOK := lineageTestManagerAlias(projectID, docID); aliasOK && s.lineageDocVisible(r.Context(), projectID, user.ID, alt) { + if fb, fbErr := s.pwSvc.GetLineageForProjectDownstream(r.Context(), projectID, alt, 0); fbErr == nil && len(fb) > 0 { + lineages = fb } } } - if lineages == nil { - lineages = make([]*projectworkflow.DocumentLineage, 0) + visible := func(id string) bool { + return s.lineageDocVisible(r.Context(), projectID, user.ID, id) } - writeJSONResponse(w, http.StatusOK, lineages) + writeJSONResponse(w, http.StatusOK, filterLineageEdges(lineages, projectID, visible)) } diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index cd20c7aa..753fbdd0 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -256,3 +256,70 @@ func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) { require.NotEmpty(t, lineagesAfter) assert.True(t, lineagesAfter[0].IsStale) } + +func TestProjectLineage_ForeignDocDoesNotLeak(t *testing.T) { + srv, handler := setupTestServerForProjectWorkflow(t) + token := loginTestUser(t, handler, "zhanggong", "123456") + create := func(name string) int64 { + body, err := json.Marshal(projectworkflow.CreateProjectInput{Name: name}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(body)) + withUserIDAuth(req, token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + require.Equal(t, http.StatusCreated, rec.Code) + var proj projectworkflow.Project + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &proj)) + return proj.ID + } + idA := create("血缘项目A") + idB := create("血缘项目B") + upstreamB := fmt.Sprintf("doc_proj%d_system_reqs", idB) + downstreamB := fmt.Sprintf("doc_proj%d_test_manager", idB) + require.NoError(t, srv.ProjectWorkflowService().RecordLineage(nil, &projectworkflow.DocumentLineage{ + ProjectID: idB, + DownstreamDocID: downstreamB, + DownstreamVersion: 1, + UpstreamDocID: upstreamB, + UpstreamVersion: 1, + })) + + reqA := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/lineage?doc_id=%s", idA, downstreamB), nil) + withUserIDAuth(reqA, token) + recA := httptest.NewRecorder() + handler.ServeHTTP(recA, reqA) + require.Equal(t, http.StatusOK, recA.Code) + assert.NotContains(t, recA.Body.String(), upstreamB) + assert.JSONEq(t, "[]", recA.Body.String()) + + reqMissing := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/lineage?doc_id=%s", idA, fmt.Sprintf("doc_proj%d_intake", idA)), nil) + withUserIDAuth(reqMissing, token) + recMissing := httptest.NewRecorder() + handler.ServeHTTP(recMissing, reqMissing) + require.Equal(t, http.StatusOK, recMissing.Code) + assert.JSONEq(t, "[]", recMissing.Body.String()) + + hiddenUpstream := fmt.Sprintf("doc_proj%d_secret_spec", idB) + downstreamA := fmt.Sprintf("doc_proj%d_test_manager", idA) + require.NoError(t, srv.ProjectWorkflowService().RecordLineage(nil, &projectworkflow.DocumentLineage{ + ProjectID: idA, + DownstreamDocID: downstreamA, + DownstreamVersion: 1, + UpstreamDocID: hiddenUpstream, + UpstreamVersion: 1, + })) + reqHidden := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/lineage?doc_id=%s", idA, downstreamA), nil) + withUserIDAuth(reqHidden, token) + recHidden := httptest.NewRecorder() + handler.ServeHTTP(recHidden, reqHidden) + require.Equal(t, http.StatusOK, recHidden.Code) + assert.NotContains(t, recHidden.Body.String(), hiddenUpstream) + assert.JSONEq(t, "[]", recHidden.Body.String()) + + reqB := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/lineage?doc_id=%s", idB, downstreamB), nil) + withUserIDAuth(reqB, token) + recB := httptest.NewRecorder() + handler.ServeHTTP(recB, reqB) + require.Equal(t, http.StatusOK, recB.Code) + assert.Contains(t, recB.Body.String(), upstreamB) +} diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index aea369ae..dabd88fb 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -2,15 +2,86 @@ package sysadmin import "os" -// Feature 功能开关视图;value 统一用字符串表示。 +// Feature 功能开关视图。value / effectiveValue 是进程实际读到的配置, +// 不等于能力已经接入;是否可用看 implementationState 与 adapterReadiness。 type Feature struct { - Key string `json:"key"` - Label string `json:"label"` - Group string `json:"group"` - Value string `json:"value"` - Source string `json:"source"` - RequiresRestart bool `json:"requiresRestart"` - Description string `json:"description"` + Key string `json:"key"` + Label string `json:"label"` + Group string `json:"group"` + Value string `json:"value"` + ConfiguredValue string `json:"configuredValue"` + EffectiveValue string `json:"effectiveValue"` + ImplementationState string `json:"implementationState"` + AdapterReadiness string `json:"adapterReadiness"` + AdapterReason string `json:"adapterReason,omitempty"` + RolloutScope string `json:"rolloutScope"` + Source string `json:"source"` + RequiresRestart bool `json:"requiresRestart"` + Description string `json:"description"` +} + +const ( + implPlaceholder = "placeholder" + implDryRun = "dry_run" + implShadow = "shadow" + implLive = "live" + + adapterReady = "ready" + adapterUnavailable = "unavailable" + adapterDegraded = "degraded" + + rolloutEnvironment = "environment" +) + +type featureMaturity struct { + state string + adapter string + reason string +} + +// featureMaturityCatalog 是源码接线事实,不随环境变量翻转。 +// 未列出的开关视为已接入的环境级能力;列出的项即使配置为 true 也保持此处状态。 +var featureMaturityCatalog = map[string]featureMaturity{ + "FAV_PUBLISHER_ENABLED": { + state: implDryRun, adapter: adapterUnavailable, + reason: "观察窗未授权,禁止 live", + }, + "FAV_DEGRADED_READ": { + state: implDryRun, adapter: adapterUnavailable, + reason: "降级读保持关闭", + }, + "MULTI_INTENT_PLAN_TIER": { + state: implDryRun, adapter: adapterUnavailable, + reason: "持久计划生产路径仍使用 Fake query owner,confirm 后缺少调度接线;调高档位不会变成可用", + }, + "MULTI_INTENT_U9_SEGMENT": { + state: implDryRun, adapter: adapterUnavailable, + reason: "仅有 PI proposer,尚未进入真实聊天分段", + }, + "MULTI_INTENT_U10_MUTATE": { + state: implDryRun, adapter: adapterUnavailable, + reason: "适配器仍是 dry-run/validated-only", + }, + "MULTI_INTENT_U11_DOCGOV": { + state: implDryRun, adapter: adapterUnavailable, + reason: "适配器仍是 dry-run/validated-only", + }, + "MULTI_INTENT_U12_RUN_CONTROL": { + state: implDryRun, adapter: adapterUnavailable, + reason: "适配器仍是 dry-run/validated-only", + }, + "SEMANTIC_SUMMARY_SHADOW": { + state: implShadow, adapter: adapterDegraded, + reason: "影子组件默认关闭;chat_summaries 缺失时持久化会静默退回内存,Runner 也还不是纯推理", + }, + "SEMANTIC_SUMMARY_REPLACE": { + state: implPlaceholder, adapter: adapterUnavailable, + reason: "替换路径明确未接线,打开开关不会替换叙事", + }, + "PI_CHANGE_REPORT_NARRATIVE": { + state: implShadow, adapter: adapterDegraded, + reason: "生产输入目前只有 SHA、风险和变更数量,没有具体差异;Runner 也还不是纯推理", + }, } type featureSpec struct { @@ -63,14 +134,24 @@ func (s *Service) Features() []Feature { value = spec.fallback } } + maturity := featureMaturity{state: implLive, adapter: adapterReady} + if known, ok := featureMaturityCatalog[spec.key]; ok { + maturity = known + } out = append(out, Feature{ - Key: spec.key, - Label: spec.label, - Group: spec.group, - Value: value, - Source: "env", - RequiresRestart: true, - Description: spec.description, + Key: spec.key, + Label: spec.label, + Group: spec.group, + Value: value, + ConfiguredValue: value, + EffectiveValue: value, + ImplementationState: maturity.state, + AdapterReadiness: maturity.adapter, + AdapterReason: maturity.reason, + RolloutScope: rolloutEnvironment, + Source: "env", + RequiresRestart: true, + Description: spec.description, }) } return out diff --git a/internal/sysadmin/service_test.go b/internal/sysadmin/service_test.go index 7827cf17..a8e20c39 100644 --- a/internal/sysadmin/service_test.go +++ b/internal/sysadmin/service_test.go @@ -148,4 +148,47 @@ func TestFeatures_CoversCatalogAndInjectedValues(t *testing.T) { t.Fatalf("catalog must cover %s", key) } } + if byKey["PI_CHANGE_REPORT_NARRATIVE"].ImplementationState != "shadow" || byKey["PI_CHANGE_REPORT_NARRATIVE"].AdapterReadiness != "degraded" { + t.Fatalf("narrative readiness must stay degraded while input is incomplete: %+v", byKey["PI_CHANGE_REPORT_NARRATIVE"]) + } +} + +func TestFeatures_ConfiguredValueDoesNotImplyReadiness(t *testing.T) { + svc := NewService(testConfig(), Options{ + FeatureValues: func() map[string]string { + return map[string]string{ + "SEMANTIC_SUMMARY_REPLACE": "true", + "MULTI_INTENT_PLAN_TIER": "confirm", + "MULTI_INTENT_U9_SEGMENT": "true", + } + }, + }) + byKey := map[string]Feature{} + for _, f := range svc.Features() { + byKey[f.Key] = f + if f.ConfiguredValue != f.Value || f.EffectiveValue != f.Value { + t.Fatalf("env-only flag %s must report the same configured and effective value: %+v", f.Key, f) + } + if f.RolloutScope != "environment" { + t.Fatalf("flag %s rollout scope = %q", f.Key, f.RolloutScope) + } + if f.ImplementationState == "" || f.AdapterReadiness == "" { + t.Fatalf("flag %s missing readiness: %+v", f.Key, f) + } + } + replace := byKey["SEMANTIC_SUMMARY_REPLACE"] + if replace.Value != "true" || replace.ImplementationState != "placeholder" || replace.AdapterReadiness != "unavailable" { + t.Fatalf("replace=true must stay unwired: %+v", replace) + } + tier := byKey["MULTI_INTENT_PLAN_TIER"] + if tier.Value != "confirm" || tier.ImplementationState != "dry_run" || tier.AdapterReadiness != "unavailable" { + t.Fatalf("confirm tier must not look production-ready: %+v", tier) + } + u9 := byKey["MULTI_INTENT_U9_SEGMENT"] + if u9.Value != "true" || u9.AdapterReadiness != "unavailable" { + t.Fatalf("U9 flag must not imply a live segmenter: %+v", u9) + } + if byKey["MULTI_INTENT_ENABLED"].ImplementationState != "live" || byKey["MULTI_INTENT_ENABLED"].AdapterReadiness != "ready" { + t.Fatalf("legacy multi-intent remains the live path: %+v", byKey["MULTI_INTENT_ENABLED"]) + } } diff --git a/web/src/components/admin/console/sections/FeaturesSection.tsx b/web/src/components/admin/console/sections/FeaturesSection.tsx index 5a9a28c1..eb43c294 100644 --- a/web/src/components/admin/console/sections/FeaturesSection.tsx +++ b/web/src/components/admin/console/sections/FeaturesSection.tsx @@ -18,6 +18,38 @@ const SOURCE_META: Record = { + placeholder: '未接线', + dry_run: '演练', + shadow: '影子', + live: '已接入', +}; + +const ADAPTER_META: Record = { + ready: { label: '可用', color: 'var(--success)', bg: 'var(--success-bg)' }, + unavailable: { label: '不可用', color: 'var(--danger, #b42318)', bg: 'var(--danger-bg, #fef3f2)' }, + degraded: { label: '降级', color: 'var(--warning, #b54708)', bg: 'var(--warning-bg, #fffaeb)' }, +}; + +function readinessBadge(kind: string | undefined, meta: Record) { + if (!kind) return -; + const item = meta[kind] ?? { label: kind, color: 'var(--text-muted)', bg: 'var(--bg-subtle)' }; + return ( + + {item.label} + + ); +} + export const FeaturesSection: React.FC = () => { const [features, setFeatures] = useState([]); const [loading, setLoading] = useState(true); @@ -46,7 +78,7 @@ export const FeaturesSection: React.FC = () => {
@@ -61,6 +93,8 @@ export const FeaturesSection: React.FC = () => { + + @@ -68,7 +102,7 @@ export const FeaturesSection: React.FC = () => { {features.length === 0 && ( - @@ -87,8 +121,17 @@ export const FeaturesSection: React.FC = () => { )} - + +
名称 分组 当前值实现适配器 来源 标注
+ 暂无特性开关数据
{f.group || '-'} - {formatConfigValue(f.value)} + + {formatConfigValue(f.effectiveValue ?? f.value)} + {IMPLEMENTATION_LABEL[f.implementationState ?? ''] ?? f.implementationState ?? '-'} + {readinessBadge(f.adapterReadiness, ADAPTER_META)} + {f.adapterReason && ( +
+ {f.adapterReason} +
+ )}