From 914d0aa39d9dd038efd9949758fd61c0cb73d0c3 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 25 Sep 2026 16:15:47 +0000 Subject: [PATCH 1/2] feat(maintenance): wire sysadmin ui.* whitelist settings to public site status - sysadmin.Service.GetSettingValue: whitelist read-through (DB > env > default) - maintenance.Service: SiteSettingsProvider injection; StatusView carries notice/readOnly; new CurrentSiteStatus + GET /api/v1/maintenance/site-status - composition root wires ui.maintenance_notice / ui.read_only_mode into maintenance site settings - MaintenanceBanner: persistent notice banner + read-only mode strip - chore(web): drop dead 'dirty' var in EnvSettingsSection (baseline TS6133 build blocker) - KB ops actions: deferred out-of-repo (shared infra container side); documented in task record --- .../2026-09-25-websysadmin-residual.md | 68 ++++++++++++++ internal/app/app.go | 16 ++++ internal/maintenance/service.go | 38 +++++++- internal/maintenance/site_settings_test.go | 90 +++++++++++++++++++ internal/server/maintenance_handlers.go | 10 +++ internal/server/server.go | 1 + internal/sysadmin/settings.go | 14 +++ .../console/sections/EnvSettingsSection.tsx | 1 - .../maintenance/MaintenanceBanner.tsx | 44 ++++++++- web/src/types/maintenance.ts | 4 + 10 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-25-websysadmin-residual.md create mode 100644 internal/maintenance/site_settings_test.go diff --git a/.doc/ops/short-term/2026-09-25-websysadmin-residual.md b/.doc/ops/short-term/2026-09-25-websysadmin-residual.md new file mode 100644 index 00000000..9d7d3b62 --- /dev/null +++ b/.doc/ops/short-term/2026-09-25-websysadmin-residual.md @@ -0,0 +1,68 @@ +--- +title: "P9: web-sysadmin 残差收口" +task_id: websysadmin-residual-r2-2026-09-25 (r1 cancelled: scope missed internal/app) +status: in_progress +created: 2026-09-25 +updated: 2026-09-25 +owner: cursor-auto +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1" +branch: feature/websysadmin-residual-2026-09-25 +worktree: .worktrees/websysadmin-residual-2026-09-25 +base_dev_sha: "c5027788ea72bc9488247c2c22dc67c511241fd6" +write_scope: + - internal/maintenance/ + - internal/sysadmin/ + - internal/server/ + - internal/app/ + - web/ + - .doc/ops/short-term/ + - .doc/test-reports/ +allowed_environments: [] +head_sha: "" +mutex: web+sysadmin +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: independent maintainer after owner exit +cleanup_trigger: acceptance green + owner exit +remote_sync: not_requested +--- + +# P9: web-sysadmin 残差收口 + +## 用户目标与授权 + +- 用户目标:`/goal 把这些任务都进行规划,并推进至完成` → 总控 P9 批次。 +- 来源:MATRIX `web-sysadmin` 行尾注「剩余 deferred:Playwright 实跑、KB 运维动作、白名单项接消费方」。 +- 交付终点:三项残差处理完毕(做/不做+理由),验收绿。 + +## 残差处置 + +| 残差 | 处置 | 说明 | +|---|---|---| +| 白名单项接消费方 | **本批实施** | `ui.maintenance_notice` + `ui.read_only_mode` 接入维护状态公开端点与前端横幅 | +| Playwright 实跑 | **本批实施(docker 镜像通道)** | 宿主缺 libatk 系统库且无 sudo,改用 `mcr.microsoft.com/playwright` 官方镜像跑 `admin-console.spec.ts`(自带全部依赖) | +| KB 运维动作 | **登记不做** | KB 为纯只读检索客户端(`internal/kb` 无写路径),rebuild/index 属共享基础设施容器(`shuimu-kb-shared`)侧能力,超出本仓边界;一期只读口径维持,管理台 KB 分组补探活刷新即可 | + +## 实施内容(白名单消费方接线) + +1. `sysadmin.Service.GetSettingValue(key)`:白名单 key 只读解析(DB > env > default),供跨模块消费。 +2. `maintenance.Service`:新增 `SiteSettingsProvider` 注入位(装配根从 sysadmin 解析,避免业务包反向依赖);`StatusView` 增加 `notice`/`readOnly` 字段;`CurrentStatus` 每次读取携带站点开关;新增 `CurrentSiteStatus`(无窗口时也可轮询)。 +3. 装配根(`app.go`):`rt.Maintenance.SetSiteSettings(...)` 从 sysadmin 读 `ui.maintenance_notice`/`ui.read_only_mode`。 +4. 新公开端点:`GET /api/v1/maintenance/site-status`(无需登录,同 status 口径)。 +5. 前端 `MaintenanceBanner`:readOnly → 蓝色只读提示条;notice 非空且无窗口 → 黄色常驻公告横幅;类型 `MaintenanceStatus` 补两字段。 +6. 顺手修:`EnvSettingsSection.tsx` 死变量 `dirty`(dev 基线既有 TS6133 构建阻断,单行删除不改行为)。 + +## 验证 + +- Go:`go build ./...` + `go vet` + `go test -short ./internal/maintenance/ ./internal/sysadmin/ ./internal/server/` 全绿(新增 3 个 site settings 用例)。 +- Web:`npm test`(34/34)+ `npm run build` PASS。 +- Playwright:docker 镜像通道跑 `admin-console.spec.ts`(证据见测试报告)。 + +## 非目标 + +- 不做 KB 写路径/重建编排(超边界,见上表)。 +- 不改 `chat.default_session_role` 等其余白名单键(消费方属各自业务模块后续任务)。 diff --git a/internal/app/app.go b/internal/app/app.go index 54f2e597..55d0939b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -305,6 +305,22 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { } if pool != nil { rt.Maintenance = maintenance.NewService(maintenance.NewPGStore(pool)) + if rt.SysAdmin != nil { + // P9 白名单消费方接线:ui.maintenance_notice / ui.read_only_mode + // 经公开维护状态端点生效(横幅常驻 + 前端只读提示)。 + sa := rt.SysAdmin + rt.Maintenance.SetSiteSettings(func(ctx context.Context) (string, bool) { + notice, _ := sa.GetSettingValue(ctx, "ui.maintenance_notice") + noticeStr, _ := notice.(string) + readOnly := false + if v, err := sa.GetSettingValue(ctx, "ui.read_only_mode"); err == nil { + if b, ok := v.(bool); ok { + readOnly = b + } + } + return noticeStr, readOnly + }) + } } if instSvc != nil { instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin}) diff --git a/internal/maintenance/service.go b/internal/maintenance/service.go index 5aa3eca0..38c58805 100644 --- a/internal/maintenance/service.go +++ b/internal/maintenance/service.go @@ -11,11 +11,16 @@ import ( // HandoffFunc 到点时调用:负责 spawn 公告进程并移交 listener。 type HandoffFunc func(ctx context.Context, w Window) error +// SiteSettingsProvider 提供站点级开关(ui.maintenance_notice / ui.read_only_mode +// 的运行时生效值)。由装配根从 sysadmin 设置解析注入;nil 表示未接线。 +type SiteSettingsProvider func(ctx context.Context) (notice string, readOnly bool) + // Service 维护窗口调度。 type Service struct { store Store now func() time.Time handoff HandoffFunc + site SiteSettingsProvider mu sync.Mutex timer *time.Timer @@ -30,6 +35,11 @@ func NewService(store Store) *Service { } } +// SetSiteSettings 注入站点开关提供方(装配根调用)。 +func (s *Service) SetSiteSettings(fn SiteSettingsProvider) { + s.site = fn +} + func (s *Service) SetHandoff(fn HandoffFunc) { s.mu.Lock() defer s.mu.Unlock() @@ -46,13 +56,21 @@ type StatusView struct { ExpectedHours float64 `json:"expectedHours,omitempty"` WindowID int64 `json:"windowId,omitempty"` Status string `json:"status"` + // 站点级开关(sysadmin 白名单 ui.* 生效值)。 + Notice string `json:"notice,omitempty"` // ui.maintenance_notice 非空时横幅常驻展示 + ReadOnly bool `json:"readOnly,omitempty"` // ui.read_only_mode 为 true 时前端进入只读提示 } func (s *Service) CurrentStatus(ctx context.Context) (StatusView, error) { + siteNotice, siteReadOnly := "", false + if s.site != nil { + siteNotice, siteReadOnly = s.site(ctx) + } w, err := s.store.GetOpen(ctx) if err != nil { if errors.Is(err, ErrNotFound) { - return StatusView{Status: "none"}, nil + view := StatusView{Status: "none", Notice: siteNotice, ReadOnly: siteReadOnly} + return view, nil } return StatusView{}, err } @@ -64,6 +82,8 @@ func (s *Service) CurrentStatus(ctx context.Context) (StatusView, error) { ExpectedHours: hours, WindowID: w.ID, Status: w.Status, + Notice: siteNotice, + ReadOnly: siteReadOnly, } switch w.Status { case StatusActive: @@ -74,6 +94,22 @@ func (s *Service) CurrentStatus(ctx context.Context) (StatusView, error) { return view, nil } +// CurrentSiteStatus 返回站点级开关(不依赖维护窗口);site provider 未接线时为零值。 +func (s *Service) CurrentSiteStatus(ctx context.Context) StatusView { + view := StatusView{Status: "none"} + if s == nil || s.site == nil { + return view + } + notice, readOnly := s.site(ctx) + view.Notice = notice + view.ReadOnly = readOnly + if notice != "" { + // notice 自身驱动横幅(非维护窗口),状态标记为 site_notice 以便前端区分。 + view.Status = "site_notice" + } + return view +} + type ScheduleInput struct { Start time.Time End time.Time diff --git a/internal/maintenance/site_settings_test.go b/internal/maintenance/site_settings_test.go new file mode 100644 index 00000000..571e3a3b --- /dev/null +++ b/internal/maintenance/site_settings_test.go @@ -0,0 +1,90 @@ +package maintenance + +import ( + "context" + "errors" + "testing" + "time" +) + +// P9 白名单消费方接线:site provider 注入后 CurrentStatus 无窗口时也返回 notice/readOnly。 +func TestCurrentStatusCarriesSiteSettings(t *testing.T) { + svc := NewService(&fakeStore{err: ErrNotFound}) + svc.SetSiteSettings(func(ctx context.Context) (string, bool) { + return "系统今晚 22:00 例行维护", true + }) + view, err := svc.CurrentStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if view.Status != "none" { + t.Fatalf("status = %s, want none (no window)", view.Status) + } + if view.Notice != "系统今晚 22:00 例行维护" { + t.Fatalf("notice = %q", view.Notice) + } + if !view.ReadOnly { + t.Fatal("readOnly must carry through") + } +} + +// CurrentSiteStatus:provider 返回非空 notice 时状态切 site_notice;未接线为零值。 +func TestCurrentSiteStatus(t *testing.T) { + svc := NewService(&fakeStore{err: ErrNotFound}) + if v := svc.CurrentSiteStatus(context.Background()); v.Status != "none" || v.Notice != "" || v.ReadOnly { + t.Fatalf("unwired provider should zero, got %+v", v) + } + svc.SetSiteSettings(func(ctx context.Context) (string, bool) { return "公告", false }) + v := svc.CurrentSiteStatus(context.Background()) + if v.Status != "site_notice" || v.Notice != "公告" || v.ReadOnly { + t.Fatalf("site status = %+v", v) + } +} + +// 有维护窗口时 notice/readOnly 同样随窗口状态返回。 +func TestCurrentStatusWindowPlusSite(t *testing.T) { + start := time.Now().Add(time.Hour).UTC() + svc := NewService(&fakeStore{w: &Window{ + ID: 7, ScheduledStart: start, ScheduledEnd: start.Add(2 * time.Hour), + Message: "升级", Status: StatusScheduled, + }}) + svc.SetSiteSettings(func(ctx context.Context) (string, bool) { return "横幅公告", false }) + view, err := svc.CurrentStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !view.Scheduled || view.Message != "升级" { + t.Fatalf("window view = %+v", view) + } + if view.Notice != "横幅公告" { + t.Fatalf("notice lost: %+v", view) + } +} + +type fakeStore struct { + w *Window + err error +} + +func (f *fakeStore) GetOpen(ctx context.Context) (*Window, error) { + if f.err != nil { + return nil, f.err + } + return f.w, nil +} +func (f *fakeStore) GetByID(ctx context.Context, id int64) (*Window, error) { + return f.w, nil +} +func (f *fakeStore) Insert(ctx context.Context, w Window) (Window, error) { + return w, nil +} +func (f *fakeStore) UpdateStatus(ctx context.Context, id int64, from, to string) (Window, error) { + if f.w != nil { + f.w.Status = to + return *f.w, nil + } + return Window{}, errors.New("no window") +} +func (f *fakeStore) ListRecent(ctx context.Context, limit int) ([]Window, error) { + return nil, nil +} diff --git a/internal/server/maintenance_handlers.go b/internal/server/maintenance_handlers.go index ed161eff..8d44286b 100644 --- a/internal/server/maintenance_handlers.go +++ b/internal/server/maintenance_handlers.go @@ -32,6 +32,16 @@ func (s *Server) handleMaintenanceStatus(w http.ResponseWriter, r *http.Request) writeJSONResponse(w, http.StatusOK, view) } +// handleMaintenanceSiteStatus 只返回站点级开关(notice / readOnly), +// 供无维护窗口时的横幅与只读提示轮询。 +func (s *Server) handleMaintenanceSiteStatus(w http.ResponseWriter, r *http.Request) { + if s.maintenance() == nil { + writeJSONResponse(w, http.StatusOK, maintenance.StatusView{Status: "none"}) + return + } + writeJSONResponse(w, http.StatusOK, s.maintenance().CurrentSiteStatus(r.Context())) +} + func (s *Server) handleAdminMaintenanceList(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAdmin(w, r); !ok { return diff --git a/internal/server/server.go b/internal/server/server.go index fc30005d..a7eabba8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -344,6 +344,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey) mux.HandleFunc("GET /api/v1/maintenance/status", s.handleMaintenanceStatus) + mux.HandleFunc("GET /api/v1/maintenance/site-status", s.handleMaintenanceSiteStatus) mux.HandleFunc("GET /api/v1/admin/maintenance", s.handleAdminMaintenanceList) mux.HandleFunc("POST /api/v1/admin/maintenance", s.handleAdminMaintenanceSchedule) mux.HandleFunc("POST /api/v1/admin/maintenance/{id}/cancel", s.handleAdminMaintenanceCancel) diff --git a/internal/sysadmin/settings.go b/internal/sysadmin/settings.go index cbdd528d..a4a9352f 100644 --- a/internal/sysadmin/settings.go +++ b/internal/sysadmin/settings.go @@ -214,6 +214,20 @@ func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Settin return setting, nil } +// GetSettingValue 返回白名单 key 的当前生效值(PG 覆盖 → env 覆盖 → 默认)。 +// 供维护横幅 / 只读提示等运行时消费方只读使用;非白名单 key 返回 ErrUnknownSetting。 +func (s *Service) GetSettingValue(ctx context.Context, key string) (any, error) { + spec, ok := s.findSpec(key) + if !ok { + return nil, ErrUnknownSetting + } + setting, err := s.resolve(ctx, spec) + if err != nil { + return nil, err + } + return setting.Value, nil +} + // resolve 按 DB > env > default 解析单个设置的当前值。 func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error) { setting := Setting{ diff --git a/web/src/components/admin/console/sections/EnvSettingsSection.tsx b/web/src/components/admin/console/sections/EnvSettingsSection.tsx index d3194ef4..e337ec7a 100644 --- a/web/src/components/admin/console/sections/EnvSettingsSection.tsx +++ b/web/src/components/admin/console/sections/EnvSettingsSection.tsx @@ -148,7 +148,6 @@ export const EnvSettingsSection: React.FC = () => { {g.items.map((s) => { const draft = drafts[s.key]; - const dirty = s.writable && String(draft ?? '') !== '' && (!s.secret || String(draft) !== ''); const secretDirty = s.secret && String(draft ?? '').trim() !== ''; const plainDirty = !s.secret && draft !== s.value; const canSave = s.writable && (secretDirty || plainDirty); diff --git a/web/src/components/maintenance/MaintenanceBanner.tsx b/web/src/components/maintenance/MaintenanceBanner.tsx index 63d0d223..ab050a64 100644 --- a/web/src/components/maintenance/MaintenanceBanner.tsx +++ b/web/src/components/maintenance/MaintenanceBanner.tsx @@ -39,7 +39,49 @@ export const MaintenanceBanner: React.FC<{ pollMs?: number }> = ({ pollMs = 4500 }; }, [pollMs]); - if (!status || (!status.scheduled && !status.active)) return null; + if (!status) return null; + + // sysadmin 站点开关:ui.read_only_mode → 常驻只读提示条 + if (status.readOnly) { + return ( +
+ 系统处于只读模式:写操作入口已暂停,请联系管理员恢复。 +
+ ); + } + + // sysadmin 站点开关:ui.maintenance_notice 非空 → 常驻公告横幅(无维护窗口也展示) + if (!status.scheduled && !status.active && status.notice) { + return ( +
+ {status.notice} +
+ ); + } + + if (!status.scheduled && !status.active) return null; const hours = status.expectedHours ? status.expectedHours.toFixed(1) : '?'; const text = status.active diff --git a/web/src/types/maintenance.ts b/web/src/types/maintenance.ts index 883cb3d4..8d7f16e6 100644 --- a/web/src/types/maintenance.ts +++ b/web/src/types/maintenance.ts @@ -10,6 +10,10 @@ export interface MaintenanceStatus { expectedHours?: number; windowId?: number; status: string; + /** sysadmin ui.maintenance_notice 生效值;非空时横幅常驻 */ + notice?: string; + /** sysadmin ui.read_only_mode 生效值;true 时前端进入只读提示 */ + readOnly?: boolean; } export interface MaintenanceWindow { From 923da11ab4c62ce0c1656c708903a7849b7c2c38 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 25 Sep 2026 16:32:46 +0000 Subject: [PATCH 2/2] feat(server): public /maintenance/site-status route + tests; P9 evidence - isPublicRoute exempts site-status (anonymous banner polling) - httptest coverage: anonymous 200 + site_notice payload; route table guards - test report + playwright evidence (docker mcr playwright v1.63 noble vs real dev stack, 1 passed) --- ...-25-p9-websysadmin-residual-test-report.md | 80 +++++++++++++++++++ internal/server/auth_middleware.go | 3 +- .../server/maintenance_site_status_test.go | 73 +++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 .doc/test-reports/2026-09-25-p9-websysadmin-residual-test-report.md create mode 100644 internal/server/maintenance_site_status_test.go diff --git a/.doc/test-reports/2026-09-25-p9-websysadmin-residual-test-report.md b/.doc/test-reports/2026-09-25-p9-websysadmin-residual-test-report.md new file mode 100644 index 00000000..f461c14e --- /dev/null +++ b/.doc/test-reports/2026-09-25-p9-websysadmin-residual-test-report.md @@ -0,0 +1,80 @@ +# P9 web-sysadmin 残差收口 测试报告 + +## 一、 测试背景 (Background) + +MATRIX `web-sysadmin` 行尾注三项 deferred:Playwright 实跑、KB 运维动作、白名单项接消费方。总控 P9 批次授权收口。 + +## 二、 测试目标 (Test Objectives) + +1. `ui.maintenance_notice` / `ui.read_only_mode` 白名单设置接运行时消费方(公开状态端点 + 前端横幅)。 +2. admin-console Playwright spec 对真实 dev 栈实跑取证。 +3. KB 运维动作给出做/不做结论与依据。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|---|---| +| OS | Linux 7.0.0-31-generic | +| worktree | `.worktrees/websysadmin-residual-2026-09-25`(base dev `c5027788`) | +| dev 栈 | 本机 `bin/agent-server`(:8080,PG `agent_system_dev@5433` schema 72) | +| Playwright 通道 | docker `mcr.microsoft.com/playwright:v1.63.0-noble`(宿主缺 libatk 且无 sudo,改镜像跑;`--network host` 对真实 dev 栈) | +| 证据目录 | `assets/2026-09-25-websysadmin-residual/` | + +## 四、 预期结果 (Expected Results) + +- 白名单消费方:PUT `ui.maintenance_notice` → 公开 status 返回 notice(本批代码合入后生效;运行中旧版本服务不体现新字段属预期)。 +- Playwright:`admin console smoke` 登录→管理台→8 分组渲染→返回工作台 PASS。 +- 公开路由:`/api/v1/maintenance/site-status` 匿名 200。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 白名单消费方(Go 层) + +- **Input**:`maintenance.NewService(fakeStore{ErrNotFound})` + `SetSiteSettings`(返回 notice/readOnly) +- **Output**:`TestCurrentStatusCarriesSiteSettings`、`TestCurrentSiteStatus`、`TestCurrentStatusWindowPlusSite` 全 PASS(notice/readOnly 贯穿无窗口/有窗口路径) +- **判定**:PASS +- 耗时:<1s + +### 5.2 公开端点 + +- **Input**:`TestMaintenanceSiteStatusPublic` 匿名 GET + `TestIsPublicRouteMaintenanceSiteStatus` 路由豁免表 +- **Output**:均 PASS(200 + `site_notice` 状态 + notice/readOnly 字段) +- **判定**:PASS + +### 5.3 Playwright 实跑(真实 dev 栈) + +- **Input**:docker 镜像内 `node node_modules/playwright/cli.js test e2e/admin-console.spec.ts --project=chromium`,`RUN_UI_ACCEPTANCE=1`,`BASE_URL=http://127.0.0.1:8080`,admin 真实凭据 +- **Output**: + ``` + Running 1 test using 1 worker + ✓ 1 [chromium] › e2e/admin-console.spec.ts:22:1 › admin console smoke: enter, nav renders, exit back to workbench (1.2s) + 1 passed (1.8s) + ``` +- **判定**:PASS(登录→管理台→8 分组→返回工作台全链路真实栈) +- **性能与耗时**:单用例 1.2s +- **证据**:`assets/2026-09-25-websysadmin-residual/playwright-report-main/`、`playwright-output-main/` + +### 5.4 运行中 dev 栈写读回环(旧二进制) + +- **Input**:admin PUT `ui.maintenance_notice` = "P9 验收:系统今晚 23:30 例行维护演练" +- **Output**:200 + 白名单返回更新值(写入生效);旧二进制 `/maintenance/status` 无 notice 字段(新端点未部署,属预期) +- **判定**:PASS(写入路径与既有审计链路正常;新消费端点待 dev 重建后生效) + +### 5.5 KB 运维动作 + +- **结论**:不做(超边界)。`internal/kb` 为纯只读检索客户端,无 rebuild/index 写路径;重建/索引属共享基础设施容器 `shuimu-kb-shared` 侧能力(见 `scripts/migrate_shared_kb.sh` 口径)。管理台 KB 分组维持只读连接拓扑展示。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | +|---|---| +| `go test -short ./internal/server/ ./internal/maintenance/ ./internal/sysadmin/` | PASS(3 包,含 5 个新用例) | +| `cd web && npm test` | PASS 34/34 | +| `cd web && npm run build` | PASS | +| docker Playwright admin-console | PASS 1/1 | +| `go vet`(maintenance/sysadmin/server) | PASS | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- P9 三项残差收口完成:白名单消费方已接线(代码层全绿)、Playwright 实跑取证 PASS、KB 运维登记不做(边界依据明确)。 +- 后续:dev 栈重建后 `site-status` 端点生效,可补一条真实 HTTP notice 展示证据;其余白名单键(`chat.default_session_role` 等)消费方属各自业务模块后续任务。 diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index 898134f3..afb04cd6 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -21,7 +21,8 @@ func isPublicRoute(method, path string) bool { if path == "/" || path == "/health" || path == "/api/v1/health" || path == "/ready" || path == "/api/v1/ready" || path == "/api/v1/vault/status" || - path == "/api/v1/maintenance/status" { + path == "/api/v1/maintenance/status" || + path == "/api/v1/maintenance/site-status" { return true } if method == http.MethodPost && path == "/api/v1/auth/login" { diff --git a/internal/server/maintenance_site_status_test.go b/internal/server/maintenance_site_status_test.go new file mode 100644 index 00000000..6eccb16b --- /dev/null +++ b/internal/server/maintenance_site_status_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/maintenance" +) + +// P9:site-status 为公开端点(登录页横幅轮询),匿名可达且不落鉴权错误。 +func TestMaintenanceSiteStatusPublic(t *testing.T) { + svc := maintenance.NewService(&fakeSiteStore{}) + svc.SetSiteSettings(func(ctx context.Context) (string, bool) { + return "公告内容", true + }) + s := &Server{} + s.setOverrideMaintenance(svc) + + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/maintenance/site-status", s.handleMaintenanceSiteStatus) + ts := httptest.NewServer(mux) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/v1/maintenance/site-status") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var view maintenance.StatusView + if err := json.NewDecoder(resp.Body).Decode(&view); err != nil { + t.Fatal(err) + } + if view.Notice != "公告内容" || !view.ReadOnly || view.Status != "site_notice" { + t.Fatalf("site view = %+v", view) + } +} + +// isPublicRoute 必须豁免 site-status(匿名横幅)。 +func TestIsPublicRouteMaintenanceSiteStatus(t *testing.T) { + if !isPublicRoute(http.MethodGet, "/api/v1/maintenance/site-status") { + t.Fatal("site-status must be public") + } + if !isPublicRoute(http.MethodGet, "/api/v1/maintenance/status") { + t.Fatal("status must stay public") + } + if isPublicRoute(http.MethodGet, "/api/v1/admin/maintenance") { + t.Fatal("admin maintenance must not be public") + } +} + +type fakeSiteStore struct{} + +func (f *fakeSiteStore) GetOpen(ctx context.Context) (*maintenance.Window, error) { + return nil, maintenance.ErrNotFound +} +func (f *fakeSiteStore) GetByID(ctx context.Context, id int64) (*maintenance.Window, error) { + return nil, maintenance.ErrNotFound +} +func (f *fakeSiteStore) Insert(ctx context.Context, w maintenance.Window) (maintenance.Window, error) { + return w, nil +} +func (f *fakeSiteStore) UpdateStatus(ctx context.Context, id int64, from, to string) (maintenance.Window, error) { + return maintenance.Window{}, nil +} +func (f *fakeSiteStore) ListRecent(ctx context.Context, limit int) ([]maintenance.Window, error) { + return nil, nil +}