diff --git a/CHANGELOG.md b/CHANGELOG.md index e483972..aae8383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是实时通讯录同步或使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。 +- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。 - **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估、人员区域映射尚未接入,不标为第一阶段完成。 - 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。 - **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。 diff --git a/api/openapi.yaml b/api/openapi.yaml index 8a94b6f..66cfbdc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -44,6 +44,57 @@ } ], "paths": { + "/api/v1/qa-ops/organization": { + "get": { + "tags": ["QA operations"], + "operationId": "getQAOrganization", + "summary": "管理员读取服务器固定租户的完整部门路径、已保存区域归属与快照时间,不返回人员名单", + "responses": { + "200": {"$ref": "#/components/responses/Envelope"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"description": "需要登录"}, + "403": {"description": "仅管理员可访问"}, + "500": {"$ref": "#/components/responses/InternalError"}, + "503": {"description": "治理侧组织存储不可用"} + } + } + }, + "/api/v1/qa-ops/organization/preview": { + "post": { + "tags": ["QA operations"], + "operationId": "previewQAOrganizationRegions", + "summary": "预览当前通讯录版本下的父子部门区域归属和去重人数,不保存修改", + "parameters": [{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}], + "requestBody": {"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["directory_id","base_revision","rules"],"properties":{"directory_id":{"type":"string"},"base_revision":{"type":"string"},"rules":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["department_id","region"],"properties":{"department_id":{"type":"string"},"region":{"enum":["国内工业","APAC","EMEA","America"]}}}}}}}}}, + "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/regions": { + "put": { + "tags": ["QA operations"], + "operationId": "saveQAOrganizationRegions", + "summary": "保存版本化区域配置;操作人取管理员会话,不改变钉钉或DobotAI数据", + "parameters": [{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}], + "requestBody": {"$ref":"#/paths/~1api~1v1~1qa-ops~1organization~1preview/post/requestBody"}, + "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/status": { "get": { "tags": ["QA operations"], diff --git a/cmd/api/main.go b/cmd/api/main.go index bd497bc..032855f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -20,6 +20,7 @@ import ( "knowledge-governance/internal/monitoring" postgresstore "knowledge-governance/internal/platform/postgres" "knowledge-governance/internal/platform/weknora" + "knowledge-governance/internal/qaopsorg" "knowledge-governance/internal/serviceverify" ) @@ -133,6 +134,7 @@ func run(ctx context.Context, getenv func(string) string) error { log.Print("QA operations source is unavailable; governance remains online") } handler, err := httpapi.NewHandler(httpapi.Config{ + QAOrg: qaopsorg.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY")), QAOps: qaReader, QAOpsTenantKey: qaSettings.TenantKey, QAOpsEnabled: qaSettings.Enabled, diff --git a/cmd/qa-org-import/main.go b/cmd/qa-org-import/main.go new file mode 100644 index 0000000..2630334 --- /dev/null +++ b/cmd/qa-org-import/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "knowledge-governance/internal/qaopsorg" +) + +func main() { + if err := run(os.Args[1:], os.Getenv, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "directory import failed; check private input and governance connection (details withheld)") + os.Exit(1) + } +} + +func run(args []string, getenv func(string) string, out io.Writer) error { + if len(args) != 1 || getenv("QAOPS_CONFIRM_DIRECTORY_IMPORT") != "import-governance-directory" || getenv("DOBOTAI_OPS_TENANT_KEY") == "" || getenv("DATABASE_URL") == "" { + return errors.New("explicit input, tenant, governance database and confirmation required") + } + info, err := os.Lstat(args[0]) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 16<<20 { + return errors.New("input must be a private regular JSON file") + } + file, err := os.Open(args[0]) + if err != nil { + return errors.New("input unavailable") + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, (16<<20)+1)) + decoder.DisallowUnknownFields() + var input qaopsorg.Import + if decoder.Decode(&input) != nil || decoder.Decode(&struct{}{}) != io.EOF { + return errors.New("input invalid") + } + if !input.Complete { + return qaopsorg.ErrInvalid + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, getenv("DATABASE_URL")) + if err != nil { + return qaopsorg.ErrUnavailable + } + defer pool.Close() + store := qaopsorg.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY")) + saved, err := store.ReplaceDirectory(ctx, input) + if err != nil { + return err + } + active, err := store.GetView(ctx) + if err != nil || active.DirectoryID != saved.DirectoryID { + return qaopsorg.ErrConflict + } + return json.NewEncoder(out).Encode(map[string]any{"directory_id": active.DirectoryID, "source": active.DirectorySource, "synced_at": active.DirectorySyncedAt, "departments": len(active.View.Departments), "people": active.View.TotalPeople, "readback_verified": true}) +} diff --git a/cmd/qa-org-import/main_test.go b/cmd/qa-org-import/main_test.go new file mode 100644 index 0000000..820a5fc --- /dev/null +++ b/cmd/qa-org-import/main_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestDirectoryImportRequiresExplicitGovernanceConfirmationAndPrivateInput(t *testing.T) { + var output bytes.Buffer + if run(nil, func(string) string { return "" }, &output) == nil || output.Len() != 0 { + t.Fatal("unconfigured importer must not connect or report success") + } + path := filepath.Join(t.TempDir(), "directory.json") + if os.WriteFile(path, []byte(`{"complete":false}`), 0644) != nil { + t.Fatal("fixture creation failed") + } + env := func(k string) string { + return map[string]string{"QAOPS_CONFIRM_DIRECTORY_IMPORT": "import-governance-directory", "DOBOTAI_OPS_TENANT_KEY": "fixture", "DATABASE_URL": "postgres://invalid.invalid/no_connection_expected"}[k] + } + if run([]string{path}, env, &output) == nil { + t.Fatal("public personnel input accepted") + } + if os.Chmod(path, 0600) != nil { + t.Fatal("fixture chmod failed") + } + if run([]string{path}, env, &output) == nil || output.Len() != 0 { + t.Fatal("partial sync accepted or importer reported fabricated success") + } +} diff --git a/internal/httpapi/qa_org.go b/internal/httpapi/qa_org.go new file mode 100644 index 0000000..aed634c --- /dev/null +++ b/internal/httpapi/qa_org.go @@ -0,0 +1,81 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "knowledge-governance/internal/qaopsorg" + "net/http" + "net/url" +) + +type QAOrgStore interface { + GetView(context.Context) (qaopsorg.State, error) + PreviewRules(context.Context, qaopsorg.SaveRequest) (qaopsorg.State, error) + SaveRules(context.Context, qaopsorg.SaveRequest, string) (qaopsorg.State, error) +} + +func qaOrganization(w http.ResponseWriter, r *http.Request, config Config) { + if !authorizeQAOps(w, r) { + return + } + if r.URL.RawQuery != "" { + writeOrgError(w, qaopsorg.ErrInvalid) + return + } + if config.QAOrg == nil { + writeOrgError(w, qaopsorg.ErrUnavailable) + return + } + var result qaopsorg.State + var err error + if r.Method == http.MethodGet { + result, err = config.QAOrg.GetView(r.Context()) + } else { + if r.Header.Get("X-Governance-Action") != "qa-organization" || r.Header.Get("Sec-Fetch-Site") == "cross-site" { + writeJSON(w, 403, map[string]string{"error": "区域配置需要同源操作意图"}) + return + } + // A same-origin browser request can pass a reverse proxy that rewrites + // Host. Fetch Metadata is browser-controlled; keep the explicit action + // header and reject cross-site requests without trusting forwarded hosts. + if raw := r.Header.Get("Origin"); raw != "" && r.Header.Get("Sec-Fetch-Site") != "same-origin" { + origin, parseErr := url.Parse(raw) + if parseErr != nil || (origin.Scheme != "http" && origin.Scheme != "https") || origin.Host != r.Host { + writeJSON(w, 403, map[string]string{"error": "拒绝跨站配置请求"}) + return + } + } + var request qaopsorg.SaveRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&request) != nil || decoder.Decode(&struct{}{}) != io.EOF || request.DirectoryID == "" || request.Rules == nil { + writeOrgError(w, qaopsorg.ErrInvalid) + return + } + if r.Method == http.MethodPost { + result, err = config.QAOrg.PreviewRules(r.Context(), request) + } else { + identity, _ := sessionFrom(r.Context()) + result, err = config.QAOrg.SaveRules(r.Context(), request, identity.Username) + } + } + if err != nil { + writeOrgError(w, err) + return + } + writeJSON(w, 200, map[string]any{"data": result}) +} +func writeOrgError(w http.ResponseWriter, err error) { + status, message := 503, "通讯录或区域配置存储不可用,未展示为空数据" + switch { + case errors.Is(err, qaopsorg.ErrInvalid): + status, message = 400, "部门树或区域配置无效,请核对重复、缺失路径及区域" + case errors.Is(err, qaopsorg.ErrConflict): + status, message = 409, "通讯录或区域配置已变化,请重新读取后预览" + case errors.Is(err, qaopsorg.ErrNoDirectory): + status, message = 409, "尚无完整通讯录快照,不能保存区域配置" + } + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/httpapi/qa_org_proxy_test.go b/internal/httpapi/qa_org_proxy_test.go new file mode 100644 index 0000000..01924c7 --- /dev/null +++ b/internal/httpapi/qa_org_proxy_test.go @@ -0,0 +1,33 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestQAOrganizationSameOriginThroughReverseProxy(t *testing.T) { + auth := newFakeAuthStore() + auth.sessions["org-proxy-admin"] = "usr-boss" + spy := &orgSpy{} + handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOrg: spy, Auth: &AuthConfig{Store: auth}}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + site string + status int + }{{"same-origin", 200}, {"cross-site", 403}, {"same-site", 403}} { + req := httptest.NewRequest("PUT", "http://internal-api:8080/api/v1/qa-ops/organization/regions", strings.NewReader(`{"directory_id":"d","base_revision":"","rules":[]}`)) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "org-proxy-admin"}) + req.Header.Set("X-Governance-Action", "qa-organization") + req.Header.Set("Origin", "http://frontend:8088") + req.Header.Set("Sec-Fetch-Site", tc.site) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != tc.status { + t.Fatalf("proxy fetch site %s got %d want %d", tc.site, res.Code, tc.status) + } + } +} diff --git a/internal/httpapi/qa_org_test.go b/internal/httpapi/qa_org_test.go new file mode 100644 index 0000000..882c9be --- /dev/null +++ b/internal/httpapi/qa_org_test.go @@ -0,0 +1,82 @@ +package httpapi + +import ( + "context" + "knowledge-governance/internal/qaopsorg" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type orgSpy struct { + calls int + actor string + err error +} + +func (s *orgSpy) GetView(context.Context) (qaopsorg.State, error) { + s.calls++ + return qaopsorg.State{State: "empty"}, s.err +} +func (s *orgSpy) PreviewRules(context.Context, qaopsorg.SaveRequest) (qaopsorg.State, error) { + s.calls++ + return qaopsorg.State{}, s.err +} +func (s *orgSpy) SaveRules(_ context.Context, _ qaopsorg.SaveRequest, actor string) (qaopsorg.State, error) { + s.calls++ + s.actor = actor + return qaopsorg.State{}, s.err +} +func TestQAOrganizationGuardsIdentityIntentAndErrors(t *testing.T) { + spy := &orgSpy{} + auth := newFakeAuthStore() + auth.sessions["org-admin"] = "usr-boss" + auth.sessions["org-viewer"] = "usr-eye" + handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOrg: spy, Auth: &AuthConfig{Store: auth}}) + if err != nil { + t.Fatal(err) + } + base := "/api/v1/qa-ops/organization" + for _, tc := range []struct { + method, path, token, action, origin, body string + status int + }{ + {"GET", base, "", "", "", "", 401}, + {"GET", base, "org-viewer", "", "", "", 403}, + {"GET", base, "org-admin", "", "", "", 200}, + {"GET", base + "?tenant=another", "org-admin", "", "", "", 400}, + {"PUT", base + "/regions", "org-admin", "", "", "{}", 403}, + {"PUT", base + "/regions", "org-admin", "qa-organization", "https://untrusted.invalid", "{}", 403}, + {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"updated_by":"forged"}`, 400}, + {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{} {}`, 400}, + {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{}`, 400}, + {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":null}`, 400}, + {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":[]}`, 200}, + {"POST", base + "/preview", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":[]}`, 200}, + } { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + if tc.token != "" { + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tc.token}) + } + req.Header.Set("X-Governance-Action", tc.action) + req.Header.Set("Origin", tc.origin) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != tc.status { + t.Fatalf("%s %s got %d want %d: %s", tc.method, tc.path, res.Code, tc.status, res.Body.String()) + } + } + if spy.calls != 3 || spy.actor == "" || spy.actor == "forged" { + t.Fatalf("invalid requests reached store or actor not stamped: %+v", spy) + } + spy.err = qaopsorg.ErrConflict + req := httptest.NewRequest("PUT", base+"/regions", strings.NewReader(`{"directory_id":"d","base_revision":"","rules":[]}`)) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "org-admin"}) + req.Header.Set("X-Governance-Action", "qa-organization") + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != 409 { + t.Fatal("stale configuration not reported as conflict") + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 2c1c1b3..df5c2d9 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -101,6 +101,7 @@ type Store interface { } type Config struct { + QAOrg QAOrgStore QAOps QAOpsReader QAOpsTenantKey string QAOpsEnabled bool @@ -171,6 +172,9 @@ func NewHandler(config Config) (http.Handler, error) { config.NewID = newID } mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/qa-ops/organization", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) }) + mux.HandleFunc("POST /api/v1/qa-ops/organization/preview", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) }) + mux.HandleFunc("PUT /api/v1/qa-ops/organization/regions", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) }) mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) { qaOpsStatus(w, r, config) }) diff --git a/internal/platform/postgres/migrations/000048_qa_ops_organization.down.sql b/internal/platform/postgres/migrations/000048_qa_ops_organization.down.sql new file mode 100644 index 0000000..031a50b --- /dev/null +++ b/internal/platform/postgres/migrations/000048_qa_ops_organization.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS qa_ops_organization_state; +DROP TABLE IF EXISTS qa_ops_region_revisions; +DROP TABLE IF EXISTS qa_ops_directory_snapshots; diff --git a/internal/platform/postgres/migrations/000048_qa_ops_organization.up.sql b/internal/platform/postgres/migrations/000048_qa_ops_organization.up.sql new file mode 100644 index 0000000..f7b665c --- /dev/null +++ b/internal/platform/postgres/migrations/000048_qa_ops_organization.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE qa_ops_directory_snapshots ( + tenant_key TEXT NOT NULL, + id TEXT NOT NULL, + source TEXT NOT NULL CHECK (source IN ('weekly_report_snapshot','weekly_report_live')), + synced_at TIMESTAMPTZ NOT NULL, + imported_at TIMESTAMPTZ NOT NULL DEFAULT now(), + payload JSONB NOT NULL CHECK (jsonb_typeof(payload)='object'), + PRIMARY KEY (tenant_key,id) +); +CREATE TABLE qa_ops_region_revisions ( + tenant_key TEXT NOT NULL, + id TEXT NOT NULL, + directory_id TEXT NOT NULL, + rules JSONB NOT NULL CHECK (jsonb_typeof(rules)='array'), + updated_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_key,id), + FOREIGN KEY (tenant_key,directory_id) REFERENCES qa_ops_directory_snapshots(tenant_key,id) +); +CREATE TABLE qa_ops_organization_state ( + tenant_key TEXT PRIMARY KEY, + directory_id TEXT NOT NULL, + revision_id TEXT, + FOREIGN KEY (tenant_key,directory_id) REFERENCES qa_ops_directory_snapshots(tenant_key,id), + FOREIGN KEY (tenant_key,revision_id) REFERENCES qa_ops_region_revisions(tenant_key,id) +); diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index 404baab..deae533 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) != 47 { - t.Fatalf("migration count = %d, want 47", len(migrations)) + if len(migrations) != 48 { + t.Fatalf("migration count = %d, want 48", 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/qaopsorg/regions.go b/internal/qaopsorg/regions.go new file mode 100644 index 0000000..f275de9 --- /dev/null +++ b/internal/qaopsorg/regions.go @@ -0,0 +1,147 @@ +package qaopsorg + +import ( + "errors" + "sort" + "strings" +) + +var ErrInvalid = errors.New("invalid organization data or region rules") +var RegionNames = []string{"国内工业", "APAC", "EMEA", "America"} + +type Department struct { + ID string `json:"id"` + ParentID string `json:"parent_id"` + Name string `json:"name"` +} +type Person struct { + ID string `json:"id"` + UnionID string `json:"union_id,omitempty"` + DepartmentIDs []string `json:"department_ids"` +} +type Directory struct { + Departments []Department `json:"departments"` + People []Person `json:"people"` +} +type Rule struct { + DepartmentID string `json:"department_id"` + Region string `json:"region"` +} +type DepartmentView struct { + Department + Path string `json:"path"` + Region string `json:"region"` + Explicit bool `json:"explicit"` + InheritedFrom string `json:"inherited_from"` +} +type PreviewResult struct { + Departments []DepartmentView `json:"departments"` + Regions map[string]int `json:"regions"` + TotalPeople int `json:"total_people"` + UnassignedPeople int `json:"unassigned_people"` + ConflictingPeople int `json:"conflicting_people"` + MissingDepartments []string `json:"missing_departments"` +} + +// Identity is department ID. Names and legacy keyword rules never decide ownership. +func Preview(directory Directory, rules []Rule) (PreviewResult, error) { + result := PreviewResult{Departments: []DepartmentView{}, Regions: map[string]int{}, MissingDepartments: []string{}, TotalPeople: len(directory.People)} + if len(directory.Departments) == 0 || len(directory.Departments) > 20000 || len(directory.People) == 0 || len(directory.People) > 100000 || len(rules) > 20000 { + return result, ErrInvalid + } + for _, region := range RegionNames { + result.Regions[region] = 0 + } + departments := make(map[string]Department, len(directory.Departments)) + for _, d := range directory.Departments { + if !validID(d.ID) || d.Name == "" || len(d.Name) > 1000 || strings.ContainsRune(d.Name, 0) { + return result, ErrInvalid + } + if _, ok := departments[d.ID]; ok { + return result, ErrInvalid + } + departments[d.ID] = d + } + direct := map[string]string{} + for _, rule := range rules { + if !validID(rule.DepartmentID) { + return result, ErrInvalid + } + if _, ok := result.Regions[rule.Region]; !ok { + return result, ErrInvalid + } + if _, ok := direct[rule.DepartmentID]; ok { + return result, ErrInvalid + } + direct[rule.DepartmentID] = rule.Region + if _, ok := departments[rule.DepartmentID]; !ok { + result.MissingDepartments = append(result.MissingDepartments, rule.DepartmentID) + } + } + resolved := map[string]DepartmentView{} + for _, d := range directory.Departments { + seen := map[string]bool{} + chain := []string{} + region, from := "", "" + for cur := d.ID; cur != ""; { + node, ok := departments[cur] + if !ok || seen[cur] || len(chain) > 100 { + return result, ErrInvalid + } + seen[cur] = true + chain = append(chain, node.Name) + if region == "" && direct[cur] != "" { + region, from = direct[cur], cur + } + cur = node.ParentID + } + for left, right := 0, len(chain)-1; left < right; left, right = left+1, right-1 { + chain[left], chain[right] = chain[right], chain[left] + } + view := DepartmentView{Department: d, Path: strings.Join(chain, "/"), Region: region, Explicit: direct[d.ID] != ""} + if from != d.ID { + view.InheritedFrom = from + } + resolved[d.ID] = view + result.Departments = append(result.Departments, view) + } + people := map[string]bool{} + for _, person := range directory.People { + if !validID(person.ID) || people[person.ID] || len(person.DepartmentIDs) > 1000 { + return result, ErrInvalid + } + people[person.ID] = true + regions := map[string]bool{} + for _, id := range person.DepartmentIDs { + department, ok := resolved[id] + if !ok { + return result, ErrInvalid + } + if department.Region != "" { + regions[department.Region] = true + } + } + switch len(regions) { + case 0: + result.UnassignedPeople++ + case 1: + for region := range regions { + result.Regions[region]++ + } + default: + result.ConflictingPeople++ + } + } + sort.Slice(result.Departments, func(i, j int) bool { + a, b := result.Departments[i], result.Departments[j] + if a.Path == b.Path { + return a.ID < b.ID + } + return a.Path < b.Path + }) + sort.Strings(result.MissingDepartments) + return result, nil +} +func validID(value string) bool { + return value != "" && len(value) <= 256 && strings.TrimSpace(value) == value && !strings.ContainsAny(value, "\x00\r\n") +} diff --git a/internal/qaopsorg/regions_test.go b/internal/qaopsorg/regions_test.go new file mode 100644 index 0000000..bba0536 --- /dev/null +++ b/internal/qaopsorg/regions_test.go @@ -0,0 +1,86 @@ +package qaopsorg + +import ( + "errors" + "testing" +) + +func exampleDirectory() Directory { + return Directory{Departments: []Department{ + {ID: "root", Name: "示例公司"}, + {ID: "sales", ParentID: "root", Name: "销售"}, + {ID: "apac", ParentID: "sales", Name: "业务组"}, + {ID: "leaf", ParentID: "apac", Name: "支持"}, + {ID: "other", ParentID: "root", Name: "业务组"}, + }, People: []Person{ + {ID: "p1", DepartmentIDs: []string{"sales"}}, + {ID: "p2", DepartmentIDs: []string{"leaf"}}, + {ID: "p3", DepartmentIDs: []string{"sales", "leaf"}}, + {ID: "p4", DepartmentIDs: []string{"other"}}, + }} +} + +func TestRegionPreviewUsesNearestExplicitAncestorAndCountsPeopleOnce(t *testing.T) { + view, err := Preview(exampleDirectory(), []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}}) + if err != nil { + t.Fatal(err) + } + byID := map[string]DepartmentView{} + for _, d := range view.Departments { + byID[d.ID] = d + } + if got := byID["leaf"]; got.Region != "APAC" || got.InheritedFrom != "apac" || got.Path != "示例公司/销售/业务组/支持" { + t.Fatalf("wrong child inheritance: %+v", got) + } + if !byID["apac"].Explicit || byID["other"].Region != "" { + t.Fatal("explicit choice or unmapped department lost") + } + if view.Regions["国内工业"] != 1 || view.Regions["APAC"] != 1 || view.UnassignedPeople != 1 || view.ConflictingPeople != 1 || view.TotalPeople != 4 { + t.Fatalf("person duplicated or conflict silently resolved: %+v", view) + } +} + +func TestRegionPreviewRejectsBrokenDirectoryAndDuplicateAssignments(t *testing.T) { + cases := []struct { + name string + mutate func(*Directory) + rules []Rule + }{ + {name: "cycle", mutate: func(d *Directory) { d.Departments[0].ParentID = "leaf" }}, + {name: "missing parent", mutate: func(d *Directory) { d.Departments[1].ParentID = "absent" }}, + {name: "duplicate department", mutate: func(d *Directory) { d.Departments = append(d.Departments, d.Departments[0]) }}, + {name: "duplicate person", mutate: func(d *Directory) { d.People = append(d.People, d.People[0]) }}, + {name: "missing membership", mutate: func(d *Directory) { d.People[0].DepartmentIDs = []string{"absent"} }}, + {name: "conflicting direct rules", rules: []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "sales", Region: "APAC"}}}, + {name: "unknown region", rules: []Rule{{DepartmentID: "sales", Region: "automatic"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := exampleDirectory() + if tc.mutate != nil { + tc.mutate(&d) + } + _, err := Preview(d, tc.rules) + if !errors.Is(err, ErrInvalid) { + t.Fatalf("must fail closed, got %v", err) + } + }) + } +} + +func TestRegionPreviewKeepsMissingRulesAndDoesNotInventPrimaryDepartment(t *testing.T) { + d := exampleDirectory() + d.People = append(d.People, Person{ID: "p5", DepartmentIDs: []string{"apac", "leaf"}}) + view, err := Preview(d, []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}, {DepartmentID: "removed", Region: "EMEA"}}) + if err != nil { + t.Fatal(err) + } + if len(view.MissingDepartments) != 1 || view.MissingDepartments[0] != "removed" || view.ConflictingPeople != 1 || view.Regions["APAC"] != 2 { + t.Fatalf("lost orphan rule or duplicated same-region membership: %+v", view) + } + d.Departments[2].Name = "改名后的部门" + renamed, err := Preview(d, []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}}) + if err != nil || renamed.Regions["APAC"] != 2 { + t.Fatal("stable department ID did not survive rename") + } +} diff --git a/internal/qaopsorg/store.go b/internal/qaopsorg/store.go new file mode 100644 index 0000000..c07cb95 --- /dev/null +++ b/internal/qaopsorg/store.go @@ -0,0 +1,214 @@ +package qaopsorg + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "sort" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrConflict = errors.New("organization snapshot or region revision changed") +var ErrUnavailable = errors.New("organization store unavailable") +var ErrNoDirectory = errors.New("organization directory has not been imported") + +type Import struct { + Source string `json:"source"` + SyncedAt time.Time `json:"synced_at"` + Complete bool `json:"complete"` + Directory Directory `json:"directory"` +} +type SaveRequest struct { + DirectoryID string `json:"directory_id"` + BaseRevision string `json:"base_revision"` + Rules []Rule `json:"rules"` +} +type State struct { + State string `json:"state"` + DirectoryID string `json:"directory_id"` + DirectorySource string `json:"directory_source"` + DirectorySyncedAt *time.Time `json:"directory_synced_at"` + RevisionID string `json:"revision_id"` + UpdatedBy string `json:"updated_by"` + UpdatedAt *time.Time `json:"updated_at"` + Rules []Rule `json:"rules"` + View PreviewResult `json:"view"` +} +type Store struct { + pool *pgxpool.Pool + tenant string +} + +func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} } +func (s *Store) available() bool { return s != nil && s.pool != nil && validID(s.tenant) } + +type orgQuerier interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +const readStateSQL = `SELECT d.id,d.source,d.synced_at,d.payload,COALESCE(r.id,''),COALESCE(r.rules,'[]'::jsonb),COALESCE(r.updated_by,''),r.created_at + FROM qa_ops_organization_state st JOIN qa_ops_directory_snapshots d ON d.tenant_key=st.tenant_key AND d.id=st.directory_id + LEFT JOIN qa_ops_region_revisions r ON r.tenant_key=st.tenant_key AND r.id=st.revision_id WHERE st.tenant_key=$1` + +func (s *Store) read(ctx context.Context, q orgQuerier, lock bool) (State, Directory, error) { + state := State{State: "empty", Rules: []Rule{}, View: PreviewResult{Departments: []DepartmentView{}, Regions: map[string]int{}, MissingDepartments: []string{}}} + for _, r := range RegionNames { + state.View.Regions[r] = 0 + } + var directory Directory + query := readStateSQL + if lock { + query += " FOR UPDATE OF st" + } + var payload, rules []byte + err := q.QueryRow(ctx, query, s.tenant).Scan(&state.DirectoryID, &state.DirectorySource, &state.DirectorySyncedAt, &payload, &state.RevisionID, &rules, &state.UpdatedBy, &state.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return state, directory, nil + } + if err != nil || json.Unmarshal(payload, &directory) != nil || json.Unmarshal(rules, &state.Rules) != nil { + return State{}, directory, ErrUnavailable + } + state.View, err = Preview(directory, state.Rules) + if err != nil { + return State{}, directory, ErrUnavailable + } + state.State = "ready" + return state, directory, nil +} +func (s *Store) GetView(ctx context.Context) (State, error) { + if !s.available() { + return State{}, ErrUnavailable + } + state, _, err := s.read(ctx, s.pool, false) + return state, err +} +func (s *Store) PreviewRules(ctx context.Context, request SaveRequest) (State, error) { + if !s.available() { + return State{}, ErrUnavailable + } + state, directory, err := s.read(ctx, s.pool, false) + if err != nil { + return State{}, err + } + return applyRequest(state, directory, request) +} +func applyRequest(state State, directory Directory, request SaveRequest) (State, error) { + if state.State != "ready" { + return State{}, ErrNoDirectory + } + if request.DirectoryID != state.DirectoryID || request.BaseRevision != state.RevisionID { + return State{}, ErrConflict + } + view, err := Preview(directory, request.Rules) + if err != nil { + return State{}, err + } + // Missing saved paths are retained for operator review, never silently removed. + old := map[string]string{} + for _, rule := range state.Rules { + old[rule.DepartmentID] = rule.Region + } + for _, missing := range view.MissingDepartments { + for _, rule := range request.Rules { + if rule.DepartmentID == missing && old[missing] != rule.Region { + return State{}, ErrInvalid + } + } + } + state.View = view + state.Rules = append([]Rule{}, request.Rules...) + sort.Slice(state.Rules, func(i, j int) bool { return state.Rules[i].DepartmentID < state.Rules[j].DepartmentID }) + return state, nil +} +func (s *Store) SaveRules(ctx context.Context, request SaveRequest, actor string) (State, error) { + if !s.available() { + return State{}, ErrUnavailable + } + if !validID(actor) { + return State{}, ErrInvalid + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return State{}, ErrUnavailable + } + defer func() { _ = tx.Rollback(context.Background()) }() + current, directory, err := s.read(ctx, tx, true) + if err != nil { + return State{}, err + } + next, err := applyRequest(current, directory, request) + if err != nil { + return State{}, err + } + before := append([]Rule{}, current.Rules...) + sort.Slice(before, func(i, j int) bool { return before[i].DepartmentID < before[j].DepartmentID }) + oldJSON, _ := json.Marshal(before) + newJSON, _ := json.Marshal(next.Rules) + if current.RevisionID != "" && string(oldJSON) == string(newJSON) { + if tx.Commit(ctx) != nil { + return State{}, ErrUnavailable + } + return current, nil + } + var nonce [16]byte + if _, err = rand.Read(nonce[:]); err != nil { + return State{}, ErrUnavailable + } + next.RevisionID = "reg-" + hex.EncodeToString(nonce[:]) + next.UpdatedBy = actor + var at time.Time + if err = tx.QueryRow(ctx, `INSERT INTO qa_ops_region_revisions(tenant_key,id,directory_id,rules,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING created_at`, s.tenant, next.RevisionID, next.DirectoryID, newJSON, actor).Scan(&at); err != nil { + return State{}, ErrUnavailable + } + next.UpdatedAt = &at + if _, err = tx.Exec(ctx, `UPDATE qa_ops_organization_state SET revision_id=$2 WHERE tenant_key=$1`, s.tenant, next.RevisionID); err != nil { + return State{}, ErrUnavailable + } + if err = tx.Commit(ctx); err != nil { + return State{}, ErrUnavailable + } + return next, nil +} +func (s *Store) ReplaceDirectory(ctx context.Context, input Import) (State, error) { + if !s.available() { + return State{}, ErrUnavailable + } + if !input.Complete || input.SyncedAt.IsZero() || input.SyncedAt.After(time.Now().Add(time.Minute)) || (input.Source != "weekly_report_snapshot" && input.Source != "weekly_report_live") { + return State{}, ErrInvalid + } + if _, err := Preview(input.Directory, nil); err != nil { + return State{}, err + } + raw, err := json.Marshal(input) + if err != nil || len(raw) > 16<<20 { + return State{}, ErrInvalid + } + sum := sha256.Sum256(raw) + id := "dir-" + hex.EncodeToString(sum[:]) + payload, _ := json.Marshal(input.Directory) + tx, err := s.pool.Begin(ctx) + if err != nil { + return State{}, ErrUnavailable + } + defer func() { _ = tx.Rollback(context.Background()) }() + if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_directory_snapshots(tenant_key,id,source,synced_at,payload) VALUES($1,$2,$3,$4,$5) ON CONFLICT(tenant_key,id) DO NOTHING`, s.tenant, id, input.Source, input.SyncedAt, payload); err != nil { + return State{}, ErrUnavailable + } + tag, err := tx.Exec(ctx, `INSERT INTO qa_ops_organization_state(tenant_key,directory_id) VALUES($1,$2) ON CONFLICT(tenant_key) DO UPDATE SET directory_id=EXCLUDED.directory_id WHERE qa_ops_organization_state.directory_id=EXCLUDED.directory_id OR (SELECT synced_at FROM qa_ops_directory_snapshots WHERE tenant_key=$1 AND id=qa_ops_organization_state.directory_id)<$3`, s.tenant, id, input.SyncedAt) + if err != nil { + return State{}, ErrUnavailable + } + if tag.RowsAffected() != 1 { + return State{}, ErrConflict + } + if err = tx.Commit(ctx); err != nil { + return State{}, ErrUnavailable + } + return s.GetView(ctx) +} diff --git a/internal/qaopsorg/store_integration_test.go b/internal/qaopsorg/store_integration_test.go new file mode 100644 index 0000000..6af53ac --- /dev/null +++ b/internal/qaopsorg/store_integration_test.go @@ -0,0 +1,146 @@ +package qaopsorg_test + +import ( + "context" + "errors" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + postgresstore "knowledge-governance/internal/platform/postgres" + "knowledge-governance/internal/qaopsorg" +) + +func TestOrganizationStoreRoundTripAndOptimisticConflict(t *testing.T) { + dsn := os.Getenv("KG_QAOPS_ORG_TEST_DSN") + if dsn == "" { + t.Skip("explicit isolated organization database is not configured") + } + u, err := url.Parse(dsn) + if err != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_org_test_") || os.Getenv("KG_QAOPS_ORG_TEST_ISOLATED") != "1" { + t.Fatal("refusing non-isolated organization database") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal("test database connection failed") + } + defer pool.Close() + if err = postgresstore.ApplyMigrations(ctx, pool); err != nil { + t.Fatal(err) + } + store := qaopsorg.NewStore(pool, "fixture-tenant") + empty, err := store.GetView(ctx) + if err != nil || empty.State != "empty" { + t.Fatalf("empty state must be explicit: %+v %v", empty, err) + } + input := qaopsorg.Import{Source: "weekly_report_snapshot", SyncedAt: time.Date(2026, 9, 27, 14, 3, 44, 0, time.UTC), Complete: true, Directory: qaopsorg.Directory{ + Departments: []qaopsorg.Department{{ID: "root", Name: "示例公司"}, {ID: "a", ParentID: "root", Name: "国内组"}, {ID: "b", ParentID: "a", Name: "海外组"}}, + People: []qaopsorg.Person{{ID: "u1", DepartmentIDs: []string{"a"}}, {ID: "u2", DepartmentIDs: []string{"b"}}}, + }} + imported, err := store.ReplaceDirectory(ctx, input) + if err != nil || imported.DirectoryID == "" || imported.View.TotalPeople != 2 { + t.Fatalf("import failed: %+v %v", imported, err) + } + request := qaopsorg.SaveRequest{DirectoryID: imported.DirectoryID, Rules: []qaopsorg.Rule{{DepartmentID: "a", Region: "国内工业"}, {DepartmentID: "b", Region: "APAC"}}} + preview, err := store.PreviewRules(ctx, request) + if err != nil || preview.View.Regions["APAC"] != 1 { + t.Fatalf("preview failed: %+v %v", preview, err) + } + before, _ := store.GetView(ctx) + if before.RevisionID != "" { + t.Fatal("preview must not persist changes") + } + saved, err := store.SaveRules(ctx, request, "fixture-admin") + if err != nil || saved.RevisionID == "" || saved.UpdatedBy != "fixture-admin" { + t.Fatalf("save failed: %+v %v", saved, err) + } + read, err := store.GetView(ctx) + if err != nil || read.RevisionID != saved.RevisionID || read.View.Regions["国内工业"] != 1 { + t.Fatal("saved rules not consumed by read path") + } + if _, err = store.SaveRules(ctx, request, "stale-admin"); !errors.Is(err, qaopsorg.ErrConflict) { + t.Fatalf("stale update overwrote newer rules: %v", err) + } + request.BaseRevision = saved.RevisionID + again, err := store.SaveRules(ctx, request, "fixture-admin") + if err != nil || again.RevisionID != saved.RevisionID { + t.Fatal("unchanged save must not append duplicate revisions") + } + broken := input + broken.Complete = false + if _, err = store.ReplaceDirectory(ctx, broken); !errors.Is(err, qaopsorg.ErrInvalid) { + t.Fatal("partial sync activated") + } + request.Rules = []qaopsorg.Rule{{DepartmentID: "absent", Region: "EMEA"}} + if _, err = store.SaveRules(ctx, request, "fixture-admin"); !errors.Is(err, qaopsorg.ErrInvalid) { + t.Fatal("invalid rule persisted") + } + after, _ := store.GetView(ctx) + if after.RevisionID != saved.RevisionID { + t.Fatal("failed writes changed active revision") + } + other, _ := qaopsorg.NewStore(pool, "another-tenant").GetView(ctx) + if other.State != "empty" { + t.Fatal("directory leaked across tenants") + } + input.Directory.Departments = input.Directory.Departments[:2] + input.Directory.People = input.Directory.People[:1] + input.SyncedAt = input.SyncedAt.Add(time.Hour) + newer, err := store.ReplaceDirectory(ctx, input) + if err != nil || newer.RevisionID != saved.RevisionID || len(newer.View.MissingDepartments) != 1 { + t.Fatalf("directory refresh deleted rules or hid missing department: %+v %v", newer, err) + } + if _, err = store.PreviewRules(ctx, request); !errors.Is(err, qaopsorg.ErrConflict) { + t.Fatal("preview accepted an obsolete directory snapshot") + } + older := input + start := make(chan struct{}) + results := make(chan error, 2) + for _, region := range []string{"APAC", "EMEA"} { + go func(region string) { + <-start + _, failure := store.SaveRules(ctx, qaopsorg.SaveRequest{DirectoryID: newer.DirectoryID, BaseRevision: newer.RevisionID, Rules: []qaopsorg.Rule{{DepartmentID: "a", Region: region}}}, "concurrent-admin") + results <- failure + }(region) + } + close(start) + successes, conflicts := 0, 0 + for range 2 { + failure := <-results + if failure == nil { + successes++ + } else if errors.Is(failure, qaopsorg.ErrConflict) { + conflicts++ + } else { + t.Fatal(failure) + } + } + if successes != 1 || conflicts != 1 { + t.Fatalf("concurrent stale writes were not serialized: success=%d conflict=%d", successes, conflicts) + } + older.SyncedAt = older.SyncedAt.Add(-2 * time.Hour) + if _, err = store.ReplaceDirectory(ctx, older); !errors.Is(err, qaopsorg.ErrConflict) { + t.Fatalf("older directory rolled back the active roster: %v", err) + } + var revisions int + if err = pool.QueryRow(ctx, "SELECT count(*) FROM qa_ops_region_revisions WHERE tenant_key=$1", "fixture-tenant").Scan(&revisions); err != nil || revisions != 2 { + t.Fatal("revision history changed unexpectedly") + } + if err = postgresstore.RollbackLatestMigration(ctx, pool); err != nil { + t.Fatal(err) + } + var removed bool + if err = pool.QueryRow(ctx, "SELECT to_regclass('qa_ops_organization_state') IS NULL AND to_regclass('qa_ops_region_revisions') IS NULL AND to_regclass('qa_ops_directory_snapshots') IS NULL").Scan(&removed); err != nil || !removed { + t.Fatal("organization down migration left residue") + } + if err = postgresstore.ApplyMigrations(ctx, pool); err != nil { + t.Fatal(err) + } + if state, failure := store.GetView(ctx); failure != nil || state.State != "empty" { + t.Fatal("organization migration could not be reapplied") + } +} diff --git a/scripts/test-scope.json b/scripts/test-scope.json index 0198d5c..f5d61ca 100644 --- a/scripts/test-scope.json +++ b/scripts/test-scope.json @@ -9,6 +9,11 @@ "⚠️ 文件缺失或解析不了 ⇒ 门禁红:范围声明不出来时,不许默认放行。" ], "optional": [ + { + "package": "knowledge-governance/internal/qaopsorg", + "test": "TestOrganizationStoreRoundTripAndOptimisticConflict", + "reason": "仅在显式KG_QAOPS_ORG_TEST_ISOLATED=1且127.0.0.1上的一次性kg_qaops_org_test_库运行;写入治理侧快照与配置,禁止使用共享或生产库;本批已在隔离PG补验" + }, { "package": "knowledge-governance/internal/dobotaiops", "test": "TestSSHDialerTransportsRealTraffic", diff --git a/web/src/QAOperations.tsx b/web/src/QAOperations.tsx index bbe2ca7..134a26d 100644 --- a/web/src/QAOperations.tsx +++ b/web/src/QAOperations.tsx @@ -4,6 +4,7 @@ import { useOptionalAuth } from './auth' import { fetchQARecord, fetchQARecords, fetchQAStatus, qaCellValue, qaColumns } from './qaOps' import type { QAQuery, QARecord, QARecordPage, QAStatus } from './qaOps' import './qaOps.css' +import { QARegions } from './QARegions' interface ViewProps { page: QARecordPage | null @@ -229,6 +230,7 @@ function initialDraft() { } export function QAOperations() { + const [view, setView] = useState<'records' | 'regions'>('records') const auth = useOptionalAuth() const canRead = auth?.identity?.role === 'admin' const [draft, setDraft] = useState(initialDraft) @@ -362,7 +364,7 @@ export function QAOperations() {

- 问答运营 / 问答记录 + 问答运营 / {view === 'regions' ? '区域归属' : '问答记录'}

真实问答只读查询;原始记录、源端判断与治理复检分开保留。

@@ -372,55 +374,69 @@ export function QAOperations() {

) : ( <> -
- - - - -
-

- 当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。 -

- void select(id)} - onCloseDetail={close} - onLoadMore={() => void loadMore()} - onRetry={() => setRevision((value) => value + 1)} - /> + + + {view === 'regions' ? ( + + ) : ( + <> +
+ + + + +
+

+ 当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。 +

+ void select(id)} + onCloseDetail={close} + onLoadMore={() => void loadMore()} + onRetry={() => setRevision((value) => value + 1)} + /> + + )} )}
diff --git a/web/src/QARegions.test.tsx b/web/src/QARegions.test.tsx new file mode 100644 index 0000000..ad8e254 --- /dev/null +++ b/web/src/QARegions.test.tsx @@ -0,0 +1,66 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { RegionMappingView } from './QARegions' +import { parseOrganization } from './qaOrg' +const state = parseOrganization({ + state: 'ready', + directory_id: 'd', + directory_source: 'weekly_report_snapshot', + directory_synced_at: '2026-09-27T14:03:44Z', + revision_id: 'r', + updated_by: 'fixture-admin', + updated_at: null, + rules: [{ department_id: 'a', region: 'APAC' }], + view: { + departments: [ + { + id: 'a', + parent_id: '', + name: '', + path: '示例公司/', + region: 'APAC', + explicit: true, + inherited_from: '', + }, + ], + regions: { 国内工业: 0, APAC: 1, EMEA: 0, America: 0 }, + total_people: 2, + unassigned_people: 0, + conflicting_people: 1, + missing_departments: [], + }, +}) +const props = { + state, + preview: null, + rules: state.rules, + selectedRegion: 'APAC', + search: '', + busy: false, + error: '', + notice: '', + dirty: false, + onRegion: vi.fn(), + onSearch: vi.fn(), + onToggle: vi.fn(), + onPreview: vi.fn(), + onSave: vi.fn(), + onReload: vi.fn(), + onRemoveMissing: vi.fn(), +} +describe('region mapping workbench', () => { + it('shows a dated historical roster, conflicts and no invented performance verdict', () => { + const html = renderToStaticMarkup() + expect(html).toContain('历史快照') + expect(html).toContain('跨区域待核对:1') + expect(html).toContain('配置区域归属不等于纳入绩效考核') + expect(html).not.toContain('') + expect(html).not.toContain('达标率') + expect(html).toMatch(/disabled=""[^>]*>保存归属/) + }) + it('requires a preview of the current draft before enabling save', () => { + const html = renderToStaticMarkup() + expect(html).toContain('预览结果(未保存)') + expect(html).not.toMatch(/disabled=""[^>]*>保存归属/) + }) +}) diff --git a/web/src/QARegions.tsx b/web/src/QARegions.tsx new file mode 100644 index 0000000..25cb37e --- /dev/null +++ b/web/src/QARegions.tsx @@ -0,0 +1,246 @@ +import { useEffect, useRef, useState } from 'react' +import { changeRegionRule, fetchOrganization, previewOrganization, regionNames, saveOrganization } from './qaOrg' +import type { Organization, RegionRule } from './qaOrg' +import './qaRegions.css' +interface Props { + state: Organization | null + preview: Organization | null + rules: RegionRule[] + selectedRegion: string + search: string + busy: boolean + error: string + notice: string + dirty: boolean + onRegion: (value: string) => void + onSearch: (value: string) => void + onToggle: (id: string, checked: boolean) => void + onRemoveMissing: (id: string) => void + onPreview: () => void + onSave: () => void + onReload: () => void +} +export function RegionMappingView(p: Props) { + const display = p.preview ?? p.state + const available = p.state?.state === 'ready' + const departments = + display?.view.departments.filter((d) => d.path.toLowerCase().includes(p.search.toLowerCase())) ?? [] + return ( +
+
+

区域归属配置

+ +
+ {p.state?.directory_synced_at ? ( +

+ {p.state.directory_source === 'weekly_report_snapshot' ? '历史快照' : '全量同步快照'} · 通讯录同步时间: + {new Date(p.state.directory_synced_at).toLocaleString('zh-CN', { hour12: false })} · 配置版本: + {p.state.revision_id || '尚未保存'} +

+ ) : null} +

+ 父部门包含子部门,子部门单独配置优先。配置区域归属不等于纳入绩效考核;跨区域多部门人员先待核对。 +

+ {p.error ? ( +

+ {p.error} +

+ ) : null} + {p.notice ?

{p.notice}

: null} + {!p.state ? ( +

{p.busy ? '正在读取通讯录…' : '尚未读取通讯录'}

+ ) : !available ? ( +

尚无完整通讯录快照。先接入已验证的全量同步结果,不能使用按需人员缓存或编造部门。

+ ) : ( + <> +
+ + + + +
+ {display ? ( +
+ {p.preview ? '预览结果(未保存)' : '已保存归属'} + 通讯录人数:{display.view.total_people} + {regionNames.map((r) => ( + + {r}:{display.view.regions[r]} + + ))} + 未归属:{display.view.unassigned_people} + 跨区域待核对:{display.view.conflicting_people} +
+ ) : null} + {p.dirty && !p.preview ? ( +

勾选已修改,人数仍为已保存结果;请预览后保存。

+ ) : null} + {display?.view.missing_departments.length ? ( +
+ 部分已配置部门不在当前通讯录中,原规则保留,需核对: + {display.view.missing_departments.map((id) => ( + + {id}{' '} + + + ))} +
+ ) : null} +
+ + + + + + + + + + + {departments.map((d) => ( + + + + + + + ))} + +
直接配置完整部门路径{p.dirty && !p.preview ? '已保存归属' : '生效归属'}依据
+ r.department_id === d.id && r.region === p.selectedRegion)} + disabled={p.busy} + onChange={(e) => p.onToggle(d.id, e.target.checked)} + /> + {d.path}{d.region || '未归属'}{d.explicit ? '直接配置' : d.inherited_from ? '继承上级' : '未配置'}
+ {!departments.length ?

没有匹配的部门路径

: null} +
+

+ 此处仅预览通讯录归属人数;2.0 使用次数、考核名单与绩效统计尚未接入。不会将未知在职状态当作已确认在职人数。 +

+ + )} +
+ ) +} +function signature(rules: RegionRule[]) { + return JSON.stringify([...rules].sort((a, b) => a.department_id.localeCompare(b.department_id))) +} +export function QARegions() { + const [state, setState] = useState(null), + [preview, setPreview] = useState(null), + [rules, setRules] = useState([]) + const [region, setRegion] = useState(regionNames[0]), + [search, setSearch] = useState(''), + [busy, setBusy] = useState(false), + [error, setError] = useState(''), + [notice, setNotice] = useState('') + const request = useRef(null) + const accept = (value: Organization) => { + setState(value) + setRules(value.rules) + setPreview(null) + } + const load = async () => { + request.current?.abort() + const controller = new AbortController() + request.current = controller + setBusy(true) + setError('') + setNotice('') + setPreview(null) + try { + const value = await fetchOrganization(controller.signal) + if (!controller.signal.aborted) accept(value) + } catch (e) { + if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '读取失败') + } finally { + if (!controller.signal.aborted) setBusy(false) + } + } + useEffect(() => { + void load() + return () => request.current?.abort() + }, []) + const edit = (next: RegionRule[]) => { + setRules(next) + setPreview(null) + setNotice('') + setError('') + } + const act = async (save: boolean) => { + if (!state || busy || (save && !preview)) return + const controller = new AbortController() + request.current?.abort() + request.current = controller + setBusy(true) + setError('') + setNotice('') + try { + const value = await (save + ? saveOrganization(state, rules, controller.signal) + : previewOrganization(state, rules, controller.signal)) + if (!controller.signal.aborted) { + if (save) { + const readback = await fetchOrganization(controller.signal) + if (controller.signal.aborted) return + accept(readback) + if (readback.revision_id !== value.revision_id) throw new Error('保存后配置再次变化,已读取最新版本,请核对') + setNotice('区域归属已保存并重新读取') + } else setPreview(value) + } + } catch (e) { + if (!controller.signal.aborted) { + setPreview(null) + setError(e instanceof Error ? e.message : '操作失败') + } + } finally { + if (!controller.signal.aborted) setBusy(false) + } + } + return ( + edit(changeRegionRule(rules, id, region, checked))} + onRemoveMissing={(id) => edit(rules.filter((r) => r.department_id !== id))} + onPreview={() => void act(false)} + onSave={() => void act(true)} + onReload={() => void load()} + /> + ) +} diff --git a/web/src/qaOrg.test.ts b/web/src/qaOrg.test.ts new file mode 100644 index 0000000..1fb4c5e --- /dev/null +++ b/web/src/qaOrg.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { fetchOrganization, parseOrganization, changeRegionRule } from './qaOrg' + +export const directoryFixture = { + state: 'ready', + directory_id: 'dir-fixture', + directory_source: 'weekly_report_snapshot', + directory_synced_at: '2026-09-27T14:03:44Z', + revision_id: '', + updated_by: '', + updated_at: null, + rules: [], + view: { + departments: [ + { + id: 'root', + parent_id: '', + name: '示例公司', + path: '示例公司', + region: '', + explicit: false, + inherited_from: '', + }, + ], + regions: { 国内工业: 0, APAC: 0, EMEA: 0, America: 0 }, + total_people: 1, + unassigned_people: 1, + conflicting_people: 0, + missing_departments: [], + }, +} +describe('department region configuration', () => { + it('parses an honest dated snapshot without exposing employee identities', () => { + const state = parseOrganization(directoryFixture) + expect(state.directory_source).toBe('weekly_report_snapshot') + expect(state.view.unassigned_people).toBe(1) + expect(() => + parseOrganization({ ...directoryFixture, view: { ...directoryFixture.view, total_people: 0 } }) + ).toThrow() + expect(() => + parseOrganization({ ...directoryFixture, view: { ...directoryFixture.view, departments: [{}, {}] } }) + ).toThrow() + }) + it('changes only a direct stable ID rule, leaving sibling and child overrides alone', () => { + const rules = [ + { department_id: 'parent', region: '国内工业' }, + { department_id: 'child', region: 'APAC' }, + ] + expect(changeRegionRule(rules, 'parent', 'EMEA', true)).toEqual([ + { department_id: 'child', region: 'APAC' }, + { department_id: 'parent', region: 'EMEA' }, + ]) + expect(changeRegionRule(rules, 'parent', '国内工业', false)).toEqual([{ department_id: 'child', region: 'APAC' }]) + }) + it('surfaces store errors rather than displaying an empty directory', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({ error: '通讯录不可用' }) }) + ) + await expect(fetchOrganization()).rejects.toThrow('通讯录不可用') + vi.unstubAllGlobals() + }) +}) diff --git a/web/src/qaOrg.ts b/web/src/qaOrg.ts new file mode 100644 index 0000000..aa53d23 --- /dev/null +++ b/web/src/qaOrg.ts @@ -0,0 +1,152 @@ +export const regionNames = ['国内工业', 'APAC', 'EMEA', 'America'] as const +export interface RegionRule { + department_id: string + region: string +} +export interface DepartmentRegion { + id: string + parent_id: string + name: string + path: string + region: string + explicit: boolean + inherited_from: string +} +export interface Organization { + state: 'empty' | 'ready' + directory_id: string + directory_source: string + directory_synced_at: string | null + revision_id: string + updated_by: string + updated_at: string | null + rules: RegionRule[] + view: { + departments: DepartmentRegion[] + regions: Record + total_people: number + unassigned_people: number + conflicting_people: number + missing_departments: string[] + } +} +function invalid(): never { + throw new Error('通讯录或区域配置响应格式无效') +} +function obj(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid() + return value as Record +} +function str(value: unknown): string { + return typeof value === 'string' ? value : invalid() +} +function count(value: unknown): number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : invalid() +} +function instant(value: unknown): string | null { + return value === null ? null : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : invalid() +} +export function parseOrganization(value: unknown): Organization { + const s = obj(value), + v = obj(s.view), + regions = obj(v.regions) + if ( + (s.state !== 'empty' && s.state !== 'ready') || + !Array.isArray(s.rules) || + !Array.isArray(v.departments) || + !Array.isArray(v.missing_departments) + ) + return invalid() + const rules = s.rules.map((raw) => { + const r = obj(raw) + const region = str(r.region) + if (!regionNames.some((x) => x === region)) return invalid() + return { department_id: str(r.department_id), region } + }) + const departments = v.departments.map((raw) => { + const d = obj(raw) + if (typeof d.explicit !== 'boolean' || (d.region !== '' && !regionNames.some((x) => x === d.region))) + return invalid() + return { + id: str(d.id), + parent_id: str(d.parent_id), + name: str(d.name), + path: str(d.path), + region: str(d.region), + explicit: d.explicit, + inherited_from: str(d.inherited_from), + } + }) + if ( + departments.some((d) => !d.id || !d.path) || + new Set(departments.map((d) => d.id)).size !== departments.length || + new Set(rules.map((r) => r.department_id)).size !== rules.length + ) + return invalid() + const total = count(v.total_people), + unassigned = count(v.unassigned_people), + conflicting = count(v.conflicting_people) + const regionCounts = Object.fromEntries(regionNames.map((r) => [r, count(regions[r])])) + if (Object.values(regionCounts).reduce((a, b) => a + b, 0) + unassigned + conflicting !== total) return invalid() + const result: Organization = { + state: s.state, + directory_id: str(s.directory_id), + directory_source: str(s.directory_source), + directory_synced_at: instant(s.directory_synced_at), + revision_id: str(s.revision_id), + updated_by: str(s.updated_by), + updated_at: instant(s.updated_at), + rules, + view: { + departments, + regions: regionCounts, + total_people: total, + unassigned_people: unassigned, + conflicting_people: conflicting, + missing_departments: v.missing_departments.map(str), + }, + } + if (s.state === 'ready' && (!result.directory_id || !result.directory_synced_at || !departments.length || !total)) + return invalid() + return result +} +export function changeRegionRule(rules: RegionRule[], id: string, region: string, checked: boolean): RegionRule[] { + return [...rules.filter((r) => r.department_id !== id), ...(checked ? [{ department_id: id, region }] : [])] +} +async function requestOrganization( + path: string, + method: string, + body: unknown, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/v1/qa-ops/organization${path}`, { + method, + credentials: 'same-origin', + cache: 'no-store', + signal, + headers: + method === 'GET' ? undefined : { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-organization' }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const raw: unknown = await response.json().catch(() => null) + if (!response.ok) { + const error = raw && typeof raw === 'object' ? (raw as Record).error : undefined + throw new Error(typeof error === 'string' ? error : `区域配置读取失败(HTTP ${response.status})`) + } + return parseOrganization(obj(raw).data) +} +export const fetchOrganization = (signal?: AbortSignal) => requestOrganization('', 'GET', undefined, signal) +export const previewOrganization = (state: Organization, rules: RegionRule[], signal?: AbortSignal) => + requestOrganization( + '/preview', + 'POST', + { directory_id: state.directory_id, base_revision: state.revision_id, rules }, + signal + ) +export const saveOrganization = (state: Organization, rules: RegionRule[], signal?: AbortSignal) => + requestOrganization( + '/regions', + 'PUT', + { directory_id: state.directory_id, base_revision: state.revision_id, rules }, + signal + ) diff --git a/web/src/qaRegions.css b/web/src/qaRegions.css new file mode 100644 index 0000000..e121a63 --- /dev/null +++ b/web/src/qaRegions.css @@ -0,0 +1,124 @@ +.qa-regions { + display: flex; + flex-direction: column; + min-height: 0; + gap: 12px; + flex: 1; +} +.qa-region-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.qa-region-heading h3 { + margin: 0; +} +.qa-region-filters { + display: flex; + gap: 12px; + align-items: end; + flex-wrap: wrap; +} +.qa-region-filters label { + display: flex; + flex-direction: column; + gap: 6px; +} +.qa-region-filters label:nth-child(2) { + flex: 1; + min-width: 220px; +} +.qa-region-filters input, +.qa-region-filters select { + min-height: 36px; + padding: 6px 10px; + border: 1px solid var(--line, #d8e1eb); + border-radius: 6px; + background: var(--panel, #fff); + color: inherit; +} +.qa-region-preview { + display: flex; + gap: 8px 18px; + flex-wrap: wrap; + padding: 12px 14px; + border: 1px solid var(--line, #d8e1eb); + background: var(--panel-muted, #f3f7fc); + border-radius: 6px; +} +.qa-region-tree-scroll { + min-height: 120px; + overflow: auto; + flex: 1; + border: 1px solid var(--line, #d8e1eb); + border-radius: 6px; +} +.qa-region-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 13px; +} +.qa-region-table th, +.qa-region-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--line, #d8e1eb); +} +.qa-region-table th { + position: sticky; + top: 0; + background: var(--panel, #fff); + white-space: nowrap; +} +.qa-region-table td:nth-child(2) { + min-width: 240px; + overflow-wrap: anywhere; +} +.qa-region-table input[type='checkbox'] { + width: 17px; + height: 17px; + cursor: pointer; + accent-color: #1765c1; +} +.qa-region-tabs { + display: flex; + gap: 8px; +} +.qa-region-tabs button[aria-pressed='true'] { + background: #e5efff; + border-color: #8db8f0; + color: #154a88; +} +.qa-regions button:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.qa-regions p { + margin: 0; +} +.qa-ops-warning > span { + display: inline-block; + margin: 4px 8px; +} +.qa-regions :focus-visible { + outline: 2px solid #1765c1; + outline-offset: 2px; +} +@media (max-width: 700px) { + .qa-region-filters { + align-items: stretch; + } + .qa-region-filters label { + flex: 1; + } + .qa-region-tree-scroll { + min-height: 300px; + } + .qa-region-preview { + font-size: 13px; + } + .qa-region-heading { + flex-wrap: wrap; + } +}