fix(authz): separate capability readiness from configured flags

管理台不再把开关值当成能力已接入。lineage 读取必须落在 URL 项目内,并按两端文档可见性裁剪,跨项目或不可见文档只返回空列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 03:45:16 +00:00
co-authored by Cursor
parent d3364f9125
commit 033462a4e6
13 changed files with 588 additions and 40 deletions
+3
View File
@@ -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 容器,页面验收留到占槽冒烟。
+10 -1
View File
@@ -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,
+44
View File
@@ -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())
+43
View File
@@ -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()
+109
View File
@@ -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
}
}
+46
View File
@@ -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")
}
}
+14 -20
View File
@@ -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)
}
+96 -15
View File
@@ -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
+43
View File
@@ -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
+9
View File
@@ -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;