merge: feature/track-t-strengthen-2026-09-22

This commit is contained in:
WeiCHEN
2026-09-22 21:54:48 +08:00
9 changed files with 435 additions and 5 deletions
+1
View File
@@ -84,6 +84,7 @@ type Config struct {
VaultMasterKey string `json:"vault_master_key"` VaultMasterKey string `json:"vault_master_key"`
FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false
FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false
HTTPAccessLogEnabled bool `json:"http_access_log_enabled"` // HTTP_ACCESS_LOG; default on for dev/test
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"log/slog"
"net/http"
"strings"
"time"
"chat-agent-system/internal/httpx"
)
// accessLogMiddleware emits one structured line per HTTP request when enabled.
// It never logs bodies, Authorization, Cookie, or raw query strings.
func (s *Server) accessLogMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s == nil || s.cfg == nil || !s.cfg.HTTPAccessLogEnabled {
next.ServeHTTP(w, r)
return
}
started := time.Now()
requestID := httpx.RequestID(r)
w.Header().Set(httpx.HeaderRequestID, requestID)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("http_access",
"method", r.Method,
"path", accessLogPath(r.URL.Path),
"status", rec.status,
"duration_ms", time.Since(started).Milliseconds(),
"request_id", requestID,
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (r *statusRecorder) WriteHeader(code int) {
if r.wroteHeader {
return
}
r.wroteHeader = true
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
return r.ResponseWriter.Write(b)
}
// accessLogPath keeps only the URL path. Query strings are dropped so tokens
// and secrets in ?access_token=… never reach the log line.
func accessLogPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/"
}
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
if i := strings.IndexByte(path, '#'); i >= 0 {
path = path[:i]
}
return path
}
// accessLogSafeMessage is used by tests to assert the middleware contract.
func accessLogSafeMessage(method, path string, status int, durationMs int64, requestID string) map[string]any {
return map[string]any{
"method": method,
"path": accessLogPath(path),
"status": status,
"duration_ms": durationMs,
"request_id": requestID,
}
}
@@ -0,0 +1,89 @@
package server
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"chat-agent-system/internal/config"
)
func TestAccessLogPath_DropsQueryAndFragment(t *testing.T) {
got := accessLogPath("/api/v1/chat?token=secret&q=1#frag")
if got != "/api/v1/chat" {
t.Fatalf("path=%q", got)
}
msg := accessLogSafeMessage("GET", "/x?password=p", 200, 3, "req_1")
blob := strings.ToLower(strings.Join([]string{
msg["method"].(string),
msg["path"].(string),
msg["request_id"].(string),
}, " "))
for _, leak := range []string{"password", "token", "secret", "bearer", "authorization"} {
if strings.Contains(blob, leak) {
t.Fatalf("leak %q in %#v", leak, msg)
}
}
}
func TestAccessLogMiddleware_EmitsSafeFieldsOnly(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
srv := &Server{cfg: &config.Config{HTTPAccessLogEnabled: true}}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Authorization", "Bearer should-not-log")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"password":"nope","prompt":"secret text"}`))
})
handler := srv.accessLogMiddleware(inner)
req := httptest.NewRequest(http.MethodPost, "/api/v1/login?access_token=leak", strings.NewReader(`{"password":"x"}`))
req.Header.Set("Authorization", "Bearer super-secret")
req.Header.Set("Cookie", "session=abc")
req.Header.Set("X-Request-ID", "req_test_access")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d", rec.Code)
}
line := buf.String()
if !strings.Contains(line, `"msg":"http_access"`) && !strings.Contains(line, `"http_access"`) {
t.Fatalf("missing http_access log: %s", line)
}
if !strings.Contains(line, `"path":"/api/v1/login"`) {
t.Fatalf("expected redacted path, got %s", line)
}
if !strings.Contains(line, `"status":201`) {
t.Fatalf("expected status, got %s", line)
}
if !strings.Contains(line, "req_test_access") {
t.Fatalf("expected request_id, got %s", line)
}
low := strings.ToLower(line)
for _, leak := range []string{"super-secret", "access_token=leak", "password", "bearer super", "session=abc", "secret text"} {
if strings.Contains(low, leak) {
t.Fatalf("log leaked %q: %s", leak, line)
}
}
}
func TestAccessLogMiddleware_DisabledIsNoOp(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
srv := &Server{cfg: &config.Config{HTTPAccessLogEnabled: false}}
handler := srv.accessLogMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/health", nil))
if strings.Contains(buf.String(), "http_access") {
t.Fatalf("disabled middleware must not log: %s", buf.String())
}
}
+1 -1
View File
@@ -351,7 +351,7 @@ func (s *Server) Routes() http.Handler {
// 10. 现代 SPA 前端页面与静态资产托管 // 10. 现代 SPA 前端页面与静态资产托管
mux.HandleFunc("GET /", s.serveSPA) mux.HandleFunc("GET /", s.serveSPA)
return s.corsMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux))) return s.corsMiddleware(s.accessLogMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux))))
} }
// Start 启动 HTTP 守护进程 // Start 启动 HTTP 守护进程
+10 -4
View File
@@ -33,11 +33,17 @@ func (s *Server) handleProjectTrace(w http.ResponseWriter, r *http.Request) {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return return
} }
candidates, err := s.rt.Trace.ListCandidateEdges(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{ writeJSONResponse(w, http.StatusOK, map[string]any{
"items": visible, "items": visible,
"hidden_count": hidden, "hidden_count": hidden,
"index": index, "index": index,
"recheck_marks": marks, "recheck_marks": marks,
"candidate_edges": candidates,
}) })
} }
+20
View File
@@ -178,6 +178,26 @@ func (p *pgStore) AddDecision(ctx context.Context, d Decision) (Decision, error)
return d, err return d, err
} }
func (p *pgStore) ListDecisions(ctx context.Context, projectID int64) ([]Decision, error) {
rows, err := p.pool.Query(ctx, `
SELECT id, project_id, edge_id, decision, reason, decided_by
FROM project_trace_edge_decisions WHERE project_id = $1 ORDER BY id
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Decision
for rows.Next() {
var d Decision
if err := rows.Scan(&d.ID, &d.ProjectID, &d.EdgeID, &d.Decision, &d.Reason, &d.DecidedBy); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
func (p *pgStore) PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) { func (p *pgStore) PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) {
if req.ID == 0 { if req.ID == 0 {
err := p.pool.QueryRow(ctx, ` err := p.pool.QueryRow(ctx, `
+96
View File
@@ -303,6 +303,102 @@ func (s *Service) DecideCandidate(ctx context.Context, edgeID, actorID int64, cr
return err return err
} }
// UpsertModelCandidate stores a model_candidate edge that stays out of confirmed walks until decided.
func (s *Service) UpsertModelCandidate(ctx context.Context, projectID int64, fromKey, toKey, evidenceDoc string) (Edge, error) {
if projectID <= 0 || fromKey == "" || toKey == "" {
return Edge{}, fmt.Errorf("traceimpact: project and both keys are required")
}
items, err := s.store.ListItems(ctx, projectID)
if err != nil {
return Edge{}, err
}
var from, to Item
for _, it := range items {
if it.BusinessKey == fromKey {
from = it
}
if it.BusinessKey == toKey {
to = it
}
}
if from.ID == 0 || to.ID == 0 {
return Edge{}, fmt.Errorf("traceimpact: both items must already be indexed")
}
return s.store.UpsertEdge(ctx, Edge{
ProjectID: projectID, FromItemID: from.ID, ToItemID: to.ID,
RelationType: RelationDependsOn, Provenance: ProvenanceModel,
ReviewState: ReviewCandidate, EvidenceDoc: evidenceDoc,
FromKey: from.BusinessKey, ToKey: to.BusinessKey,
})
}
// ListCandidateEdges returns undecided candidate edges with business keys filled in.
func (s *Service) ListCandidateEdges(ctx context.Context, projectID int64) ([]Edge, error) {
edges, err := s.store.ListEdges(ctx, projectID)
if err != nil {
return nil, err
}
items, err := s.store.ListItems(ctx, projectID)
if err != nil {
return nil, err
}
byID := map[int64]Item{}
for _, it := range items {
byID[it.ID] = it
}
var out []Edge
for _, e := range edges {
if e.ReviewState != ReviewCandidate {
continue
}
if e.FromKey == "" {
e.FromKey = byID[e.FromItemID].BusinessKey
}
if e.ToKey == "" {
e.ToKey = byID[e.ToItemID].BusinessKey
}
out = append(out, e)
}
if out == nil {
out = []Edge{}
}
return out, nil
}
// ReplayDecisions re-applies the decision ledger onto edge review_state.
// Latest decision per edge wins. Repair rebuilds must call this so rejected
// candidates cannot re-enter confirmed walks.
func (s *Service) ReplayDecisions(ctx context.Context, projectID int64) (applied int, err error) {
decisions, err := s.store.ListDecisions(ctx, projectID)
if err != nil {
return 0, err
}
latest := map[int64]Decision{}
for _, d := range decisions {
latest[d.EdgeID] = d
}
for edgeID, d := range latest {
review := ReviewConfirmed
if d.Decision == DecisionReject {
review = ReviewRejected
}
if err := s.store.SetEdgeReview(ctx, edgeID, review); err != nil {
return applied, err
}
applied++
}
return applied, nil
}
// ListDecisions returns the authoritative decision ledger for a project.
func (s *Service) ListDecisions(ctx context.Context, projectID int64) ([]Decision, error) {
out, err := s.store.ListDecisions(ctx, projectID)
if err == nil && out == nil {
out = []Decision{}
}
return out, err
}
// NeedsRecheckAck is true when the stage still has an open mark. // NeedsRecheckAck is true when the stage still has an open mark.
// Acknowledgement does not clear the mark. // Acknowledgement does not clear the mark.
func (s *Service) NeedsRecheckAck(ctx context.Context, projectID int64, stageKey string) (bool, error) { func (s *Service) NeedsRecheckAck(ctx context.Context, projectID int64, stageKey string) (bool, error) {
+123
View File
@@ -246,3 +246,126 @@ func TestPropose_UnconfirmedDoesNotStore(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestReplayDecisions_RejectSurvivesProjectionReset(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "down", Markdown: "RS-010 细化需求"}); err != nil {
t.Fatal(err)
}
edge, err := svc.UpsertModelCandidate(ctx, 7, "RS-010", "UR-001", "down")
if err != nil {
t.Fatal(err)
}
if err := svc.DecideCandidate(ctx, edge.ID, 9, false, "down", 9, DecisionReject, "误匹配"); err != nil {
t.Fatal(err)
}
if err := svc.store.SetEdgeReview(ctx, edge.ID, ReviewCandidate); err != nil {
t.Fatal(err)
}
got, err := svc.store.GetEdge(ctx, edge.ID)
if err != nil || got.ReviewState != ReviewCandidate {
t.Fatalf("reset failed: %+v err=%v", got, err)
}
n, err := svc.ReplayDecisions(ctx, 7)
if err != nil || n != 1 {
t.Fatalf("replay applied=%d err=%v", n, err)
}
got, err = svc.store.GetEdge(ctx, edge.ID)
if err != nil || got.ReviewState != ReviewRejected {
t.Fatalf("after replay %+v err=%v", got, err)
}
hits, _ := WalkReverse([]Edge{got}, []int64{got.ToItemID}, 3, 50)
for _, h := range hits {
if h.ItemID == got.FromItemID {
t.Fatal("rejected edge must not be walked")
}
}
}
func TestAnalyze_BoundedMultiHopDepth3(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
docs := []struct {
id, md string
}{
{"d1", "UR-001 根需求"},
{"d2", "RS-002 细化 UR-001"},
{"d3", "REQ-003 依赖 RS-002"},
{"d4", "TC-004 验证 REQ-003"},
{"d5", "TC-005 验证 TC-004"},
}
for _, d := range docs {
if _, err := svc.IndexDocument(ctx, 3, DocRef{DocID: d.id, Markdown: d.md}); err != nil {
t.Fatal(err)
}
}
// IndexDocument only confirms same-line pairs. d5→d4 is not same-line with UR, so chain
// depth-4 must be explicit confirmed edges built for the walk.
items, err := svc.ListItems(ctx, 3)
if err != nil {
t.Fatal(err)
}
byKey := map[string]Item{}
for _, it := range items {
byKey[it.BusinessKey] = it
}
chain := [][2]string{{"RS-002", "UR-001"}, {"REQ-003", "RS-002"}, {"TC-004", "REQ-003"}, {"TC-005", "TC-004"}}
for _, pair := range chain {
if _, err := svc.store.UpsertEdge(ctx, Edge{
ProjectID: 3, FromItemID: byKey[pair[0]].ID, ToItemID: byKey[pair[1]].ID,
RelationType: RelationDependsOn, Provenance: ProvenanceExplicit, ReviewState: ReviewConfirmed,
FromKey: pair[0], ToKey: pair[1],
}); err != nil {
t.Fatal(err)
}
}
rep, err := svc.Analyze(ctx, 3, DocRef{DocID: "d1", Markdown: "UR-001 根需求变更"}, []DocRef{
{DocID: "d2", Visible: true}, {DocID: "d3", Visible: true},
{DocID: "d4", Visible: true}, {DocID: "d5", Visible: true},
})
if err != nil {
t.Fatal(err)
}
depths := map[string]int{}
for _, imp := range rep.Impacts {
if imp.ImpactType == ImpactConfirmed {
depths[imp.ItemKey] = imp.Depth
}
}
if depths["RS-002"] != 1 || depths["REQ-003"] != 2 || depths["TC-004"] != 3 {
t.Fatalf("expected depth 1..3, got %#v impacts=%#v", depths, rep.Impacts)
}
if _, ok := depths["TC-005"]; ok {
t.Fatalf("depth-4 TC-005 must not appear in confirmed impacts: %#v", depths)
}
}
func TestListCandidateEdges_OnlyUndecided(t *testing.T) {
svc := NewMemoryService()
ctx := t.Context()
if _, err := svc.IndexDocument(ctx, 1, DocRef{DocID: "a", Markdown: "UR-001 x"}); err != nil {
t.Fatal(err)
}
if _, err := svc.IndexDocument(ctx, 1, DocRef{DocID: "b", Markdown: "RS-002 y"}); err != nil {
t.Fatal(err)
}
edge, err := svc.UpsertModelCandidate(ctx, 1, "RS-002", "UR-001", "b")
if err != nil {
t.Fatal(err)
}
cands, err := svc.ListCandidateEdges(ctx, 1)
if err != nil || len(cands) != 1 || cands[0].FromKey != "RS-002" {
t.Fatalf("cands=%#v err=%v", cands, err)
}
if err := svc.DecideCandidate(ctx, edge.ID, 1, true, "b", 0, DecisionConfirm, "ok"); err != nil {
t.Fatal(err)
}
cands, err = svc.ListCandidateEdges(ctx, 1)
if err != nil || len(cands) != 0 {
t.Fatalf("after confirm cands=%#v err=%v", cands, err)
}
}
+13
View File
@@ -17,6 +17,7 @@ type Store interface {
ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) ListIndex(ctx context.Context, projectID int64) ([]IndexState, error)
SetEdgeReview(ctx context.Context, edgeID int64, review string) error SetEdgeReview(ctx context.Context, edgeID int64, review string) error
AddDecision(ctx context.Context, d Decision) (Decision, error) AddDecision(ctx context.Context, d Decision) (Decision, error)
ListDecisions(ctx context.Context, projectID int64) ([]Decision, error)
PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error) PutReopen(ctx context.Context, req ReopenRequest) (ReopenRequest, error)
GetReopen(ctx context.Context, id int64) (ReopenRequest, error) GetReopen(ctx context.Context, id int64) (ReopenRequest, error)
PutMark(ctx context.Context, m RecheckMark) (RecheckMark, error) PutMark(ctx context.Context, m RecheckMark) (RecheckMark, error)
@@ -195,6 +196,18 @@ func (m *memStore) AddDecision(_ context.Context, d Decision) (Decision, error)
return d, nil return d, nil
} }
func (m *memStore) ListDecisions(_ context.Context, projectID int64) ([]Decision, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Decision
for _, d := range m.decisions {
if d.ProjectID == projectID {
out = append(out, d)
}
}
return out, nil
}
func (m *memStore) PutReopen(_ context.Context, req ReopenRequest) (ReopenRequest, error) { func (m *memStore) PutReopen(_ context.Context, req ReopenRequest) (ReopenRequest, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()