From 6e67034fbdbf71c98a2be93cd6d39be0ebdae26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Sun, 18 Oct 2026 23:26:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(qa-ops):=20=E4=BA=A4=E4=BB=98=E5=8D=81?= =?UTF-8?q?=E4=B8=80=E9=A1=B9=E7=BB=9F=E8=AE=A1=E7=9C=8B=E6=9D=BF=E4=B8=8E?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=9B=BE=E7=89=87=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以治理库单一只读事务分开聚合累计、自然周、截止月及当前通讯录使用分母;浏览器不再读取全量长答案来构建看板。 新增迁移52与普通worker周质量冻结,保留真实冻结时点、规则和标量贡献;缺少当时快照留空。提交后冻结头与贡献不可修改、删除或追加;只读接口保留管理员、固定租户、严格JSON与输入冲突保护。 已验证:make verify退出0,686个Go用例和305个前端用例通过,必需跳过0;镜像与组织隔离PostgreSQL竞态回归退出0。真实治理副本浏览器核对第41周累计1885与新增471、十一项和完整PNG下载,390px无外溢、JS异常0;明确模拟503后不伪零且重试恢复。没有修改DobotAI原始问答、真实区域规则或生产发布门禁。 --- CHANGELOG.md | 17 + api/openapi.yaml | 1 + cmd/worker/qa_dashboard.go | 23 + cmd/worker/qa_dashboard_test.go | 37 ++ cmd/worker/qa_mirror.go | 2 +- internal/httpapi/qa_dashboard.go | 50 ++ internal/httpapi/qa_dashboard_test.go | 73 +++ internal/httpapi/server.go | 1 + ...0052_qa_ops_weekly_frozen_quality.down.sql | 10 + ...000052_qa_ops_weekly_frozen_quality.up.sql | 50 ++ internal/platform/postgres/migrations_test.go | 4 +- .../qa_weekly_quality_migration_test.go | 33 + internal/qaopsmirror/dashboard.go | 82 +++ internal/qaopsmirror/dashboard_freeze.go | 170 +++++ .../qaopsmirror/dashboard_integration_test.go | 156 +++++ internal/qaopsmirror/dashboard_store.go | 215 +++++++ internal/qaopsmirror/dashboard_test.go | 27 + internal/qaopsorg/attribution.go | 11 + internal/qaopsorg/store_integration_test.go | 13 + internal/qaopsquality/read_views.go | 15 +- web/src/QADashboard.test.tsx | 16 +- web/src/QADashboard.tsx | 246 +++----- web/src/QADashboardDrawing.test.tsx | 33 + web/src/QADashboardDrawing.tsx | 589 ++++++++++++++++++ web/src/QAOperations.tsx | 5 +- web/src/qaDashboard.css | 173 +---- web/src/qaDashboard.fixture.ts | 89 +++ web/src/qaDashboardData.test.ts | 25 + web/src/qaDashboardData.ts | 230 +++++++ web/src/qaDashboardExport.test.ts | 14 + web/src/qaDashboardExport.ts | 48 ++ web/src/qaQuality.ts | 1 + 32 files changed, 2103 insertions(+), 356 deletions(-) create mode 100644 cmd/worker/qa_dashboard.go create mode 100644 cmd/worker/qa_dashboard_test.go create mode 100644 internal/httpapi/qa_dashboard.go create mode 100644 internal/httpapi/qa_dashboard_test.go create mode 100644 internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.down.sql create mode 100644 internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.up.sql create mode 100644 internal/platform/postgres/qa_weekly_quality_migration_test.go create mode 100644 internal/qaopsmirror/dashboard.go create mode 100644 internal/qaopsmirror/dashboard_freeze.go create mode 100644 internal/qaopsmirror/dashboard_integration_test.go create mode 100644 internal/qaopsmirror/dashboard_store.go create mode 100644 internal/qaopsmirror/dashboard_test.go create mode 100644 web/src/QADashboardDrawing.test.tsx create mode 100644 web/src/QADashboardDrawing.tsx create mode 100644 web/src/qaDashboard.fixture.ts create mode 100644 web/src/qaDashboardData.test.ts create mode 100644 web/src/qaDashboardData.ts create mode 100644 web/src/qaDashboardExport.test.ts create mode 100644 web/src/qaDashboardExport.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eef0015..2c1c176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,23 @@ > `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯, > 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。 +## 开发快照 snapshot/v0.1.32 - 2026-10-18 + +### Added + +- 问题统计看板按参考布局提供完整十一项:2.0 累计问题、本周新增、覆盖率与正确率半环、问题类型饼图、产品横条排行、所选自然周逐日折线、区域平均使用频率与使用覆盖率、两条历史质量周趋势。未知分类保留为未分类,未知分母及历史缺失点不伪造为零。 +- 支持导出当前完整看板 PNG,包含所有图形、中文、图例与数值,独立于屏幕滚动高度;不包含导航、筛选控件、按钮或个人月明细。桌面与窄屏共享同一份数据及响应式图形。 +- 治理侧保存自然周质量冻结快照与逐条标量贡献;普通 worker 在符合采集条件的下一周一自动冻结上一周,保存实际时点、源观测、版本和分子分母。首次启用前、错过采集或没有完整观测的历史周保持空白,后续人工改判不回写已冻结比率。 + +### Changed + +- 看板通过单一只读一致性接口聚合治理副本、有效质量与组织版本,不连接源端、不下载全部长答案;累计、所选自然周和截止月分别计算,原问题关键词按字面筛选。区域分母使用配置后的通讯录去重人数,仅用于使用统计,个人月明细与四页共享查询保留。 +- 新增加表迁移 52。应用回退保留加表;发现冻结历史时拒绝有损 down。未接入翻译/AI 评估模型、绩效名单、1.0 归档或自动发送。 + +### Security + +- 统计接口维持固定租户、管理员会话、同源动作头、严格 JSON 和 no-store;输入指纹变化返回冲突,不混合新旧结论。数据库拒绝修改、删除或追加已提交的冻结贡献;浏览器查询不写指标,PNG 使用自包含 SVG,不加载外部图片或执行原始 HTML。 + ## 开发快照 snapshot/v0.1.31 - 2026-10-18 ### Added diff --git a/api/openapi.yaml b/api/openapi.yaml index c1fa454..dde790c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -48,6 +48,7 @@ "get": {"tags":["QA operations"],"operationId":"getQAOrganizationSync","summary":"管理员读取通讯录同步执行器状态及最近任务的安全汇总,不返回人员名单或运行配置","responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"状态存储不可用,不伪装为空记录"}}}, "post": {"tags":["QA operations"],"operationId":"startQAOrganizationSync","summary":"管理员同源发起持久化通讯录同步;重复请求重放同一任务,进行中任务合并;失败保留现有快照","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false}}}},"responses":{"202":{"$ref":"#/components/responses/OperationEnvelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源操作"},"409":{"description":"操作标识与原请求冲突"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"同步未接线、执行器离线或入队失败;未创建新任务"}}} }, + "/api/v1/qa-ops/dashboard": {"post":{"operationId":"readQAOperationsDashboard","tags":["QA operations"],"summary":"管理员只读本地一致性看板:2.0累计、所选周、新增/质量/类别及截止月区域统计;历史质量仅真实冻结快照,缺失留空","description":"同一只读可重复读事务读取副本、有效质量及组织版本。不访问源端,不冻结或写入指标;区域分母为当前配置通讯录去重人数而非在职/绩效名单。冻结记录按保存的原始标量分子分母及问题文字支持字面关键词筛选,不用当前评估回填历史。","parameters":[{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-dashboard"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["start","end"],"properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"},"keyword":{"type":"string","maxLength":200}}}}}},"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源读取"},"409":{"description":"质量输入指纹变化,拒绝混合数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"本地副本或一致性投影不可用,不返回伪零"}}}}, "/api/v1/qa-ops/organization": { "get": { "tags": ["QA operations"], diff --git a/cmd/worker/qa_dashboard.go b/cmd/worker/qa_dashboard.go new file mode 100644 index 0000000..36213f2 --- /dev/null +++ b/cmd/worker/qa_dashboard.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "errors" + "time" +) + +type recordsSyncSchedulerStore interface { + EnqueueDue(context.Context) (bool, error) + TrackQualityWeeks(context.Context, time.Time) error + FreezePreviousQualityWeek(context.Context, time.Time) (bool, error) +} + +func runRecordsSyncCycle(ctx context.Context, store recordsSyncSchedulerStore, at time.Time) error { + _, syncErr := store.EnqueueDue(ctx) + trackingErr := store.TrackQualityWeeks(ctx, at) + if trackingErr != nil { + return errors.Join(syncErr, trackingErr) + } + _, freezeErr := store.FreezePreviousQualityWeek(ctx, at) + return errors.Join(syncErr, freezeErr) +} diff --git a/cmd/worker/qa_dashboard_test.go b/cmd/worker/qa_dashboard_test.go new file mode 100644 index 0000000..4fe9cff --- /dev/null +++ b/cmd/worker/qa_dashboard_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "errors" + "reflect" + "testing" + "time" +) + +type frozenScheduleSpy struct { + calls []string + trackErr error +} + +func (s *frozenScheduleSpy) EnqueueDue(context.Context) (bool, error) { + s.calls = append(s.calls, "sync") + return true, nil +} +func (s *frozenScheduleSpy) TrackQualityWeeks(context.Context, time.Time) error { + s.calls = append(s.calls, "track") + return s.trackErr +} +func (s *frozenScheduleSpy) FreezePreviousQualityWeek(context.Context, time.Time) (bool, error) { + s.calls = append(s.calls, "freeze") + return true, nil +} +func TestRecordsSchedulerActuallyCapturesFrozenWeeklyMetrics(t *testing.T) { + s := &frozenScheduleSpy{} + if e := runRecordsSyncCycle(context.Background(), s, time.Now()); e != nil || !reflect.DeepEqual(s.calls, []string{"sync", "track", "freeze"}) { + t.Fatal("weekly capture is not on the real scheduler invocation path", e, s.calls) + } + broken := &frozenScheduleSpy{trackErr: errors.New("isolated tracking unavailable")} + if e := runRecordsSyncCycle(context.Background(), broken, time.Now()); e == nil || !reflect.DeepEqual(broken.calls, []string{"sync", "track"}) { + t.Fatal("snapshot failure must not suppress source sync or freeze untracked history") + } +} diff --git a/cmd/worker/qa_mirror.go b/cmd/worker/qa_mirror.go index 58e30a4..8e05fe4 100644 --- a/cmd/worker/qa_mirror.go +++ b/cmd/worker/qa_mirror.go @@ -40,7 +40,7 @@ func runRecordsSyncScheduler(ctx context.Context, store *qaopsmirror.Store, onEr tick := time.NewTicker(15 * time.Second) defer tick.Stop() for { - if _, e := store.EnqueueDue(ctx); e != nil && ctx.Err() == nil && onError != nil { + if e := runRecordsSyncCycle(ctx, store, time.Now()); e != nil && ctx.Err() == nil && onError != nil { onError(e) } select { diff --git a/internal/httpapi/qa_dashboard.go b/internal/httpapi/qa_dashboard.go new file mode 100644 index 0000000..f9a161b --- /dev/null +++ b/internal/httpapi/qa_dashboard.go @@ -0,0 +1,50 @@ +package httpapi + +import ( + "context" + "errors" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsmirror" + "knowledge-governance/internal/qaopsquality" + "net/http" + "time" +) + +type qaDashboardReader interface { + ReadDashboard(context.Context, dobotaiops.RecordQuery) (qaopsmirror.Dashboard, error) +} + +func qaOperationsDashboard(w http.ResponseWriter, r *http.Request, c Config) { + if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-dashboard") { + return + } + var request struct { + Start string `json:"start"` + End string `json:"end"` + Keyword string `json:"keyword"` + } + if decodeQualityJSON(w, r, &request, "start", "end") != nil { + qaOpsError(w, dobotaiops.ErrInvalidQuery) + return + } + reader, ok := c.QAOps.(qaDashboardReader) + if !ok || c.QAOpsTenantKey == "" { + qaOpsError(w, dobotaiops.ErrUnavailable) + return + } + window, e := dobotaiops.NewWindow(c.QAOpsTenantKey, request.Start, request.End, 90*24*time.Hour, c.Now()) + if e != nil { + qaOpsError(w, e) + return + } + dashboard, e := reader.ReadDashboard(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: request.Keyword}) + if e != nil { + if errors.Is(e, qaopsquality.ErrConflict) { + writeQualityError(w, e) + return + } + qaOpsError(w, e) + return + } + writeJSON(w, 200, map[string]any{"data": dashboard}) +} diff --git a/internal/httpapi/qa_dashboard_test.go b/internal/httpapi/qa_dashboard_test.go new file mode 100644 index 0000000..810a938 --- /dev/null +++ b/internal/httpapi/qa_dashboard_test.go @@ -0,0 +1,73 @@ +package httpapi + +import ( + "context" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsmirror" + "knowledge-governance/internal/qaopsquality" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type dashboardReaderSpy struct { + attributedReader + calls int + query dobotaiops.RecordQuery + err error +} + +func (s *dashboardReaderSpy) ReadDashboard(_ context.Context, q dobotaiops.RecordQuery) (qaopsmirror.Dashboard, error) { + s.calls++ + s.query = q + return qaopsmirror.Dashboard{TotalIssues: 7, NewThisWeek: 2}, s.err +} +func TestQADashboardIsFixedTenantAdminAndReadOnly(t *testing.T) { + reader := &dashboardReaderSpy{} + auth := newFakeAuthStore() + auth.sessions["dash-admin"] = "usr-boss" + auth.sessions["dash-viewer"] = "usr-eye" + h, e := NewHandler(Config{Store: &fakeJobStore{}, QAOps: reader, QAOpsTenantKey: "fixed-dashboard-tenant", Now: func() time.Time { return time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) }, Auth: &AuthConfig{Store: auth}}) + if e != nil { + t.Fatal(e) + } + good := `{"start":"2026-10-05T00:00:00+08:00","end":"2026-10-12T00:00:00+08:00","keyword":"100%_"}` + for _, c := range []struct { + token, action, origin, body string + status int + }{ + {"", "qa-dashboard", "", good, 401}, {"dash-viewer", "qa-dashboard", "", good, 403}, {"dash-admin", "", "", good, 403}, {"dash-admin", "qa-dashboard", "https://untrusted.invalid", good, 403}, + {"dash-admin", "qa-dashboard", "", `null`, 400}, {"dash-admin", "qa-dashboard", "", `{"start":null,"end":"2026-10-12T00:00:00+08:00"}`, 400}, + {"dash-admin", "qa-dashboard", "", `{"tenant":"other","start":"2026-10-05T00:00:00+08:00","end":"2026-10-12T00:00:00+08:00"}`, 400}, + {"dash-admin", "qa-dashboard", "", good, 200}, + } { + r := httptest.NewRequest(http.MethodPost, "/api/v1/qa-ops/dashboard", strings.NewReader(c.body)) + if c.token != "" { + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: c.token}) + } + r.Header.Set("X-Governance-Action", c.action) + r.Header.Set("Origin", c.origin) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != c.status { + t.Fatalf("dashboard status=%d want=%d", w.Code, c.status) + } + if w.Code == 200 && w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("private dashboard response may be cached") + } + } + if reader.calls != 1 || reader.query.Window.TenantKey != "fixed-dashboard-tenant" || reader.query.Keyword != "100%_" { + t.Fatal("rejected requests reached the reader or browser controlled the tenant") + } + reader.err = qaopsquality.ErrConflict + r := httptest.NewRequest(http.MethodPost, "/api/v1/qa-ops/dashboard", strings.NewReader(good)) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "dash-admin"}) + r.Header.Set("X-Governance-Action", "qa-dashboard") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusConflict { + t.Fatalf("quality input drift status=%d want409", w.Code) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 226494b..179a956 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -177,6 +177,7 @@ func NewHandler(config Config) (http.Handler, error) { config.NewID = newID } mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/qa-ops/dashboard", func(w http.ResponseWriter, r *http.Request) { qaOperationsDashboard(w, r, config) }) mux.HandleFunc("POST /api/v1/qa-ops/quality/query", func(w http.ResponseWriter, r *http.Request) { qaQualityQuery(w, r, config) }) mux.HandleFunc("POST /api/v1/qa-ops/records/{id}/reviews", func(w http.ResponseWriter, r *http.Request) { qaQualityReview(w, r, config) }) mux.HandleFunc("GET /api/v1/qa-ops/records/{id}/quality/history", func(w http.ResponseWriter, r *http.Request) { qaQualityHistory(w, r, config) }) diff --git a/internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.down.sql b/internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.down.sql new file mode 100644 index 0000000..f90f85c --- /dev/null +++ b/internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.down.sql @@ -0,0 +1,10 @@ +DO $$ BEGIN + IF EXISTS(SELECT 1 FROM qa_ops_weekly_quality_snapshots) THEN + RAISE EXCEPTION 'frozen weekly quality history exists; keep additive schema during application rollback'; + END IF; +END $$; +DROP TABLE qa_ops_weekly_quality_snapshot_records; +DROP TABLE qa_ops_weekly_quality_snapshots; +DROP TABLE qa_ops_weekly_quality_tracking; +DROP FUNCTION qa_ops_require_new_weekly_quality_header(); +DROP FUNCTION qa_ops_reject_frozen_quality_mutation(); diff --git a/internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.up.sql b/internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.up.sql new file mode 100644 index 0000000..dd046ac --- /dev/null +++ b/internal/platform/postgres/migrations/000052_qa_ops_weekly_frozen_quality.up.sql @@ -0,0 +1,50 @@ +CREATE TABLE qa_ops_weekly_quality_tracking ( + tenant_key TEXT PRIMARY KEY CHECK(length(tenant_key) BETWEEN 1 AND 128), + started_at TIMESTAMPTZ NOT NULL, first_data_at TIMESTAMPTZ +); +CREATE TABLE qa_ops_weekly_quality_snapshots ( + tenant_key TEXT NOT NULL, week_start TIMESTAMPTZ NOT NULL, week_end TIMESTAMPTZ NOT NULL, + frozen_at TIMESTAMPTZ NOT NULL, mirror_revision BIGINT NOT NULL CHECK(mirror_revision>=0), + source_observed_at TIMESTAMPTZ NOT NULL, policy_version TEXT NOT NULL, + record_count INTEGER NOT NULL CHECK(record_count>=0), + coverage_numerator INTEGER NOT NULL CHECK(coverage_numerator>=0), coverage_denominator INTEGER NOT NULL CHECK(coverage_denominator>=coverage_numerator), + correctness_numerator INTEGER NOT NULL CHECK(correctness_numerator>=0), correctness_denominator INTEGER NOT NULL CHECK(correctness_denominator>=correctness_numerator), + PRIMARY KEY(tenant_key,week_start), CHECK(week_end=week_start+interval '7 days'), + CHECK(date_trunc('day',week_start AT TIME ZONE 'Asia/Shanghai')=week_start AT TIME ZONE 'Asia/Shanghai'), + CHECK(EXTRACT(ISODOW FROM week_start AT TIME ZONE 'Asia/Shanghai')=1), + CHECK(frozen_at>=week_end), CHECK(source_observed_at>=week_end AND source_observed_at<=frozen_at), + CHECK(coverage_denominator<=record_count AND correctness_denominator<=record_count) +); +-- Frozen scalar contributions preserve the original formula under future policy +-- changes and allow literal keyword filtering without recalculating old reviews. +-- No answers, staff IDs, names or organization memberships are duplicated here. +CREATE TABLE qa_ops_weekly_quality_snapshot_records ( + tenant_key TEXT NOT NULL, week_start TIMESTAMPTZ NOT NULL, record_id TEXT NOT NULL, + question TEXT NOT NULL, + coverage_numerator SMALLINT NOT NULL CHECK(coverage_numerator BETWEEN 0 AND 1), + coverage_denominator SMALLINT NOT NULL CHECK(coverage_denominator BETWEEN coverage_numerator AND 1), + correctness_numerator SMALLINT NOT NULL CHECK(correctness_numerator BETWEEN 0 AND 1), + correctness_denominator SMALLINT NOT NULL CHECK(correctness_denominator BETWEEN correctness_numerator AND 1), + PRIMARY KEY(tenant_key,week_start,record_id), + FOREIGN KEY(tenant_key,week_start) REFERENCES qa_ops_weekly_quality_snapshots(tenant_key,week_start) ON DELETE CASCADE +); + +CREATE FUNCTION qa_ops_reject_frozen_quality_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'committed weekly quality snapshots are immutable' USING ERRCODE='55000'; +END $$; +CREATE TRIGGER immutable_weekly_quality_header BEFORE UPDATE OR DELETE ON qa_ops_weekly_quality_snapshots FOR EACH ROW EXECUTE FUNCTION qa_ops_reject_frozen_quality_mutation(); +CREATE TRIGGER immutable_weekly_quality_records BEFORE UPDATE OR DELETE ON qa_ops_weekly_quality_snapshot_records FOR EACH ROW EXECUTE FUNCTION qa_ops_reject_frozen_quality_mutation(); + +-- Contributions may only be inserted in the SAME transaction as their header. +-- This rejects a later append while allowing the initial atomic CopyFrom batch. +CREATE FUNCTION qa_ops_require_new_weekly_quality_header() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE parent_xid bigint; +BEGIN + SELECT xmin::text::bigint INTO parent_xid FROM qa_ops_weekly_quality_snapshots WHERE tenant_key=NEW.tenant_key AND week_start=NEW.week_start; + IF parent_xid IS NULL OR parent_xid <> mod(pg_current_xact_id()::text::numeric,4294967296)::bigint THEN + RAISE EXCEPTION 'cannot append to a committed weekly quality snapshot' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER append_only_new_weekly_quality BEFORE INSERT ON qa_ops_weekly_quality_snapshot_records FOR EACH ROW EXECUTE FUNCTION qa_ops_require_new_weekly_quality_header(); diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index 6ade4f8..334a3b7 100644 --- a/internal/platform/postgres/migrations_test.go +++ b/internal/platform/postgres/migrations_test.go @@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) { if err != nil { t.Fatalf("loadMigrations() error = %v", err) } - if len(migrations) != 51 { - t.Fatalf("migration count = %d, want 51", len(migrations)) + if len(migrations) != 52 { + t.Fatalf("migration count = %d, want 52", len(migrations)) } if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") { t.Fatalf("migration 1 = %#v", migrations[0]) diff --git a/internal/platform/postgres/qa_weekly_quality_migration_test.go b/internal/platform/postgres/qa_weekly_quality_migration_test.go new file mode 100644 index 0000000..dd88994 --- /dev/null +++ b/internal/platform/postgres/qa_weekly_quality_migration_test.go @@ -0,0 +1,33 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestWeeklyQualityMigrationPreservesFrozenHistory(t *testing.T) { + migrations, e := loadMigrations() + if e != nil { + t.Fatal(e) + } + var up, down string + for _, m := range migrations { + if m.Version == 52 { + up = m.UpSQL + down = m.DownSQL + } + } + for _, part := range []string{"CREATE TABLE qa_ops_weekly_quality_tracking", "first_data_at TIMESTAMPTZ", "CREATE TABLE qa_ops_weekly_quality_snapshots", "CREATE TABLE qa_ops_weekly_quality_snapshot_records", "AT TIME ZONE 'Asia/Shanghai'", "BEFORE UPDATE OR DELETE", "BEFORE INSERT", "pg_current_xact_id()", "coverage_denominator<=record_count"} { + if !strings.Contains(up, part) { + t.Fatal("missing frozen weekly quality contract", part) + } + } + if !strings.Contains(down, "IF EXISTS(SELECT 1 FROM qa_ops_weekly_quality_snapshots)") || !strings.Contains(down, "RAISE EXCEPTION") || !strings.Contains(down, "DROP FUNCTION qa_ops_require_new_weekly_quality_header()") { + t.Fatal("lossy history rollback or stale trigger functions") + } + for _, forbidden := range []string{"ALTER TABLE qa_ops_mirror_records", "UPDATE qa_ops_quality_reviews", "answer TEXT", "staff_id TEXT"} { + if strings.Contains(up, forbidden) { + t.Fatal("snapshot migration widened into original records or employee identity") + } + } +} diff --git a/internal/qaopsmirror/dashboard.go b/internal/qaopsmirror/dashboard.go new file mode 100644 index 0000000..72cb18f --- /dev/null +++ b/internal/qaopsmirror/dashboard.go @@ -0,0 +1,82 @@ +package qaopsmirror + +import ( + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsquality" + "time" +) + +type DashboardCount struct { + Label string `json:"label"` + Count int `json:"count"` +} +type DashboardDaily struct { + Date string `json:"date"` + Count int `json:"count"` +} +type DashboardFrozen struct { + WeekStart time.Time `json:"week_start"` + WeekEnd time.Time `json:"week_end"` + FrozenAt *time.Time `json:"frozen_at"` + PolicyVersion string `json:"policy_version"` + Records int `json:"records"` + Coverage qaopsquality.Rate `json:"coverage"` + Correctness qaopsquality.Rate `json:"correctness"` +} +type DashboardRegion struct { + ID string `json:"id"` + Name string `json:"name"` + Questions int `json:"questions"` + Users *int `json:"users"` + RosterPeople *int `json:"roster_people"` + Frequency *float64 `json:"frequency"` + UsageCoverage qaopsquality.Rate `json:"usage_coverage"` +} +type Dashboard struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + Keyword string `json:"keyword"` + AsOf time.Time `json:"as_of"` + LocalRevision int64 `json:"local_revision"` + Organization dobotaiops.OrganizationVersion `json:"organization"` + TotalIssues int `json:"total_issues"` + NewThisWeek int `json:"new_this_week"` + WeekStart time.Time `json:"week_start"` + MonthStart time.Time `json:"month_start"` + Quality qaopsquality.Summary `json:"quality"` + IssueTypes []DashboardCount `json:"issue_types"` + Products []DashboardCount `json:"products"` + Daily []DashboardDaily `json:"daily"` + Regions []DashboardRegion `json:"regions"` + FrozenWeeks []DashboardFrozen `json:"frozen_weeks"` +} + +var dashboardLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +func dashboardDay(at time.Time) time.Time { + t := at.In(dashboardLocation) + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, dashboardLocation) +} +func dashboardWeek(at time.Time) time.Time { + d := dashboardDay(at) + return d.AddDate(0, 0, -((int(d.Weekday()) + 6) % 7)) +} +func dashboardPeriods(end time.Time) (time.Time, time.Time) { + cutoff := end.Add(-time.Nanosecond).In(dashboardLocation) + return time.Date(cutoff.Year(), cutoff.Month(), 1, 0, 0, 0, 0, dashboardLocation), dashboardWeek(cutoff) +} +func dashboardFrozenSeries(first, end time.Time, snapshots map[string]DashboardFrozen) []DashboardFrozen { + out := []DashboardFrozen{} + if first.IsZero() || !first.Before(end) { + return out + } + for at := dashboardWeek(first); at.Before(end) && len(out) < 5200; at = at.AddDate(0, 0, 7) { + key := at.Format("2006-01-02") + point, ok := snapshots[key] + if !ok { + point = DashboardFrozen{WeekStart: at, WeekEnd: at.AddDate(0, 0, 7)} + } + out = append(out, point) + } + return out +} diff --git a/internal/qaopsmirror/dashboard_freeze.go b/internal/qaopsmirror/dashboard_freeze.go new file mode 100644 index 0000000..075c178 --- /dev/null +++ b/internal/qaopsmirror/dashboard_freeze.go @@ -0,0 +1,170 @@ +package qaopsmirror + +import ( + "context" + "github.com/jackc/pgx/v5" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsquality" + "time" +) + +func (s *Store) TrackQualityWeeks(ctx context.Context, at time.Time) error { + if !s.valid() || at.IsZero() { + return dobotaiops.ErrUnavailable + } + _, e := s.pool.Exec(ctx, `INSERT INTO qa_ops_weekly_quality_tracking(tenant_key,started_at,first_data_at) VALUES($1,$2,(SELECT min(asked_at) FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at<=$2)) ON CONFLICT(tenant_key) DO UPDATE SET first_data_at=LEAST(qa_ops_weekly_quality_tracking.first_data_at,EXCLUDED.first_data_at) WHERE EXCLUDED.first_data_at IS NOT NULL AND (qa_ops_weekly_quality_tracking.first_data_at IS NULL OR EXCLUDED.first_data_at=$2 AND asked_at<$3 ORDER BY id LIMIT 100001`, s.tenant, start, end) + if e != nil { + return false, dobotaiops.ErrUnavailable + } + type frozenRecord struct { + id, question, hash string + coverage, correctness qaopsquality.Rate + } + records := []frozenRecord{} + keys := []dobotaiops.QualityKey{} + for rows.Next() { + var r frozenRecord + if rows.Scan(&r.id, &r.question, &r.hash) != nil { + rows.Close() + return false, dobotaiops.ErrUnavailable + } + records = append(records, r) + keys = append(keys, dobotaiops.QualityKey{ID: r.id, InputHash: r.hash}) + if len(records) > dobotaiops.MaxMirrorRecords { + rows.Close() + return false, dobotaiops.ErrInvalidQuery + } + } + e = rows.Err() + rows.Close() + if e != nil { + return false, dobotaiops.ErrUnavailable + } + views := map[string]qaopsquality.View{} + quality := qaopsquality.NewStore(s.pool, s.tenant) + for begin := 0; begin < len(keys); begin += 5000 { + stop := begin + 5000 + if stop > len(keys) { + stop = len(keys) + } + batch, err := quality.ReadViewsInTx(ctx, tx, keys[begin:stop]) + if err != nil { + return false, err + } + for id, v := range batch { + views[id] = v + } + } + rates := qaopsquality.ComputeRates(views) + for i := range records { + r := &records[i] + v, ok := views[r.id] + if !ok { + return false, qaopsquality.ErrUnavailable + } + if v.Automatic.CreatedAt.After(at) || (v.LatestReview != nil && v.LatestReview.CreatedAt.After(at)) { + return false, qaopsquality.ErrConflict + } + one := qaopsquality.ComputeRates(map[string]qaopsquality.View{r.id: v}) + r.coverage = one.Coverage + r.correctness = one.Correctness + } + tag, e := tx.Exec(ctx, `INSERT INTO qa_ops_weekly_quality_snapshots(tenant_key,week_start,week_end,frozen_at,mirror_revision,source_observed_at,policy_version,record_count,coverage_numerator,coverage_denominator,correctness_numerator,correctness_denominator) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(tenant_key,week_start) DO NOTHING`, s.tenant, start, end, at, state.Revision, *state.SourceObservedAt, rates.PolicyVersion, len(records), rates.Coverage.Numerator, rates.Coverage.Denominator, rates.Correctness.Numerator, rates.Correctness.Denominator) + if e != nil || tag.RowsAffected() != 1 { + return false, dobotaiops.ErrUnavailable + } + count, e := tx.CopyFrom(ctx, pgx.Identifier{"qa_ops_weekly_quality_snapshot_records"}, []string{"tenant_key", "week_start", "record_id", "question", "coverage_numerator", "coverage_denominator", "correctness_numerator", "correctness_denominator"}, pgx.CopyFromSlice(len(records), func(i int) ([]any, error) { + r := records[i] + return []any{s.tenant, start, r.id, r.question, r.coverage.Numerator, r.coverage.Denominator, r.correctness.Numerator, r.correctness.Denominator}, nil + })) + if e != nil || count != int64(len(records)) { + return false, dobotaiops.ErrUnavailable + } + var verified int + if tx.QueryRow(ctx, `SELECT count(*) FROM qa_ops_weekly_quality_snapshot_records WHERE tenant_key=$1 AND week_start=$2`, s.tenant, start).Scan(&verified) != nil || verified != len(records) { + return false, dobotaiops.ErrUnavailable + } + if tx.Commit(ctx) != nil { + return false, dobotaiops.ErrUnavailable + } + return true, nil +} + +func (s *Store) readFrozenDashboard(ctx context.Context, tx pgx.Tx, keyword string, end time.Time) (map[string]DashboardFrozen, error) { + rows, e := tx.Query(ctx, `SELECT h.week_start,h.week_end,h.frozen_at,h.policy_version,h.record_count,count(r.record_id),count(r.record_id) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),COALESCE(sum(r.coverage_numerator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0),COALESCE(sum(r.coverage_denominator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0),COALESCE(sum(r.correctness_numerator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0),COALESCE(sum(r.correctness_denominator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0) FROM qa_ops_weekly_quality_snapshots h LEFT JOIN qa_ops_weekly_quality_snapshot_records r ON r.tenant_key=h.tenant_key AND r.week_start=h.week_start WHERE h.tenant_key=$1 AND h.week_start<$3 GROUP BY h.week_start,h.week_end,h.frozen_at,h.policy_version,h.record_count ORDER BY h.week_start`, s.tenant, keyword, end) + if e != nil { + return nil, dobotaiops.ErrUnavailable + } + defer rows.Close() + out := map[string]DashboardFrozen{} + for rows.Next() { + var p DashboardFrozen + var total, actual, cn, cd, an, ad int + if rows.Scan(&p.WeekStart, &p.WeekEnd, &p.FrozenAt, &p.PolicyVersion, &total, &actual, &p.Records, &cn, &cd, &an, &ad) != nil || actual != total || cn > cd || an > ad || cd > p.Records || ad > p.Records { + return nil, dobotaiops.ErrUnavailable + } + p.Coverage = dashboardRate(cn, cd) + p.Correctness = dashboardRate(an, ad) + out[p.WeekStart.In(dashboardLocation).Format("2006-01-02")] = p + } + if rows.Err() != nil { + return nil, dobotaiops.ErrUnavailable + } + return out, nil +} diff --git a/internal/qaopsmirror/dashboard_integration_test.go b/internal/qaopsmirror/dashboard_integration_test.go new file mode 100644 index 0000000..a7d4992 --- /dev/null +++ b/internal/qaopsmirror/dashboard_integration_test.go @@ -0,0 +1,156 @@ +package qaopsmirror + +import ( + "context" + "encoding/json" + "errors" + "github.com/jackc/pgx/v5/pgconn" + "knowledge-governance/internal/dobotaiops" + pg "knowledge-governance/internal/platform/postgres" + "knowledge-governance/internal/qaopsorg" + "knowledge-governance/internal/qaopsquality" + "strings" + "testing" + "time" +) + +func TestDashboardUsesLifetimeWeekMonthAndDeduplicatedRegionalRoster(t *testing.T) { + p := mirrorFixture(t) + ctx := context.Background() + s := NewStore(p, "fixture-dashboard") + queue := pg.NewStore(p) + dates := []string{"2026-09-28T08:00:00+08:00", "2026-10-02T08:00:00+08:00", "2026-10-06T08:00:00+08:00", "2026-10-10T08:00:00+08:00"} + source := &fixtureSource{} + for i, date := range dates { + entry := fixtureEntry([]string{"older", "month", "covered", "uncovered"}[i], "暂无已发布") + var wire map[string]any + _ = json.Unmarshal(entry.Payload, &wire) + wire["asked_at"] = date + wire["question"] = "needle 100%_ fixture" + entry.Payload, _ = json.Marshal(wire) + source.rows = append(source.rows, entry) + } + job := claimMirrorJob(t, s, queue, "dashboard-seed") + if e := s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil { + t.Fatal(e) + } + org := qaopsorg.NewStore(p, s.tenant) + state, e := org.ReplaceDirectory(ctx, qaopsorg.Import{Source: "weekly_report_snapshot", SyncedAt: time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), Complete: true, Directory: qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "示例组织"}}, People: []qaopsorg.Person{{ID: "s", DepartmentIDs: []string{"root"}}, {ID: "zero-use", DepartmentIDs: []string{"root"}}}}}) + if e != nil { + t.Fatal(e) + } + _, e = org.SaveRules(ctx, qaopsorg.SaveRequest{DirectoryID: state.DirectoryID, Regions: []qaopsorg.RegionDefinition{{ID: "education", Name: "国内教育"}}, Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "education"}}}, "fixture-admin") + if e != nil { + t.Fatal(e) + } + record, e := s.GetRecord(ctx, s.tenant, "covered") + if e != nil { + t.Fatal(e) + } + quality := qaopsquality.NewStore(p, s.tenant) + views, e := quality.Observe(ctx, []dobotaiops.Record{record}) + if e != nil { + t.Fatal(e) + } + v := views[record.ID] + _, e = quality.Save(ctx, record, qaopsquality.SaveRequest{InputHash: v.InputHash, InputEpoch: v.InputEpoch, BaseRevision: v.Revision, Nonce: strings.Repeat("d", 32), Fields: qaopsquality.Fields{Coverage: "covered", Correctness: "correct", ProductCategory: "CR", ProblemType: "使用咨询"}, Reason: "explicit isolated test review"}, "fixture-admin") + if e != nil { + t.Fatal(e) + } + start, _ := time.Parse(time.RFC3339, "2026-10-05T00:00:00+08:00") + end := start.AddDate(0, 0, 7) + data, e := s.ReadDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}, Keyword: "100%_"}) + if e != nil { + t.Fatal("dashboard must read one local snapshot without a source client", e) + } + if data.TotalIssues != 4 || data.NewThisWeek != 2 || data.Quality.Total != 2 || data.Quality.Rates.Coverage.Numerator != 1 || data.Quality.Rates.Coverage.Denominator != 2 { + t.Fatal("lifetime/selected-week quality were mixed") + } + if len(data.Daily) != 7 || len(data.Regions) != 1 { + t.Fatal("daily range or custom region catalog missing") + } + r := data.Regions[0] + if r.Questions != 3 || r.Users == nil || *r.Users != 1 || r.RosterPeople == nil || *r.RosterPeople != 2 || r.Frequency == nil || *r.Frequency != 1.5 || r.UsageCoverage.Percent == nil || *r.UsageCoverage.Percent != 50 { + t.Fatal("monthly regional scope or full-roster denominator was replaced by active users") + } + if len(data.FrozenWeeks) != 2 { + t.Fatal("historical weeks did not start at first data week") + } + for _, point := range data.FrozenWeeks { + if point.FrozenAt != nil || point.Coverage.Percent != nil || point.Correctness.Percent != nil { + t.Fatal("current quality was backfilled into absent frozen history") + } + } + + t.Run("historical quality is frozen once and never backfilled", func(t *testing.T) { + tracked := start.AddDate(0, 0, 2) + if e := s.TrackQualityWeeks(ctx, tracked); e != nil { + t.Fatal(e) + } + freezeAt := end.Add(6 * time.Minute) + // Explicit fake-clock fixture: the source snapshot and review existed before the closing capture. + if _, e = p.Exec(ctx, "UPDATE qa_ops_mirror_state SET source_observed_at=$2,synced_at=$2 WHERE tenant_key=$1", s.tenant, freezeAt.Add(-time.Minute)); e != nil { + t.Fatal(e) + } + if _, e = p.Exec(ctx, "UPDATE qa_ops_quality_reviews SET created_at=$2 WHERE tenant_key=$1", s.tenant, end.Add(-time.Hour)); e != nil { + t.Fatal(e) + } + if _, e = p.Exec(ctx, "UPDATE qa_ops_quality_checks SET created_at=$2 WHERE tenant_key=$1", s.tenant, end.Add(-time.Hour)); e != nil { + t.Fatal(e) + } + frozen, e := s.FreezePreviousQualityWeek(ctx, freezeAt) + if e != nil || !frozen { + t.Fatal("first eligible Monday capture was not frozen", e) + } + for _, statement := range []string{ + `UPDATE qa_ops_weekly_quality_snapshots SET policy_version=policy_version||'-changed' WHERE tenant_key=$1`, + `UPDATE qa_ops_weekly_quality_snapshot_records SET question=question||' changed' WHERE tenant_key=$1`, + `DELETE FROM qa_ops_weekly_quality_snapshots WHERE tenant_key=$1`, + `DELETE FROM qa_ops_weekly_quality_snapshot_records WHERE tenant_key=$1`, + `INSERT INTO qa_ops_weekly_quality_snapshot_records SELECT tenant_key,week_start,record_id||'-late',question,coverage_numerator,coverage_denominator,correctness_numerator,correctness_denominator FROM qa_ops_weekly_quality_snapshot_records WHERE tenant_key=$1 LIMIT 1`, + } { + mutation, err := p.Begin(ctx) + if err != nil { + t.Fatal(err) + } + _, err = mutation.Exec(ctx, statement, s.tenant) + _ = mutation.Rollback(ctx) + var dbError *pgconn.PgError + if !errors.As(err, &dbError) || dbError.Code != "55000" { + t.Fatal("committed snapshot mutation was not rejected by the immutable guard", err) + } + } + historical, e := s.ReadDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}, Keyword: "100%_"}) + if e != nil { + t.Fatal(e) + } + last := historical.FrozenWeeks[len(historical.FrozenWeeks)-1] + if last.FrozenAt == nil || last.Coverage.Percent == nil || *last.Coverage.Percent != 50 || last.Correctness.Percent == nil || *last.Correctness.Percent != 100 { + t.Fatal("frozen ratios or literal keyword projection were lost") + } + latest, e := quality.ReadViews(ctx, []dobotaiops.QualityKey{{ID: record.ID, InputHash: v.InputHash}}) + if e != nil { + t.Fatal(e) + } + current := latest[record.ID] + if _, e = quality.Save(ctx, record, qaopsquality.SaveRequest{InputHash: current.InputHash, InputEpoch: current.InputEpoch, BaseRevision: current.Revision, Nonce: strings.Repeat("e", 32), Fields: qaopsquality.Fields{Coverage: "uncovered", Correctness: "incorrect"}, Reason: "later isolated correction, must not rewrite frozen history"}, "fixture-admin"); e != nil { + t.Fatal(e) + } + repeated, e := s.FreezePreviousQualityWeek(ctx, freezeAt.Add(time.Minute)) + if e != nil || repeated { + t.Fatal("immutable weekly snapshot was replaced") + } + changed, e := s.ReadDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}, Keyword: "100%_"}) + if e != nil { + t.Fatal(e) + } + point := changed.FrozenWeeks[len(changed.FrozenWeeks)-1] + if changed.Quality.Rates.Coverage.Numerator != 0 || point.Coverage.Percent == nil || *point.Coverage.Percent != 50 || changed.FrozenWeeks[0].FrozenAt != nil { + t.Fatal("current correction rewrote frozen history or backfilled pre-tracking weeks") + } + }) + raw, _ := json.Marshal(data) + if strings.Contains(string(raw), "zero-use") || strings.Contains(string(raw), "needle") { + t.Fatal("dashboard leaked identity or original question rows") + } +} diff --git a/internal/qaopsmirror/dashboard_store.go b/internal/qaopsmirror/dashboard_store.go new file mode 100644 index 0000000..ed9ab3e --- /dev/null +++ b/internal/qaopsmirror/dashboard_store.go @@ -0,0 +1,215 @@ +package qaopsmirror + +import ( + "context" + "encoding/json" + "github.com/jackc/pgx/v5" + "knowledge-governance/internal/dobotaiops" + "knowledge-governance/internal/qaopsorg" + "knowledge-governance/internal/qaopsquality" + "sort" + "strings" + "time" + "unicode/utf8" +) + +type dashboardRecord struct { + id, hash string + at time.Time + identity dobotaiops.ActorIdentity +} + +func dashboardRate(n, d int) qaopsquality.Rate { + r := qaopsquality.Rate{Numerator: n, Denominator: d} + if d > 0 { + p := 100 * float64(n) / float64(d) + r.Percent = &p + } + return r +} +func dashboardCategories(counts map[string]int) []DashboardCount { + out := []DashboardCount{} + for label, count := range counts { + out = append(out, DashboardCount{Label: label, Count: count}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count == out[j].Count { + return out[i].Label < out[j].Label + } + return out[i].Count > out[j].Count + }) + return out +} +func dashboardCategory(value string) string { + if value = strings.TrimSpace(value); value != "" { + return value + } + return "未分类" +} + +// ReadDashboard never fetches answer payloads or opens a source connection. All +// scopes, quality inputs and organizational attribution use ONE local snapshot. +func (s *Store) ReadDashboard(ctx context.Context, q dobotaiops.RecordQuery) (Dashboard, error) { + out := Dashboard{Start: q.Window.Start, End: q.Window.End, Keyword: q.Keyword, IssueTypes: []DashboardCount{}, Products: []DashboardCount{}, Daily: []DashboardDaily{}, Regions: []DashboardRegion{}, FrozenWeeks: []DashboardFrozen{}} + if !s.valid() { + return out, dobotaiops.ErrUnavailable + } + if q.Window.TenantKey != s.tenant || !utf8.ValidString(q.Keyword) || utf8.RuneCountInString(q.Keyword) > 200 || strings.ContainsRune(q.Keyword, 0) { + return out, dobotaiops.ErrInvalidQuery + } + if _, e := dobotaiops.NewWindow(s.tenant, q.Window.Start.Format(time.RFC3339Nano), q.Window.End.Format(time.RFC3339Nano), 90*24*time.Hour, time.Now()); e != nil { + return out, e + } + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if e != nil { + return out, dobotaiops.ErrUnavailable + } + defer func() { _ = tx.Rollback(context.Background()) }() + state, e := s.readState(ctx, txQuery{tx}) + if e != nil { + return out, e + } + if state.Blocked { + return out, dobotaiops.ErrNotReadOnly + } + if !state.Ready || state.SourceObservedAt == nil { + return out, dobotaiops.ErrUnavailable + } + out.AsOf = *state.SourceObservedAt + out.LocalRevision = state.Revision + attribution, e := qaopsorg.NewStore(s.pool, s.tenant).GetAttributionInTx(ctx, tx) + if e != nil { + return out, e + } + meta := attribution.Metadata + out.Organization = dobotaiops.OrganizationVersion{State: meta.State, DirectoryID: meta.DirectoryID, DirectorySource: meta.DirectorySource, SyncedAt: meta.DirectorySyncedAt, RevisionID: meta.RevisionID, Basis: "current_directory", RegionNames: meta.RegionLabels()} + out.MonthStart, out.WeekStart = dashboardPeriods(q.Window.End) + var first *time.Time + if e = tx.QueryRow(ctx, `SELECT count(*),LEAST((SELECT min(asked_at) FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at<$2),(SELECT first_data_at FROM qa_ops_weekly_quality_tracking WHERE tenant_key=$1),(SELECT min(week_start) FROM qa_ops_weekly_quality_snapshots WHERE tenant_key=$1 AND week_start<$2)) FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at<$2 AND ($3='' OR position(lower($3) IN lower(question))>0)`, s.tenant, q.Window.End, q.Keyword).Scan(&out.TotalIssues, &first); e != nil { + return out, dobotaiops.ErrUnavailable + } + lower := q.Window.Start + if out.MonthStart.Before(lower) { + lower = out.MonthStart + } + if out.WeekStart.Before(lower) { + lower = out.WeekStart + } + rows, e := tx.Query(ctx, `SELECT id,asked_at,identity,quality_hash FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at>=$2 AND asked_at<$3 AND ($4='' OR position(lower($4) IN lower(question))>0) ORDER BY id LIMIT 100001`, s.tenant, lower, q.Window.End, q.Keyword) + if e != nil { + return out, dobotaiops.ErrUnavailable + } + records := []dashboardRecord{} + keys := []dobotaiops.QualityKey{} + for rows.Next() { + var r dashboardRecord + var identity []byte + if rows.Scan(&r.id, &r.at, &identity, &r.hash) != nil || json.Unmarshal(identity, &r.identity) != nil { + rows.Close() + return out, dobotaiops.ErrUnavailable + } + records = append(records, r) + if len(records) > dobotaiops.MaxMirrorRecords { + rows.Close() + return out, dobotaiops.ErrInvalidQuery + } + if !r.at.Before(q.Window.Start) { + keys = append(keys, dobotaiops.QualityKey{ID: r.id, InputHash: r.hash}) + } + } + e = rows.Err() + rows.Close() + if e != nil { + return out, dobotaiops.ErrUnavailable + } + views := map[string]qaopsquality.View{} + quality := qaopsquality.NewStore(s.pool, s.tenant) + for begin := 0; begin < len(keys); begin += 5000 { + end := begin + 5000 + if end > len(keys) { + end = len(keys) + } + batch, err := quality.ReadViewsInTx(ctx, tx, keys[begin:end]) + if err != nil { + return out, err + } + for id, v := range batch { + views[id] = v + } + } + out.Quality = qaopsquality.Summarize(views) + products, types, daily := map[string]int{}, map[string]int{}, map[string]int{} + for day := out.WeekStart; day.Before(q.Window.End); day = day.AddDate(0, 0, 1) { + daily[day.Format("2006-01-02")] = 0 + } + regionIndex := map[string]int{} + users := map[string]map[string]bool{} + for _, region := range meta.RegionDefinitions() { + r := DashboardRegion{ID: region.ID, Name: region.Name} + if meta.State == "ready" { + n := meta.View.Regions[region.ID] + r.RosterPeople = &n + r.Users = new(int) + } + regionIndex[region.Name] = len(out.Regions) + users[region.Name] = map[string]bool{} + out.Regions = append(out.Regions, r) + } + for _, r := range records { + if !r.at.Before(out.WeekStart) { + out.NewThisWeek++ + daily[r.at.In(dashboardLocation).Format("2006-01-02")]++ + } + if !r.at.Before(q.Window.Start) { + v, ok := views[r.id] + if !ok { + return out, qaopsquality.ErrUnavailable + } + products[dashboardCategory(v.Effective.ProductCategory)]++ + types[dashboardCategory(v.Effective.ProblemType)]++ + } + if !r.at.Before(out.MonthStart) { + person := attribution.Resolve(r.identity.StaffID, r.identity.UnionID) + if index, ok := regionIndex[person.Region]; ok && person.PersonID != "" { + out.Regions[index].Questions++ + users[person.Region][person.PersonID] = true + } + } + } + out.Products = dashboardCategories(products) + out.IssueTypes = dashboardCategories(types) + for day := out.WeekStart; day.Before(q.Window.End); day = day.AddDate(0, 0, 1) { + date := day.Format("2006-01-02") + out.Daily = append(out.Daily, DashboardDaily{Date: date, Count: daily[date]}) + } + for i := range out.Regions { + r := &out.Regions[i] + if r.Users != nil { + *r.Users = len(users[r.Name]) + if *r.Users > *r.RosterPeople { + return out, dobotaiops.ErrUnavailable + } + r.UsageCoverage = dashboardRate(*r.Users, *r.RosterPeople) + if *r.RosterPeople > 0 { + frequency := float64(r.Questions) / float64(*r.RosterPeople) + r.Frequency = &frequency + } + } + } + if first != nil { + snapshots, err := s.readFrozenDashboard(ctx, tx, q.Keyword, q.Window.End) + if err != nil { + return out, err + } + out.FrozenWeeks = dashboardFrozenSeries(*first, q.Window.End, snapshots) + if len(out.FrozenWeeks) > 0 && out.FrozenWeeks[len(out.FrozenWeeks)-1].WeekEnd.Before(q.Window.End) { + return out, dobotaiops.ErrInvalidQuery + } + } + if tx.Commit(ctx) != nil { + return out, dobotaiops.ErrUnavailable + } + return out, nil +} diff --git a/internal/qaopsmirror/dashboard_test.go b/internal/qaopsmirror/dashboard_test.go new file mode 100644 index 0000000..d872666 --- /dev/null +++ b/internal/qaopsmirror/dashboard_test.go @@ -0,0 +1,27 @@ +package qaopsmirror + +import ( + "testing" + "time" +) + +func TestDashboardPeriodsUseBeijingAndPreviousSundayAtExclusiveEnd(t *testing.T) { + end := time.Date(2026, 11, 2, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)) + month, week := dashboardPeriods(end) + if month.Format(time.RFC3339) != "2026-11-01T00:00:00+08:00" || week.Format(time.RFC3339) != "2026-10-26T00:00:00+08:00" { + t.Fatal("exclusive Monday end was assigned to the wrong source week/month") + } +} +func TestDashboardFrozenGapsNeverUseCurrentRates(t *testing.T) { + start := time.Date(2026, 9, 28, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)) + end := start.AddDate(0, 0, 21) + points := dashboardFrozenSeries(start, end, nil) + if len(points) != 3 { + t.Fatal("must include each source natural week including missing snapshots") + } + for _, point := range points { + if point.FrozenAt != nil || point.Coverage.Percent != nil || point.Correctness.Percent != nil { + t.Fatal("missing historical snapshots were fabricated from current data") + } + } +} diff --git a/internal/qaopsorg/attribution.go b/internal/qaopsorg/attribution.go index 196a3d1..97c7ead 100644 --- a/internal/qaopsorg/attribution.go +++ b/internal/qaopsorg/attribution.go @@ -2,6 +2,7 @@ package qaopsorg import ( "context" + "github.com/jackc/pgx/v5" "sort" ) @@ -111,6 +112,16 @@ func (p *Attribution) Resolve(staff, union string) PersonAttribution { } return none } +func (s *Store) GetAttributionInTx(ctx context.Context, tx pgx.Tx) (*Attribution, error) { + if !s.available() { + return nil, ErrUnavailable + } + state, directory, err := s.read(ctx, tx, false) + if err != nil { + return nil, err + } + return BuildAttribution(state, directory) +} func (s *Store) GetAttribution(ctx context.Context) (*Attribution, error) { if !s.available() { return nil, ErrUnavailable diff --git a/internal/qaopsorg/store_integration_test.go b/internal/qaopsorg/store_integration_test.go index de32740..cca04bd 100644 --- a/internal/qaopsorg/store_integration_test.go +++ b/internal/qaopsorg/store_integration_test.go @@ -181,6 +181,19 @@ func TestOrganizationStoreRoundTripAndOptimisticConflict(t *testing.T) { if e != nil || repeated.RevisionID != renamed.RevisionID { t.Fatal("unchanged catalog appended another revision") } + // Pin the catalog down test to migration 51, not a later additive feature. + for { + var latest int64 + if err := pool.QueryRow(ctx, "SELECT max(version) FROM schema_migrations").Scan(&latest); err != nil { + t.Fatal(err) + } + if latest <= 51 { + break + } + if err := postgresstore.RollbackLatestMigration(ctx, pool); err != nil { + t.Fatal(err) + } + } if e = postgresstore.RollbackLatestMigration(ctx, pool); e == nil { t.Fatal("down migration discarded a custom catalog") } diff --git a/internal/qaopsquality/read_views.go b/internal/qaopsquality/read_views.go index 72604dc..6002c07 100644 --- a/internal/qaopsquality/read_views.go +++ b/internal/qaopsquality/read_views.go @@ -3,12 +3,25 @@ package qaopsquality import ( "context" "encoding/json" + "github.com/jackc/pgx/v5" "knowledge-governance/internal/dobotaiops" ) // ReadViews projects quality already observed by mirror activation. It performs // one SELECT, never inserts inputs/checks or advances an observation epoch. +type viewQuerier interface { + Query(context.Context, string, ...any) (pgx.Rows, error) +} + func (s *Store) ReadViews(ctx context.Context, keys []dobotaiops.QualityKey) (map[string]View, error) { + return s.readViews(ctx, s.pool, keys) +} + +// ReadViewsInTx shares the caller's repeatable-read snapshot with mirror and organization metadata. +func (s *Store) ReadViewsInTx(ctx context.Context, tx pgx.Tx, keys []dobotaiops.QualityKey) (map[string]View, error) { + return s.readViews(ctx, tx, keys) +} +func (s *Store) readViews(ctx context.Context, q viewQuerier, keys []dobotaiops.QualityKey) (map[string]View, error) { if !s.available() { return nil, ErrUnavailable } @@ -34,7 +47,7 @@ func (s *Store) ReadViews(ctx context.Context, keys []dobotaiops.QualityKey) (ma if len(ids) == 0 { return out, nil } - rows, e := s.pool.Query(ctx, `SELECT h.record_id,h.input_hash,h.input_epoch,h.review_revision,`+checkObject+`::text,COALESCE((SELECT `+reviewObject+` FROM qa_ops_quality_reviews r WHERE r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision),'null'::jsonb)::text FROM qa_ops_quality_records h JOIN qa_ops_quality_inputs i ON i.tenant_key=h.tenant_key AND i.record_id=h.record_id AND i.input_epoch=h.input_epoch JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version=$3 WHERE h.tenant_key=$1 AND h.record_id=ANY($2::text[])`, s.tenant, ids, dobotaiops.CoverageRuleVersion) + rows, e := q.Query(ctx, `SELECT h.record_id,h.input_hash,h.input_epoch,h.review_revision,`+checkObject+`::text,COALESCE((SELECT `+reviewObject+` FROM qa_ops_quality_reviews r WHERE r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision),'null'::jsonb)::text FROM qa_ops_quality_records h JOIN qa_ops_quality_inputs i ON i.tenant_key=h.tenant_key AND i.record_id=h.record_id AND i.input_epoch=h.input_epoch JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version=$3 WHERE h.tenant_key=$1 AND h.record_id=ANY($2::text[])`, s.tenant, ids, dobotaiops.CoverageRuleVersion) if e != nil { return nil, ErrUnavailable } diff --git a/web/src/QADashboard.test.tsx b/web/src/QADashboard.test.tsx index c7d5634..dc7c6d9 100644 --- a/web/src/QADashboard.test.tsx +++ b/web/src/QADashboard.test.tsx @@ -2,6 +2,8 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, it, expect, vi } from 'vitest' import { qaDailySeries, qaCategorySeries } from './qaDashboard' import { QADashboardView } from './QADashboard' +import { parseQADashboard } from './qaDashboardData' +import { dashboardFixture } from './qaDashboard.fixture' import type { QARecord, QAQuery } from './qaOps' const query: QAQuery = { start: '2026-10-11T16:00:00Z', end: '2026-10-13T16:00:00Z', keyword: '', cursor: '' } const records = [ @@ -24,19 +26,11 @@ describe('question dashboard projections', () => { }) it('renders the real dashboard panels and escaped category text', () => { const html = renderToStaticMarkup( - + ) - for (const title of ['问题总量', '每日提问趋势', '质量分布', '区域分布', '产品分类', '问题类型']) + for (const title of ['问题总数', '本周新增', '覆盖率趋势', '正确率趋势', '产品问题排行', '导出看板图片']) expect(html).toContain(title) expect(html).not.toContain(' void } -const percent = (r: QualityRate | undefined) => (r?.percent == null ? '—' : `${r.percent.toFixed(1)}%`) -function Bars({ points }: { points: QACountPoint[] }) { - const max = Math.max(1, ...points.map((p) => p.count)) - return points.length ? ( -
- {points.map((p) => ( -
- {p.label} -
- -
- {p.count} -
- ))} -
- ) : ( -

暂无数据

- ) -} -export function QADashboardView(p: QADashboardProps) { - const page = p.loading ? null : p.page, - items = page?.items || [], - q = page && p.quality?.summary.total === page.total ? p.quality : null - const b = q?.summary.rates.breakdown - const daily = page && p.query ? qaDailySeries(items, p.query) : [] - const max = Math.max(1, ...daily.map((d) => d.count)) - const outcomes = b - ? [ - ['已覆盖', b.covered], - ['未覆盖', b.uncovered], - ['部分覆盖', b.partial], - ['待确认', b.pending], - ['未评估', b.unassessed], - ['不计入', b.excluded], - ['待重新核对', b.stale], - ].map(([label, count]) => ({ label: String(label), count: Number(count) })) - : [] +export function QADashboardView({ data, loading, error, onRetry }: QADashboardProps) { + const host = useRef(null), + svg = useRef(null) + const [width, setWidth] = useState(1280), + [exporting, setExporting] = useState(false), + [exportError, setExportError] = useState('') + useEffect(() => { + const element = host.current + if (!element) return + const update = () => setWidth(Math.max(240, Math.floor(element.clientWidth))) + update() + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, []) + useEffect(() => setExportError(''), [data]) + async function download() { + if (!svg.current || !data || loading || error || exporting) return + setExporting(true) + setExportError('') + try { + const blob = await createDashboardPNG(svg.current), + url = URL.createObjectURL(blob), + link = document.createElement('a'), + week = currentQAWeek(new Date(data.week_start)) + link.href = url + link.download = `DobotAI_QA_Dashboard_${week.year}_W${String(week.week).padStart(2, '0')}.png` + document.body.append(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 1000) + } catch (e) { + setExportError(e instanceof Error ? e.message : '看板图片生成失败') + } finally { + setExporting(false) + } + } return (
- {p.error ? ( +
+ +
+ {error ? (
- {p.error}{' '} -
) : null} - {p.loading ?

正在读取完整统计范围…

: null} -
-
- 问题总量 - {page?.total ?? '—'} - 条 + {exportError ? ( +
+ {exportError}
-
- 知识覆盖率 - {percent(q?.summary.rates.coverage)} - - {q ? `${q.summary.rates.coverage.numerator} / ${q.summary.rates.coverage.denominator}` : '分子 / 分母'} - -
-
- 回答正确率 - {percent(q?.summary.rates.correctness)} - - {q - ? `${q.summary.rates.correctness.numerator} / ${q.summary.rates.correctness.denominator}` - : '分子 / 分母'} - -
-
- 已人工复核 - {q?.summary.reviewed ?? '—'} - {q ? `待复核 ${q.summary.unreviewed}` : '条'} -
-
-
-
-

每日提问趋势

- {daily.length ? ( -
- {daily.map((d) => ( -
- {d.count} -
- -
- {d.label.slice(5)} -
- ))} -
- ) : ( -

暂无数据

- )} -
-
-

质量分布

- -
-
-

区域分布

- r.questions > 0) - .map((r) => ({ label: usageLabels[r.key] || r.key, count: r.questions })) || [] - } - /> - {p.usage ? ( -
- - - - - - - - - - {p.usage.rows - .filter((r) => r.questions > 0 || r.roster_people !== null) - .map((r) => ( - - - - - - ))} - -
区域提问量已关联使用人数
{usageLabels[r.key] || r.key}{r.questions}{r.users ?? '—'}
-
- ) : null} -
-
-

问题类型

- -
-
-

产品分类

- -
+ ) : null} + {loading ?

正在读取完整统计范围…

: null} +
+ {!loading && !error && data ? : null}
) } export function QADashboard({ - page, query, - loading, error, revision, onRetry, }: { - page: QARecordPage | null query: QAQuery | null - loading: boolean error: string | null revision: number onRetry: () => void }) { - const [quality, setQuality] = useState(null), - [usage, setUsage] = useState(null), + const [data, setData] = useState(null), [failure, setFailure] = useState(''), + [loading, setLoading] = useState(false), [monthly, setMonthly] = useState(false) useEffect(() => { - setQuality(null) - setUsage(null) + setData(null) setFailure('') - if (!query) return + if (!query) { + setLoading(false) + return + } const abort = new AbortController() - void fetchQADashboardQuality(query, abort.signal) + setLoading(true) + void fetchQADashboard(query, abort.signal) .then((v) => { - if (!abort.signal.aborted) setQuality(v) + if (!abort.signal.aborted) setData(v) }) .catch((e) => { - if (!abort.signal.aborted) setFailure(e instanceof Error ? e.message : '质量统计读取失败') + if (!abort.signal.aborted) setFailure(e instanceof Error ? e.message : '统计看板读取失败') }) - void fetchQAUsage(query, abort.signal) - .then((v) => { - if (!abort.signal.aborted) setUsage(v) - }) - .catch((e) => { - if (!abort.signal.aborted) setFailure(e instanceof Error ? e.message : '区域统计读取失败') + .finally(() => { + if (!abort.signal.aborted) setLoading(false) }) return () => abort.abort() }, [query, revision]) - const mismatch = !!page && !!quality && page.total !== quality.summary.total return (
- +
setMonthly(e.currentTarget.open)}> 个人月明细 {monthly ? : null} diff --git a/web/src/QADashboardDrawing.test.tsx b/web/src/QADashboardDrawing.test.tsx new file mode 100644 index 0000000..aa25cd4 --- /dev/null +++ b/web/src/QADashboardDrawing.test.tsx @@ -0,0 +1,33 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { it, expect } from 'vitest' +import { QADashboardDrawing } from './QADashboardDrawing' +import { dashboardFixture } from './qaDashboard.fixture' +import { parseQADashboard } from './qaDashboardData' +it('draws all eleven panels as a self-contained exportable SVG without HTML execution', () => { + const html = renderToStaticMarkup() + expect((html.match(/data-panel=/g) || []).length).toBe(11) + for (const title of [ + '问题总数', + '本周新增', + '覆盖率', + '正确率', + '问题类型分布', + '产品问题排行', + '问题趋势', + '区域平均使用频率', + '区域使用覆盖率', + '覆盖率趋势', + '正确率趋势', + ]) + expect(html).toContain(title) + expect(html).toContain('<img src=x') + expect(html).not.toContain(' { + const html = renderToStaticMarkup() + expect((html.match(/data-panel=/g) || []).length).toBe(11) + expect(html).toContain('viewBox="0 0 350 ') +}) diff --git a/web/src/QADashboardDrawing.tsx b/web/src/QADashboardDrawing.tsx new file mode 100644 index 0000000..360c6f1 --- /dev/null +++ b/web/src/QADashboardDrawing.tsx @@ -0,0 +1,589 @@ +import type { ReactNode, Ref } from 'react' +import type { QADashboardData, DashboardCount, DashboardRegion } from './qaDashboardData' +import type { QualityRate } from './qaQuality' +import { currentQAWeek } from './qaWeeks' +interface Box { + x: number + y: number + w: number + h: number +} +const blue = '#3274e6', + ink = '#23344d', + muted = '#7a8799', + line = '#eaf0f7' +const colors = [blue, '#59a9f3', '#69c6bc', '#f3c25a', '#f39b6c', '#9b8ade', '#86b8a1', '#d6a3cf', '#8aacc9', '#c4cb76'] +const number = (n: number) => new Intl.NumberFormat('zh-CN').format(n) +const pct = (n: number | null) => (n === null ? '—' : `${n.toFixed(1)}%`) +function wrap(value: string, width: number, size = 12): string[] { + const max = Math.max(2, width / size), + out: string[] = [] + let row = '', + units = 0 + for (const c of Array.from(value)) { + const next = c.charCodeAt(0) < 128 ? 0.57 : 1 + if (row && units + next > max) { + out.push(row) + row = '' + units = 0 + } + row += c + units += next + } + if (row || !out.length) out.push(row) + return out +} +function Text({ + value, + x, + y, + width, + size = 12, + fill = ink, + weight = 400, + anchor = 'start', +}: { + value: string + x: number + y: number + width: number + size?: number + fill?: string + weight?: number + anchor?: 'start' | 'middle' | 'end' +}) { + return ( + + {wrap(value, width, size).map((row, i) => ( + + {row} + + ))} + + ) +} +function Panel({ + box, + title, + en, + id, + children, + tint = '#fff', +}: { + box: Box + title: string + en: string + id: string + children: ReactNode + tint?: string +}) { + return ( + + + + {title} + + + {en} + + {children} + + ) +} +function Metric({ + box, + value, + title, + en, + id, + tint, + color, + subtitle, +}: { + box: Box + value: number + title: string + en: string + id: string + tint: string + color: string + subtitle: string +}) { + const text = number(value), + size = Math.min(box.w < 220 ? 40 : 54, (box.w - 32) / (text.length * 0.62)) + return ( + + + {text} + + + {subtitle} + + + ) +} +function polar(cx: number, cy: number, r: number, degrees: number) { + const a = (degrees * Math.PI) / 180 + return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) } +} +function arc(cx: number, cy: number, r: number, start: number, end: number) { + const a = polar(cx, cy, r, start), + b = polar(cx, cy, r, end) + return `M ${a.x} ${a.y} A ${r} ${r} 0 ${end - start > 180 ? 1 : 0} 1 ${b.x} ${b.y}` +} +function Gauge({ box, rate, title, en, id }: { box: Box; rate: QualityRate; title: string; en: string; id: string }) { + const r = Math.min(42, box.w * 0.27), + cx = box.w / 2, + cy = 94, + d = arc(cx, cy, r, 135, 405) + return ( + + {`${title}:${rate.numerator} / ${rate.denominator};分母为有效已评估记录`} + + {rate.percent !== null && rate.percent > 0 ? ( + + ) : null} + + {pct(rate.percent)} + + + {rate.denominator ? `${number(rate.numerator)} / ${number(rate.denominator)}` : '暂无有效分母'} + + + ) +} +function pieHeight(points: DashboardCount[], w: number) { + const mobile = w < 500, + legendW = mobile ? w - 60 : w * 0.45 + return Math.max( + mobile ? 270 : 300, + (mobile ? 225 : 65) + points.reduce((n, p) => n + Math.max(24, wrap(p.label, legendW - 85).length * 16 + 9), 0) + ) +} +function Pie({ box, points }: { box: Box; points: DashboardCount[] }) { + const mobile = box.w < 500, + total = points.reduce((n, p) => n + p.count, 0), + legendW = mobile ? box.w - 32 : box.w * 0.47, + cx = mobile ? box.w / 2 : box.w * 0.76, + cy = mobile ? 135 : Math.max(155, box.h / 2), + radius = Math.min(mobile ? 73 : 112, box.w * (mobile ? 0.25 : 0.21)) + let angle = -90, + legendY = mobile ? 236 : 76 + return ( + + {!total ? ( + + 暂无分类记录 + + ) : null} + {points.map((point, i) => { + const start = angle + angle += (point.count / total) * 360 + const a = polar(cx, cy, radius, start), + b = polar(cx, cy, radius, angle) + const y = legendY + legendY += Math.max(24, wrap(point.label, legendW - 85).length * 16 + 9) + return ( + + {points.length === 1 ? ( + + ) : ( + 180 ? 1 : 0} 1 ${b.x} ${b.y} Z`} + fill={colors[i % colors.length]} + stroke="#fff" + strokeWidth={1} + /> + )} + {`${point.label}:${point.count}(${((point.count / total) * 100).toFixed(1)}%)`} + + + + {number(point.count)} + + + ) + })} + + ) +} +function productRows(points: DashboardCount[], w: number) { + return points.map((p) => ({ point: p, lines: wrap(p.label, Math.max(70, w * 0.3), 11) })) +} +function productHeight(points: DashboardCount[], w: number) { + return Math.max(300, 75 + productRows(points, w).reduce((n, r) => n + Math.max(30, r.lines.length * 15 + 10), 0)) +} +function Products({ box, points }: { box: Box; points: DashboardCount[] }) { + const left = Math.max(85, box.w * 0.32), + right = box.w - 58, + max = Math.max(1, ...points.map((p) => p.count)) + let y = 78 + return ( + + {points.length ? ( + productRows(points, box.w).map(({ point, lines }) => { + const at = y + y += Math.max(30, lines.length * 15 + 10) + return ( + + {`${point.label}:${point.count}`} + + + + {number(point.count)} + + + ) + }) + ) : ( + + 暂无分类记录 + + )} + + ) +} +interface PlotPoint { + label: string + value: number | null + hint: string + frozen?: boolean +} +function Plot({ + box, + title, + en, + id, + points, + percent = false, +}: { + box: Box + title: string + en: string + id: string + points: PlotPoint[] + percent?: boolean +}) { + const left = 44, + right = box.w - 22, + top = 74, + bottom = box.h - 47, + max = percent ? 100 : Math.max(4, Math.ceil(Math.max(0, ...points.map((p) => p.value ?? 0)) / 4) * 4), + x = (i: number) => + points.length === 1 ? (left + right) / 2 : left + (i * (right - left)) / Math.max(1, points.length - 1), + y = (n: number) => bottom - (n / max) * (bottom - top) + let path = '', + open = false + for (let i = 0; i < points.length; i++) { + const value = points[i].value + if (value === null) { + open = false + continue + } + path += `${open ? 'L' : 'M'} ${x(i)} ${y(value)} ` + open = true + } + const every = Math.max(1, Math.ceil(points.length / (box.w < 420 ? 5 : 8))) + return ( + + {Array.from({ length: 5 }, (_, i) => ( + + + + {(max * i) / 4} + {percent ? '%' : ''} + + + ))} + {path ? : null} + {points.map((point, i) => ( + + {point.hint} + {point.value !== null ? ( + <> + + {points.length <= 14 || i % every === 0 || i === points.length - 1 ? ( + + {percent ? `${point.value.toFixed(1)}%` : number(point.value)} + + ) : null} + + ) : null} + {i % every === 0 || i === points.length - 1 ? ( + + {point.label} + + ) : null} + + ))} + {!points.some((p) => p.value !== null) ? ( + + {percent ? '暂无冻结快照' : '暂无数据'} + + ) : null} + + ) +} +function regionalHeight(regions: DashboardRegion[], w: number) { + return Math.max( + 290, + 130 + regions.reduce((n, r) => n + Math.max(34, wrap(r.name, Math.max(70, w - 125), 11.5).length * 16 + 12), 0) + ) +} +function RegionTable({ + box, + regions, + frequency, + month, +}: { + box: Box + regions: DashboardRegion[] + frequency: boolean + month: string +}) { + const rows = [...regions].sort((a, b) => { + const av = frequency ? a.frequency : a.usage_coverage.percent, + bv = frequency ? b.frequency : b.usage_coverage.percent + return (bv ?? -1) - (av ?? -1) || a.name.localeCompare(b.name, 'zh-CN') + }) + let y = 104 + return ( + + + + 排名 + + + 区域 + + + {frequency ? '人均次数' : '使用覆盖率'} + + + {rows.map((r, i) => { + const at = y, + value = frequency ? r.frequency : r.usage_coverage.percent + const h = Math.max(34, wrap(r.name, Math.max(70, box.w - 125), 11.5).length * 16 + 12) + y += h + return ( + + {`${r.name};${month}月内提问 ${r.questions} 次;去重使用 ${r.users ?? '未知'} 人 / 通讯录归属 ${r.roster_people ?? '未知'} 人`} + {i % 2 === 0 ? : null} + {value !== null && i < 3 ? ( + + ) : null} + + {value === null ? '—' : i + 1} + + + + {value === null ? '—' : frequency ? value.toFixed(2) : `${value.toFixed(1)}%`} + + + ) + })} + + {month}月累计 + + + ) +} +export function QADashboardDrawing({ + data, + width, + svgRef, +}: { + data: QADashboardData + width: number + svgRef?: Ref +}) { + const w = Math.max(240, Math.round(width)), + pad = w < 700 ? 10 : 16, + gap = w < 700 ? 12 : 18, + inner = w - pad * 2, + mobile = w < 700, + unit = (inner - gap * 3) / 4, + week = currentQAWeek(new Date(data.week_start)), + filterLines = wrap(data.keyword ? `关键词:${data.keyword}` : '', inner - 16, 11), + header = 62 + (data.keyword ? filterLines.length * 15 : 0), + metricH = mobile ? 154 : 166 + const metrics: Box[] = Array.from({ length: 4 }, (_, i) => + mobile + ? { + x: pad + ((i % 2) * (inner + gap)) / 2, + y: header + Math.floor(i / 2) * (metricH + gap), + w: (inner - gap) / 2, + h: metricH, + } + : { x: pad + i * (unit + gap), y: header, w: unit, h: metricH } + ) + let cursor = header + (mobile ? 2 * metricH + gap : metricH) + gap + const pieW = mobile ? inner : (inner - gap) * 0.54, + productW = mobile ? inner : inner - gap - pieW, + pieH = pieHeight(data.issue_types, pieW), + productsH = productHeight(data.products, productW), + row2H = Math.max(pieH, productsH) + const pie: Box = { x: pad, y: cursor, w: pieW, h: mobile ? pieH : row2H }, + products: Box = { + x: mobile ? pad : pad + pieW + gap, + y: mobile ? cursor + pieH + gap : cursor, + w: productW, + h: mobile ? productsH : row2H, + } + cursor += mobile ? pieH + productsH + gap * 2 : row2H + gap + const trendW = mobile ? inner : unit * 2 + gap, + regionW = mobile ? inner : unit, + regionH = regionalHeight(data.regions, regionW), + row3H = Math.max(290, regionH) + const trend: Box = { x: pad, y: cursor, w: trendW, h: mobile ? 270 : row3H }, + freq: Box = { + x: mobile ? pad : pad + trendW + gap, + y: mobile ? cursor + trend.h + gap : cursor, + w: regionW, + h: mobile ? regionH : row3H, + }, + usage: Box = { + x: mobile ? pad : pad + trendW + regionW + gap * 2, + y: mobile ? cursor + trend.h + regionH + gap * 2 : cursor, + w: regionW, + h: mobile ? regionH : row3H, + } + cursor += mobile ? trend.h + regionH * 2 + gap * 3 : row3H + gap + const historyW = mobile ? inner : (inner - gap) / 2, + coverage: Box = { x: pad, y: cursor, w: historyW, h: 270 }, + accuracy: Box = { + x: mobile ? pad : pad + historyW + gap, + y: mobile ? cursor + 270 + gap : cursor, + w: historyW, + h: 270, + }, + height = mobile ? cursor + 540 + gap + pad : cursor + 270 + pad + const frozen = (correctness: boolean): PlotPoint[] => + data.frozen_weeks.map((p) => { + const rate = correctness ? p.correctness : p.coverage, + week = currentQAWeek(new Date(p.week_start)) + return { + label: `${week.year}W${String(week.week).padStart(2, '0')}`, + value: rate.percent, + frozen: p.frozen_at !== null, + hint: p.frozen_at + ? `${week.year}年第${week.week}周:${rate.numerator}/${rate.denominator};冻结于 ${p.frozen_at}` + : `${week.year}年第${week.week}周:缺少当时冻结快照`, + } + }) + const month = new Date(Date.parse(data.month_start) + 8 * 3600000).toISOString().slice(0, 7) + return ( + + + 问题统计看板 · {week.year}年第{week.week}周 + + + + 问题统计看板 + + + {week.year} · W{String(week.week).padStart(2, '0')} + + + {data.week_start.slice(0, 10)} 起的自然周 · 区域统计 {month} 月累计 + + {data.keyword ? ( + + ) : null} + + + + + + + ({ label: p.date.slice(5), value: p.count, hint: `${p.date}:${p.count} 条` }))} + /> + + + + + + ) +} diff --git a/web/src/QAOperations.tsx b/web/src/QAOperations.tsx index f81d1be..0127b83 100644 --- a/web/src/QAOperations.tsx +++ b/web/src/QAOperations.tsx @@ -334,8 +334,9 @@ export function QAOperations() { useEffect(() => { if (!query || !canRead) return - if (view !== 'records' && view !== 'dashboard') { + if (view !== 'records') { setLoading(false) + setError(null) detailRequest.current?.abort() setDetail(null) return @@ -504,9 +505,7 @@ export function QAOperations() { ) : view === 'dashboard' ? ( { diff --git a/web/src/qaDashboard.css b/web/src/qaDashboard.css index 45106cb..3c56ff3 100644 --- a/web/src/qaDashboard.css +++ b/web/src/qaDashboard.css @@ -10,146 +10,19 @@ flex-direction: column; gap: 16px; } -.qa-dashboard-metrics { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - border: 1px solid var(--border-base); - background: var(--bg-panel); -} -.qa-dashboard-metrics > div { - padding: 18px 22px; +.qa-dashboard-toolbar { display: flex; - flex-direction: column; - gap: 8px; - border-right: 1px solid var(--border-base); + justify-content: flex-end; } -.qa-dashboard-metrics > div:last-child { - border-right: 0; -} -.qa-dashboard-metrics span { - color: var(--ink-mute); - font-size: 12px; -} -.qa-dashboard-metrics strong { - color: var(--ink-strong); - font-size: 30px; - font-weight: 650; - font-variant-numeric: tabular-nums; - letter-spacing: -0.04em; -} -.qa-dashboard-metrics small { - font-size: 11px; - color: var(--ink-mute); -} -.qa-dashboard-grid { - display: grid; - grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); - gap: 14px; -} -.qa-chart { +.qa-dashboard-canvas { min-width: 0; - padding: 18px 20px; - background: var(--bg-panel); - border: 1px solid var(--border-base); -} -.qa-chart-full { - grid-column: 1/-1; -} -.qa-chart h3 { - font-size: 14px; - font-weight: 600; - margin: 0 0 20px; - color: var(--ink-strong); -} -.qa-chart-bars { - display: grid; - gap: 13px; -} -.qa-chart-bar { - display: grid; - grid-template-columns: minmax(70px, 120px) minmax(0, 1fr) 40px; - gap: 12px; - align-items: center; - font-size: 12px; -} -.qa-chart-bar > span { + width: 100%; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + background: #fff; } -.qa-chart-bar b { - text-align: right; - font-weight: 500; - font-variant-numeric: tabular-nums; -} -.qa-chart-track { - height: 7px; - background: var(--bg-canvas, #f1f5f9); - overflow: hidden; - border-radius: 2px; -} -.qa-chart-track i { - display: block; - height: 100%; - background: #377bc9; - border-radius: 2px; -} -.qa-chart-empty { - padding: 28px 0; - text-align: center; - color: var(--ink-mute); - font-size: 12px; -} -.qa-daily-chart { - display: flex; - gap: clamp(6px, 1.5vw, 22px); - height: 180px; - align-items: stretch; - overflow-x: auto; -} -.qa-daily-chart > div { - flex: 1; - min-width: 24px; - display: flex; - flex-direction: column; - align-items: center; - gap: 7px; - font-size: 11px; - color: var(--ink-mute); -} -.qa-daily-chart > div > div { - height: 125px; +.qa-dashboard-svg { width: 100%; - display: flex; - align-items: flex-end; - border-bottom: 1px solid var(--border-base); -} -.qa-daily-chart i { - display: block; - width: 100%; - background: #377bc9; - border-radius: 2px 2px 0 0; - min-height: 0; -} -.qa-daily-chart b { - font-size: 11px; - color: var(--ink-strong); - font-variant-numeric: tabular-nums; - font-weight: 500; -} -.qa-dashboard-region-table { - margin-top: 18px; - overflow-x: auto; -} -.qa-dashboard-region-table table { - width: 100%; - font-size: 12px; -} -.qa-dashboard-region-table th, -.qa-dashboard-region-table td { - padding: 8px 10px; - text-align: left; - white-space: normal; + height: auto; } .qa-monthly-disclosure { margin-top: 14px; @@ -166,38 +39,6 @@ margin-top: 12px; overflow: auto; } -@media (max-width: 900px) { - .qa-dashboard-grid { - grid-template-columns: minmax(0, 1fr); - } - .qa-chart-full { - grid-column: auto; - } - .qa-dashboard-metrics > div { - padding: 14px; - } - .qa-dashboard-metrics strong { - font-size: 25px; - } -} -@media (max-width: 560px) { - .qa-dashboard-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .qa-dashboard-metrics > div:nth-child(2) { - border-right: 0; - } - .qa-dashboard-metrics > div:nth-child(-n + 2) { - border-bottom: 1px solid var(--border-base); - } - .qa-chart { - padding: 16px 12px; - } - .qa-chart-bar { - grid-template-columns: minmax(65px, 100px) minmax(0, 1fr) 30px; - } -} - .qa-weekly { display: flex; flex-direction: column; diff --git a/web/src/qaDashboard.fixture.ts b/web/src/qaDashboard.fixture.ts new file mode 100644 index 0000000..aeb4207 --- /dev/null +++ b/web/src/qaDashboard.fixture.ts @@ -0,0 +1,89 @@ +export const dashboardFixture = { + start: '2026-10-05T00:00:00+08:00', + end: '2026-10-12T00:00:00+08:00', + keyword: '', + as_of: '2026-10-18T10:00:00Z', + local_revision: 1, + organization: { + state: 'ready', + directory_id: 'd', + directory_source: 'weekly_report_live', + synced_at: '2026-10-17T10:00:00Z', + revision_id: 'r', + basis: 'current_directory', + region_names: ['国内教育'], + }, + total_issues: 4, + new_this_week: 2, + week_start: '2026-10-05T00:00:00+08:00', + month_start: '2026-10-01T00:00:00+08:00', + quality: { + total: 2, + reviewed: 1, + unreviewed: 1, + stale: 0, + pending_confirmation: 0, + rule_uncovered: 1, + rates: { + policy_version: 'quality-rates-human-first-v1', + coverage: { numerator: 1, denominator: 2, percent: 50 }, + correctness: { numerator: 1, denominator: 1, percent: 100 }, + breakdown: { + covered: 1, + uncovered: 1, + partial: 0, + pending: 0, + unassessed: 0, + excluded: 0, + stale: 0, + covered_correct: 1, + covered_incorrect: 0, + covered_pending: 0, + covered_unassessed: 0, + }, + }, + }, + issue_types: [ + { label: '使用咨询', count: 1 }, + { label: '未分类', count: 1 }, + ], + products: [ + { label: '', count: 1 }, + { label: '未分类', count: 1 }, + ], + daily: Array.from({ length: 7 }, (_, i) => ({ + date: `2026-10-${String(i + 5).padStart(2, '0')}`, + count: i === 1 || i === 5 ? 1 : 0, + })), + regions: [ + { + id: 'education', + name: '国内教育', + questions: 3, + users: 1, + roster_people: 2, + frequency: 1.5, + usage_coverage: { numerator: 1, denominator: 2, percent: 50 }, + }, + ], + frozen_weeks: [ + { + week_start: '2026-09-28T00:00:00+08:00', + week_end: '2026-10-05T00:00:00+08:00', + frozen_at: null, + policy_version: '', + records: 0, + coverage: { numerator: 0, denominator: 0, percent: null }, + correctness: { numerator: 0, denominator: 0, percent: null }, + }, + { + week_start: '2026-10-05T00:00:00+08:00', + week_end: '2026-10-12T00:00:00+08:00', + frozen_at: '2026-10-12T00:06:00+08:00', + policy_version: 'quality-rates-human-first-v1', + records: 2, + coverage: { numerator: 1, denominator: 2, percent: 50 }, + correctness: { numerator: 1, denominator: 1, percent: 100 }, + }, + ], +} diff --git a/web/src/qaDashboardData.test.ts b/web/src/qaDashboardData.test.ts new file mode 100644 index 0000000..7d85833 --- /dev/null +++ b/web/src/qaDashboardData.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { parseQADashboard } from './qaDashboardData' +import { dashboardFixture } from './qaDashboard.fixture' +describe('complete dashboard data contract', () => { + it('keeps distinct cumulative, week, month and genuinely frozen periods', () => { + const data = parseQADashboard(dashboardFixture) + expect([data.total_issues, data.new_this_week, data.regions[0].questions]).toEqual([4, 2, 3]) + expect(data.frozen_weeks[0].coverage.percent).toBeNull() + expect(data.frozen_weeks[1].coverage.percent).toBe(50) + }) + it('rejects fabricated percentages, category omissions and historical backfill', () => { + for (const bad of [ + { ...dashboardFixture, new_this_week: 5 }, + { ...dashboardFixture, products: [] }, + { ...dashboardFixture, regions: [{ ...dashboardFixture.regions[0], frequency: 3 }] }, + { + ...dashboardFixture, + frozen_weeks: [ + { ...dashboardFixture.frozen_weeks[0], coverage: { numerator: 1, denominator: 2, percent: 50 } }, + ], + }, + ]) + expect(() => parseQADashboard(bad)).toThrow() + }) +}) diff --git a/web/src/qaDashboardData.ts b/web/src/qaDashboardData.ts new file mode 100644 index 0000000..ae121a0 --- /dev/null +++ b/web/src/qaDashboardData.ts @@ -0,0 +1,230 @@ +import { parseQAOrganizationVersion } from './qaOps' +import type { QAOrganizationVersion, QAQuery } from './qaOps' +import { parseQualitySummary, parseQualityRate } from './qaQuality' +import type { QualitySummary, QualityRate } from './qaQuality' +export interface DashboardCount { + label: string + count: number +} +export interface DashboardFrozen { + week_start: string + week_end: string + frozen_at: string | null + policy_version: string + records: number + coverage: QualityRate + correctness: QualityRate +} +export interface DashboardRegion { + id: string + name: string + questions: number + users: number | null + roster_people: number | null + frequency: number | null + usage_coverage: QualityRate +} +export interface QADashboardData { + start: string + end: string + keyword: string + as_of: string + local_revision: number + organization: QAOrganizationVersion + total_issues: number + new_this_week: number + week_start: string + month_start: string + quality: QualitySummary['summary'] + issue_types: DashboardCount[] + products: DashboardCount[] + daily: { date: string; count: number }[] + regions: DashboardRegion[] + frozen_weeks: DashboardFrozen[] +} +const bad = (): never => { + throw new Error('统计看板响应无效,未展示可能失真的指标') +} +const obj = (v: unknown): Record => + v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : bad() +const text = (v: unknown): string => (typeof v === 'string' ? v : bad()) +const count = (v: unknown): number => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : bad()) +const stamp = (v: unknown): string => { + const s = text(v) + return Number.isFinite(Date.parse(s)) ? s : bad() +} +const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : bad()) +const dayMS = 86400000, + offset = 8 * 3600000 +const day = (at: number) => Math.floor((at + offset) / dayMS) * dayMS - offset +const monday = (at: number) => { + const d = day(at) + return d - ((new Date(d + offset).getUTCDay() + 6) % 7) * dayMS +} +const month = (at: number) => { + const d = new Date(at + offset) + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) - offset +} +function categories(value: unknown, total: number): DashboardCount[] { + const seen = new Set() + const out = list(value).map((raw) => { + const v = obj(raw), + label = text(v.label), + n = count(v.count) + if (!label || seen.has(label) || !n) return bad() + seen.add(label) + return { label, count: n } + }) + if (out.reduce((n, r) => n + r.count, 0) !== total) return bad() + return out +} +export function parseQADashboard(value: unknown): QADashboardData { + const v = obj(value), + start = stamp(v.start), + end = stamp(v.end), + keyword = text(v.keyword), + as_of = stamp(v.as_of), + week_start = stamp(v.week_start), + month_start = stamp(v.month_start) + if ( + Date.parse(start) >= Date.parse(end) || + Date.parse(week_start) !== monday(Date.parse(end) - 1) || + Date.parse(month_start) !== month(Date.parse(end) - 1) + ) + return bad() + const quality = parseQualitySummary({ + summary: v.quality, + start, + end, + keyword, + as_of, + scope: 'whole_filter', + ai_state: 'not_configured', + }).summary + const organization = parseQAOrganizationVersion(v.organization), + total_issues = count(v.total_issues), + new_this_week = count(v.new_this_week) + if (new_this_week > total_issues || quality.total > total_issues) return bad() + const regionIDs = new Set(), + regionNames = new Set() + const regions = list(v.regions).map((raw) => { + const r = obj(raw), + id = text(r.id), + name = text(r.name), + questions = count(r.questions), + users = r.users === null ? null : count(r.users), + roster_people = r.roster_people === null ? null : count(r.roster_people), + usage_coverage = parseQualityRate(r.usage_coverage) + if (!id || !name || regionIDs.has(id) || regionNames.has(name) || !organization.region_names?.includes(name)) + return bad() + regionIDs.add(id) + regionNames.add(name) + if ( + (users === null) !== (roster_people === null) || + (users !== null && roster_people !== null && users > roster_people) + ) + return bad() + if (usage_coverage.numerator !== (users ?? 0) || usage_coverage.denominator !== (roster_people ?? 0)) return bad() + let frequency: number | null = null + if (roster_people !== null && roster_people > 0) { + if ( + typeof r.frequency !== 'number' || + !Number.isFinite(r.frequency) || + r.frequency < 0 || + Math.abs(r.frequency - questions / roster_people) > 1e-8 + ) + return bad() + frequency = r.frequency + } else if (r.frequency !== null) return bad() + return { id, name, questions, users, roster_people, frequency, usage_coverage } + }) + if (regions.length !== (organization.region_names?.length ?? 0)) return bad() + const daily = list(v.daily).map((raw, i) => { + const d = obj(raw), + date = text(d.date), + n = count(d.count) + if ( + !/^\d{4}-\d{2}-\d{2}$/.test(date) || + Date.parse(date + 'T00:00:00+08:00') !== Date.parse(week_start) + i * dayMS + ) + return bad() + return { date, count: n } + }) + if ( + daily.length < 1 || + daily.length > 7 || + daily.reduce((n, r) => n + r.count, 0) !== new_this_week || + Date.parse(daily[daily.length - 1].date + 'T00:00:00+08:00') >= Date.parse(end) + ) + return bad() + const frozen_weeks = list(v.frozen_weeks).map((raw, i) => { + const p = obj(raw), + week_start = stamp(p.week_start), + week_end = stamp(p.week_end), + frozen_at = p.frozen_at === null ? null : stamp(p.frozen_at), + policy_version = text(p.policy_version), + records = count(p.records), + coverage = parseQualityRate(p.coverage), + correctness = parseQualityRate(p.correctness) + if ( + monday(Date.parse(week_start)) !== Date.parse(week_start) || + Date.parse(week_end) - Date.parse(week_start) !== 7 * dayMS || + Date.parse(week_start) >= Date.parse(end) || + coverage.denominator > records || + correctness.denominator > records + ) + return bad() + if (frozen_at === null) { + if (policy_version || records || coverage.denominator || correctness.denominator) return bad() + } else if (!policy_version || Date.parse(frozen_at) < Date.parse(week_end)) return bad() + const previous = list(v.frozen_weeks)[i - 1] + if (i > 0 && Date.parse(week_start) !== Date.parse(text(obj(previous).week_end))) return bad() + return { week_start, week_end, frozen_at, policy_version, records, coverage, correctness } + }) + if ( + frozen_weeks.length > 5200 || + (frozen_weeks.length && Date.parse(frozen_weeks[frozen_weeks.length - 1].week_end) < Date.parse(end)) + ) + return bad() + return { + start, + end, + keyword, + as_of, + week_start, + month_start, + local_revision: count(v.local_revision), + organization, + total_issues, + new_this_week, + quality, + issue_types: categories(v.issue_types, quality.total), + products: categories(v.products, quality.total), + daily, + regions, + frozen_weeks, + } +} +export async function fetchQADashboard(query: QAQuery, signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/qa-ops/dashboard', { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + signal, + headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-dashboard' }, + body: JSON.stringify({ start: query.start, end: query.end, keyword: query.keyword }), + }) + const body: unknown = await response.json().catch(() => null) + if (!response.ok) { + const message = body && typeof body === 'object' ? (body as Record).error : undefined + throw new Error(typeof message === 'string' ? message : `统计看板读取失败(HTTP ${response.status})`) + } + const data = parseQADashboard(obj(body).data) + if ( + Date.parse(data.start) !== Date.parse(query.start) || + Date.parse(data.end) !== Date.parse(query.end) || + data.keyword !== query.keyword + ) + throw new Error('看板返回范围与当前查询不一致,请重新读取') + return data +} diff --git a/web/src/qaDashboardExport.test.ts b/web/src/qaDashboardExport.test.ts new file mode 100644 index 0000000..507d7f4 --- /dev/null +++ b/web/src/qaDashboardExport.test.ts @@ -0,0 +1,14 @@ +import { it, expect } from 'vitest' +import { dashboardExportSize } from './qaDashboardExport' +it('exports the complete viewBox at double resolution and refuses unsafe canvas sizes', () => { + expect(dashboardExportSize(1280, 1500)).toEqual({ width: 1280 * 2, height: 1500 * 2, scale: 2 }) + expect(dashboardExportSize(2000, 9000)).toEqual({ width: 2000, height: 9000, scale: 1 }) + for (const size of [ + [0, 100], + [-1, 100], + [Infinity, 100], + [100, 40000], + [20000, 20000], + ]) + expect(() => dashboardExportSize(...(size as [number, number]))).toThrow() +}) diff --git a/web/src/qaDashboardExport.ts b/web/src/qaDashboardExport.ts new file mode 100644 index 0000000..9334122 --- /dev/null +++ b/web/src/qaDashboardExport.ts @@ -0,0 +1,48 @@ +export function dashboardExportSize(w: number, h: number): { width: number; height: number; scale: number } { + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0 || w > 32760 || h > 32760 || w * h > 64000000) + throw new Error('看板过大,无法完整生成 PNG,请缩小统计范围') + const scale = w * 2 <= 32760 && h * 2 <= 32760 && w * h * 4 <= 64000000 ? 2 : 1 + return { width: Math.ceil(w * scale), height: Math.ceil(h * scale), scale } +} +export async function createDashboardPNG(svg: SVGSVGElement): Promise { + const box = svg.viewBox.baseVal, + size = dashboardExportSize(box.width, box.height), + copy = svg.cloneNode(true) as SVGSVGElement + if ( + copy.querySelectorAll('[data-panel]').length !== 11 || + copy.querySelector('foreignObject,image,script,iframe,use') + ) + throw new Error('看板内容不完整,未生成图片') + copy.setAttribute('width', String(box.width)) + copy.setAttribute('height', String(box.height)) + copy.style.width = `${box.width}px` + copy.style.height = `${box.height}px` + copy.style.fontFamily = getComputedStyle(svg).fontFamily + if (document.fonts) await document.fonts.ready + const source = new Blob([new XMLSerializer().serializeToString(copy)], { type: 'image/svg+xml;charset=utf-8' }), + url = URL.createObjectURL(source) + try { + const image = new Image() + await new Promise((resolve, reject) => { + image.onload = () => resolve() + image.onerror = () => reject(new Error('看板图片绘制失败')) + image.src = url + }) + const canvas = document.createElement('canvas') + canvas.width = size.width + canvas.height = size.height + const context = canvas.getContext('2d', { alpha: false }) + if (!context) throw new Error('浏览器无法创建完整图片画布') + context.fillStyle = '#fff' + context.fillRect(0, 0, size.width, size.height) + context.drawImage(image, 0, 0, size.width, size.height) + return await new Promise((resolve, reject) => + canvas.toBlob( + (blob) => (blob && blob.size > 0 ? resolve(blob) : reject(new Error('PNG生成失败,请重试'))), + 'image/png' + ) + ) + } finally { + URL.revokeObjectURL(url) + } +} diff --git a/web/src/qaQuality.ts b/web/src/qaQuality.ts index c733e47..6b502fb 100644 --- a/web/src/qaQuality.ts +++ b/web/src/qaQuality.ts @@ -238,6 +238,7 @@ function qualityRate(value: unknown): QualityRate { return bad() return { numerator, denominator, percent: v.percent } } +export { qualityRate as parseQualityRate } function qualityRates(value: unknown, total: number, stale: number): QualityRates { const v = obj(value), r = obj(v.breakdown)