merge: candidate websysadmin-residual-r2-2026-09-25 into dev
This commit is contained in:
@@ -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` 等其余白名单键(消费方属各自业务模块后续任务)。
|
||||||
@@ -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` 等)消费方属各自业务模块后续任务。
|
||||||
@@ -305,6 +305,22 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
|||||||
}
|
}
|
||||||
if pool != nil {
|
if pool != nil {
|
||||||
rt.Maintenance = maintenance.NewService(maintenance.NewPGStore(pool))
|
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 {
|
if instSvc != nil {
|
||||||
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
|
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ import (
|
|||||||
// HandoffFunc 到点时调用:负责 spawn 公告进程并移交 listener。
|
// HandoffFunc 到点时调用:负责 spawn 公告进程并移交 listener。
|
||||||
type HandoffFunc func(ctx context.Context, w Window) error
|
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 维护窗口调度。
|
// Service 维护窗口调度。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store Store
|
store Store
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
handoff HandoffFunc
|
handoff HandoffFunc
|
||||||
|
site SiteSettingsProvider
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
timer *time.Timer
|
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) {
|
func (s *Service) SetHandoff(fn HandoffFunc) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -46,13 +56,21 @@ type StatusView struct {
|
|||||||
ExpectedHours float64 `json:"expectedHours,omitempty"`
|
ExpectedHours float64 `json:"expectedHours,omitempty"`
|
||||||
WindowID int64 `json:"windowId,omitempty"`
|
WindowID int64 `json:"windowId,omitempty"`
|
||||||
Status string `json:"status"`
|
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) {
|
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)
|
w, err := s.store.GetOpen(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return StatusView{Status: "none"}, nil
|
view := StatusView{Status: "none", Notice: siteNotice, ReadOnly: siteReadOnly}
|
||||||
|
return view, nil
|
||||||
}
|
}
|
||||||
return StatusView{}, err
|
return StatusView{}, err
|
||||||
}
|
}
|
||||||
@@ -64,6 +82,8 @@ func (s *Service) CurrentStatus(ctx context.Context) (StatusView, error) {
|
|||||||
ExpectedHours: hours,
|
ExpectedHours: hours,
|
||||||
WindowID: w.ID,
|
WindowID: w.ID,
|
||||||
Status: w.Status,
|
Status: w.Status,
|
||||||
|
Notice: siteNotice,
|
||||||
|
ReadOnly: siteReadOnly,
|
||||||
}
|
}
|
||||||
switch w.Status {
|
switch w.Status {
|
||||||
case StatusActive:
|
case StatusActive:
|
||||||
@@ -74,6 +94,22 @@ func (s *Service) CurrentStatus(ctx context.Context) (StatusView, error) {
|
|||||||
return view, nil
|
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 {
|
type ScheduleInput struct {
|
||||||
Start time.Time
|
Start time.Time
|
||||||
End time.Time
|
End time.Time
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ func isPublicRoute(method, path string) bool {
|
|||||||
if path == "/" || path == "/health" || path == "/api/v1/health" ||
|
if path == "/" || path == "/health" || path == "/api/v1/health" ||
|
||||||
path == "/ready" || path == "/api/v1/ready" ||
|
path == "/ready" || path == "/api/v1/ready" ||
|
||||||
path == "/api/v1/vault/status" ||
|
path == "/api/v1/vault/status" ||
|
||||||
path == "/api/v1/maintenance/status" {
|
path == "/api/v1/maintenance/status" ||
|
||||||
|
path == "/api/v1/maintenance/site-status" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if method == http.MethodPost && path == "/api/v1/auth/login" {
|
if method == http.MethodPost && path == "/api/v1/auth/login" {
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ func (s *Server) handleMaintenanceStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSONResponse(w, http.StatusOK, view)
|
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) {
|
func (s *Server) handleAdminMaintenanceList(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -344,6 +344,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey)
|
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/status", s.handleMaintenanceStatus)
|
||||||
|
mux.HandleFunc("GET /api/v1/maintenance/site-status", s.handleMaintenanceSiteStatus)
|
||||||
mux.HandleFunc("GET /api/v1/admin/maintenance", s.handleAdminMaintenanceList)
|
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", s.handleAdminMaintenanceSchedule)
|
||||||
mux.HandleFunc("POST /api/v1/admin/maintenance/{id}/cancel", s.handleAdminMaintenanceCancel)
|
mux.HandleFunc("POST /api/v1/admin/maintenance/{id}/cancel", s.handleAdminMaintenanceCancel)
|
||||||
|
|||||||
@@ -214,6 +214,20 @@ func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Settin
|
|||||||
return setting, nil
|
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 解析单个设置的当前值。
|
// resolve 按 DB > env > default 解析单个设置的当前值。
|
||||||
func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error) {
|
func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error) {
|
||||||
setting := Setting{
|
setting := Setting{
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ export const EnvSettingsSection: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{g.items.map((s) => {
|
{g.items.map((s) => {
|
||||||
const draft = drafts[s.key];
|
const draft = drafts[s.key];
|
||||||
const dirty = s.writable && String(draft ?? '') !== '' && (!s.secret || String(draft) !== '');
|
|
||||||
const secretDirty = s.secret && String(draft ?? '').trim() !== '';
|
const secretDirty = s.secret && String(draft ?? '').trim() !== '';
|
||||||
const plainDirty = !s.secret && draft !== s.value;
|
const plainDirty = !s.secret && draft !== s.value;
|
||||||
const canSave = s.writable && (secretDirty || plainDirty);
|
const canSave = s.writable && (secretDirty || plainDirty);
|
||||||
|
|||||||
@@ -39,7 +39,49 @@ export const MaintenanceBanner: React.FC<{ pollMs?: number }> = ({ pollMs = 4500
|
|||||||
};
|
};
|
||||||
}, [pollMs]);
|
}, [pollMs]);
|
||||||
|
|
||||||
if (!status || (!status.scheduled && !status.active)) return null;
|
if (!status) return null;
|
||||||
|
|
||||||
|
// sysadmin 站点开关:ui.read_only_mode → 常驻只读提示条
|
||||||
|
if (status.readOnly) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.14)',
|
||||||
|
borderBottom: '1px solid rgba(59, 130, 246, 0.35)',
|
||||||
|
color: '#1d4ed8',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
系统处于只读模式:写操作入口已暂停,请联系管理员恢复。
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysadmin 站点开关:ui.maintenance_notice 非空 → 常驻公告横幅(无维护窗口也展示)
|
||||||
|
if (!status.scheduled && !status.active && status.notice) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
background: 'rgba(234, 179, 8, 0.16)',
|
||||||
|
borderBottom: '1px solid rgba(234, 179, 8, 0.35)',
|
||||||
|
color: '#a16207',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.notice}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.scheduled && !status.active) return null;
|
||||||
|
|
||||||
const hours = status.expectedHours ? status.expectedHours.toFixed(1) : '?';
|
const hours = status.expectedHours ? status.expectedHours.toFixed(1) : '?';
|
||||||
const text = status.active
|
const text = status.active
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface MaintenanceStatus {
|
|||||||
expectedHours?: number;
|
expectedHours?: number;
|
||||||
windowId?: number;
|
windowId?: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** sysadmin ui.maintenance_notice 生效值;非空时横幅常驻 */
|
||||||
|
notice?: string;
|
||||||
|
/** sysadmin ui.read_only_mode 生效值;true 时前端进入只读提示 */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaintenanceWindow {
|
export interface MaintenanceWindow {
|
||||||
|
|||||||
Reference in New Issue
Block a user