fix(authz): separate capability readiness from configured flags
管理台不再把开关值当成能力已接入。lineage 读取必须落在 URL 项目内,并按两端文档可见性裁剪,跨项目或不可见文档只返回空列表。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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 容器,页面验收留到占槽冒烟。
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,38 @@ const SOURCE_META: Record<SystemFeatureSource, { label: string; color: string; b
|
||||
default: { label: '默认值', color: 'var(--text-muted)', bg: 'var(--bg-subtle)' },
|
||||
};
|
||||
|
||||
const IMPLEMENTATION_LABEL: Record<string, string> = {
|
||||
placeholder: '未接线',
|
||||
dry_run: '演练',
|
||||
shadow: '影子',
|
||||
live: '已接入',
|
||||
};
|
||||
|
||||
const ADAPTER_META: Record<string, { label: string; color: string; bg: string }> = {
|
||||
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<string, { label: string; color: string; bg: string }>) {
|
||||
if (!kind) return <span style={{ color: 'var(--text-faint)' }}>-</span>;
|
||||
const item = meta[kind] ?? { label: kind, color: 'var(--text-muted)', bg: 'var(--bg-subtle)' };
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
fontWeight: 600,
|
||||
backgroundColor: item.bg,
|
||||
color: item.color,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const FeaturesSection: React.FC = () => {
|
||||
const [features, setFeatures] = useState<SystemFeature[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -46,7 +78,7 @@ export const FeaturesSection: React.FC = () => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<SectionHeader
|
||||
title="特性与功能开关"
|
||||
desc="全量功能开关清单及其生效来源;来源为环境变量的项需在部署层调整后重启服务。"
|
||||
desc="当前值只表示进程读到的配置。是否真正可用要看「实现」和「适配器」;未接线或降级时,把开关设为 true 也不会生效。"
|
||||
/>
|
||||
<div style={{ borderRadius: '8px', border: '1px solid var(--border)', overflow: 'hidden' }}>
|
||||
<table style={sectionTableStyle}>
|
||||
@@ -61,6 +93,8 @@ export const FeaturesSection: React.FC = () => {
|
||||
<th style={sectionThStyle}>名称</th>
|
||||
<th style={sectionThStyle}>分组</th>
|
||||
<th style={sectionThStyle}>当前值</th>
|
||||
<th style={sectionThStyle}>实现</th>
|
||||
<th style={sectionThStyle}>适配器</th>
|
||||
<th style={sectionThStyle}>来源</th>
|
||||
<th style={sectionThStyle}>标注</th>
|
||||
</tr>
|
||||
@@ -68,7 +102,7 @@ export const FeaturesSection: React.FC = () => {
|
||||
<tbody>
|
||||
{features.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...sectionTdStyle, textAlign: 'center', color: 'var(--text-faint)' }}>
|
||||
<td colSpan={7} style={{ ...sectionTdStyle, textAlign: 'center', color: 'var(--text-faint)' }}>
|
||||
暂无特性开关数据
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,8 +121,17 @@ export const FeaturesSection: React.FC = () => {
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...sectionTdStyle, color: 'var(--text)' }}>{f.group || '-'}</td>
|
||||
<td style={{ ...sectionTdStyle, color: 'var(--brand-500)', fontWeight: 600 }}>
|
||||
{formatConfigValue(f.value)}
|
||||
<td style={{ ...sectionTdStyle, color: f.adapterReadiness === 'ready' ? 'var(--brand-500)' : 'var(--text-muted)', fontWeight: 600 }}>
|
||||
{formatConfigValue(f.effectiveValue ?? f.value)}
|
||||
</td>
|
||||
<td style={sectionTdStyle}>{IMPLEMENTATION_LABEL[f.implementationState ?? ''] ?? f.implementationState ?? '-'}</td>
|
||||
<td style={sectionTdStyle}>
|
||||
{readinessBadge(f.adapterReadiness, ADAPTER_META)}
|
||||
{f.adapterReason && (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px', maxWidth: '280px' }}>
|
||||
{f.adapterReason}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={sectionTdStyle}>
|
||||
<span
|
||||
|
||||
@@ -29,11 +29,20 @@ export interface SystemKbInfo {
|
||||
|
||||
export type SystemFeatureSource = 'env' | 'db' | 'default';
|
||||
|
||||
export type FeatureImplementationState = 'placeholder' | 'dry_run' | 'shadow' | 'live';
|
||||
export type FeatureAdapterReadiness = 'ready' | 'unavailable' | 'degraded';
|
||||
|
||||
export interface SystemFeature {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
value: SystemConfigValue;
|
||||
configuredValue?: SystemConfigValue;
|
||||
effectiveValue?: SystemConfigValue;
|
||||
implementationState?: FeatureImplementationState;
|
||||
adapterReadiness?: FeatureAdapterReadiness;
|
||||
adapterReason?: string;
|
||||
rolloutScope?: string;
|
||||
source: SystemFeatureSource;
|
||||
requiresRestart: boolean;
|
||||
description: string;
|
||||
|
||||
Reference in New Issue
Block a user