Merge PR #139: 支持按状态和判定来源筛选审批记录

合并 codex/approval-record-filters,解决与 #133 在
web/src/components/approval-records.tsx 的冲突:

- 导入同时保留 InterceptApprovalFilter 与 InterceptPending。
- load() 采用 #133 的独立待审批队列(Promise.allSettled 拉取
  /intercept/pending),并把 #139 的 filter 参数透传给历史分页调用;
  筛选只作用于历史记录,待处理队列不受 filter 影响,保证跨页/被筛
  记录仍可审批。
- decide() 保留 #133 的乐观更新(移除 pendingRows 对应项)叠加 #139
  决策后重新拉取以对齐筛选后的计数与行。

验证:前端 tsc 通过、Biome 仅剩预存告警;go build/vet 通过;
db TestInterceptApprovalFilters(8 子用例)与 server
TestInterceptFilterHTTP 在本地 dev 库均通过。

Closes #139
This commit is contained in:
Autumn-27 committed 2026-09-16 11:16:26 -04:00
commit 13ce2ea6e3
8 files changed
+436 -45

No files matched your search

+29 -4
View File
@@ -248,7 +248,12 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request)
return
}
q := r.URL.Query()
if q.Get("page") == "" && q.Get("size") == "" {
filter, err := interceptFilterParams(q)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
items, err := pg.ListTaskIntercepts(taskID)
if err != nil {
writeErr(w, 500, err.Error())
@@ -261,7 +266,7 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request)
return
}
page, size := interceptPageParams(q)
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size)
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size, filter)
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -278,7 +283,12 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
return
}
q := r.URL.Query()
if q.Get("page") == "" && q.Get("size") == "" {
filter, err := interceptFilterParams(q)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
items, err := pg.ListAllIntercepts(200)
if err != nil {
writeErr(w, 500, err.Error())
@@ -291,7 +301,7 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
return
}
page, size := interceptPageParams(q)
items, total, err := pg.ListAllInterceptsPage(page, size)
items, total, err := pg.ListAllInterceptsPage(page, size, filter)
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -302,6 +312,21 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
}
func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")}
switch filter.Status {
case "", "pending", "allowed", "denied", "timeout":
default:
return filter, fmt.Errorf("status 必须是 pending、allowed、denied 或 timeout")
}
switch filter.DecisionSource {
case "", "model", "rule", "unknown":
default:
return filter, fmt.Errorf("decision_source 必须是 model、rule 或 unknown")
}
return filter, nil
}
func interceptPageParams(q url.Values) (int, int) {
page := atoiDefault(q.Get("page"), 1)
size := atoiDefault(q.Get("size"), 20)
+105
View File
@@ -0,0 +1,105 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
)
func TestInterceptFilterHTTP(t *testing.T) {
dsn, _, err := db.DSN()
if err != nil {
t.Skip("no test database configured")
}
d, err := db.Open(dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.Close() })
m := &Manager{pg: d, interceptor: intercept.New(d)}
s := &Server{m: m, jwtKey: []byte("approval-filter-test-key")}
h := s.Handler()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
scope := "approval-http-filter"
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id IN ($1,$2)`, scope, scope+"-other") })
_, baseline, err := d.ListAllInterceptsPage(1, 20, db.InterceptApprovalFilter{Status: "denied", DecisionSource: "model"})
if err != nil {
t.Fatal(err)
}
for _, state := range []string{"pending", "allowed", "denied", "timeout"} {
if _, err := d.CreateDecidedIntercept(0, 0, scope, "test", "Bash", []byte(`{}`), state, "[模型] fixture"); err != nil {
t.Fatal(err)
}
}
if _, err := d.CreateDecidedIntercept(0, 0, scope+"-other", "test", "Bash", []byte(`{}`), "denied", "[模型] fixture"); err != nil {
t.Fatal(err)
}
do := func(path string, auth bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
if auth {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
for _, base := range []string{"/api/intercept/history", "/api/intercept/task/" + scope} {
if rec := do(base+"?status=denied&decision_source=model", false); rec.Code != 401 {
t.Fatalf("unprotected list: %d", rec.Code)
}
for _, query := range []string{"?status=invalid", "?decision_source=invalid", "?page=1&status=denied%27+OR+1%3D1--", "?size=20&decision_source=MODEL"} {
if rec := do(base+query, true); rec.Code != 400 {
t.Fatalf("invalid filter %s: %d %s", query, rec.Code, rec.Body.String())
}
}
// Filtering without explicit page parameters must still apply the filters.
rec := do(base+"?status=denied&decision_source=model", true)
var body struct {
Items []db.InterceptApprovalRow `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
}
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
t.Fatalf("list: %d %s", rec.Code, rec.Body.String())
}
want := baseline + 2
if base != "/api/intercept/history" {
want = 1
}
if body.Total != want || body.Page != 1 {
t.Fatalf("filter total=%d want=%d page=%d", body.Total, want, body.Page)
}
for _, r := range body.Items {
if r.Status != "denied" || r.DecisionSource != "model" {
t.Fatalf("nonmatching item: %+v", r)
}
}
}
for _, tc := range []struct {
query string
total, rows, page int
}{
{"", 4, 4, 0},
{"?page=2&size=2&decision_source=model", 4, 2, 2},
{"?page=2&size=1&status=denied&decision_source=model", 1, 0, 2},
{"?status=denied&decision_source=unknown", 0, 0, 1},
{"?page=0&size=0&status=pending", 1, 1, 1},
} {
rec := do("/api/intercept/task/"+scope+tc.query, true)
var body struct {
Items []db.InterceptApprovalRow `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
}
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Total != tc.total || len(body.Items) != tc.rows || body.Page != tc.page {
t.Fatalf("%s: %d %s", tc.query, rec.Code, rec.Body.String())
}
}
}