feat(qaops): 增加版本化部门区域归属配置与预览

This commit is contained in:
姚章浩
2026-10-17 11:39:38 +08:00
parent fd04bf7886
commit dab74b706b
23 changed files with 1693 additions and 51 deletions
+2
View File
@@ -4,6 +4,8 @@
### Added
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是实时通讯录同步或使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。
- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估、人员区域映射尚未接入,不标为第一阶段完成。
- 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。
- **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
+51
View File
@@ -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"],
+2
View File
@@ -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,
+62
View File
@@ -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})
}
+31
View File
@@ -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")
}
}
+81
View File
@@ -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})
}
+33
View File
@@ -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)
}
}
}
+82
View File
@@ -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")
}
}
+4
View File
@@ -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)
})
@@ -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;
@@ -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)
);
@@ -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])
+147
View File
@@ -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")
}
+86
View File
@@ -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")
}
}
+214
View File
@@ -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)
}
+146
View File
@@ -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")
}
}
+5
View File
@@ -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",
+65 -49
View File
@@ -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() {
<section className="qa-ops" aria-label="问答运营">
<header className="qa-ops-header">
<h2>
问答运营 <span>/ 问答记录</span>
问答运营 <span>/ {view === 'regions' ? '区域归属' : '问答记录'}</span>
</h2>
<p>真实问答只读查询;原始记录、源端判断与治理复检分开保留。</p>
</header>
@@ -372,55 +374,69 @@ export function QAOperations() {
</p>
) : (
<>
<form className="qa-ops-query" onSubmit={submit}>
<label>
开始时间
<input
type="datetime-local"
required
value={draft.start}
onChange={(event) => setDraft({ ...draft, start: event.target.value })}
/>
</label>
<label>
结束时间(不含)
<input
type="datetime-local"
required
value={draft.end}
onChange={(event) => setDraft({ ...draft, end: event.target.value })}
/>
</label>
<label className="qa-ops-keyword">
关键词
<input
type="search"
maxLength={200}
placeholder="检索原始问题"
value={draft.keyword}
onChange={(event) => setDraft({ ...draft, keyword: event.target.value })}
/>
</label>
<button type="submit" disabled={loading}>
查询
<nav className="qa-region-tabs" aria-label="问答运营视图">
<button type="button" aria-pressed={view === 'records'} onClick={() => setView('records')}>
问答记录
</button>
</form>
<p className="qa-ops-slice-note">
当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。
</p>
<QAOperationsView
page={page}
status={status}
error={error}
loading={loading}
detail={detail}
detailLoading={detailLoading}
detailError={detailError}
onSelect={(id) => void select(id)}
onCloseDetail={close}
onLoadMore={() => void loadMore()}
onRetry={() => setRevision((value) => value + 1)}
/>
<button type="button" aria-pressed={view === 'regions'} onClick={() => setView('regions')}>
区域使用
</button>
</nav>
{view === 'regions' ? (
<QARegions />
) : (
<>
<form className="qa-ops-query" onSubmit={submit}>
<label>
开始时间
<input
type="datetime-local"
required
value={draft.start}
onChange={(event) => setDraft({ ...draft, start: event.target.value })}
/>
</label>
<label>
结束时间(不含)
<input
type="datetime-local"
required
value={draft.end}
onChange={(event) => setDraft({ ...draft, end: event.target.value })}
/>
</label>
<label className="qa-ops-keyword">
关键词
<input
type="search"
maxLength={200}
placeholder="检索原始问题"
value={draft.keyword}
onChange={(event) => setDraft({ ...draft, keyword: event.target.value })}
/>
</label>
<button type="submit" disabled={loading}>
查询
</button>
</form>
<p className="qa-ops-slice-note">
当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。
</p>
<QAOperationsView
page={page}
status={status}
error={error}
loading={loading}
detail={detail}
detailLoading={detailLoading}
detailError={detailError}
onSelect={(id) => void select(id)}
onCloseDetail={close}
onLoadMore={() => void loadMore()}
onRetry={() => setRevision((value) => value + 1)}
/>
</>
)}
</>
)}
</section>
+66
View File
@@ -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: '<script>test</script>',
path: '示例公司/<script>test</script>',
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(<RegionMappingView {...props} />)
expect(html).toContain('历史快照')
expect(html).toContain('跨区域待核对:1')
expect(html).toContain('配置区域归属不等于纳入绩效考核')
expect(html).not.toContain('<script>test</script>')
expect(html).not.toContain('达标率')
expect(html).toMatch(/disabled=""[^>]*>保存归属/)
})
it('requires a preview of the current draft before enabling save', () => {
const html = renderToStaticMarkup(<RegionMappingView {...props} dirty preview={state} />)
expect(html).toContain('预览结果(未保存)')
expect(html).not.toMatch(/disabled=""[^>]*>保存归属/)
})
})
+246
View File
@@ -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 (
<section className="qa-regions" aria-label="区域归属配置">
<div className="qa-region-heading">
<h3>区域归属配置</h3>
<button type="button" disabled={p.busy} onClick={p.onReload}>
{p.dirty ? '放弃修改并重读' : '重新读取'}
</button>
</div>
{p.state?.directory_synced_at ? (
<p className="qa-ops-muted">
{p.state.directory_source === 'weekly_report_snapshot' ? '历史快照' : '全量同步快照'} · 通讯录同步时间:
{new Date(p.state.directory_synced_at).toLocaleString('zh-CN', { hour12: false })} · 配置版本:
{p.state.revision_id || '尚未保存'}
</p>
) : null}
<p className="qa-ops-muted">
父部门包含子部门,子部门单独配置优先。配置区域归属不等于纳入绩效考核;跨区域多部门人员先待核对。
</p>
{p.error ? (
<p role="alert" className="error-banner">
{p.error}
</p>
) : null}
{p.notice ? <p role="status">{p.notice}</p> : null}
{!p.state ? (
<p role="status">{p.busy ? '正在读取通讯录…' : '尚未读取通讯录'}</p>
) : !available ? (
<p className="qa-ops-empty">尚无完整通讯录快照。先接入已验证的全量同步结果,不能使用按需人员缓存或编造部门。</p>
) : (
<>
<div className="qa-region-filters">
<label>
选择区域
<select value={p.selectedRegion} disabled={p.busy} onChange={(e) => p.onRegion(e.target.value)}>
{regionNames.map((r) => (
<option key={r}>{r}</option>
))}
</select>
</label>
<label>
搜索完整部门路径
<input
type="search"
value={p.search}
onChange={(e) => p.onSearch(e.target.value)}
placeholder="输入部门名称或完整路径"
/>
</label>
<button type="button" disabled={p.busy || !p.dirty} onClick={p.onPreview}>
预览影响
</button>
<button type="button" disabled={p.busy || !p.dirty || !p.preview} onClick={p.onSave}>
保存归属
</button>
</div>
{display ? (
<div className="qa-region-preview" aria-label="归属人数预览">
<strong>{p.preview ? '预览结果(未保存)' : '已保存归属'}</strong>
<span>通讯录人数:{display.view.total_people}</span>
{regionNames.map((r) => (
<span key={r}>
{r}:{display.view.regions[r]}
</span>
))}
<span>未归属:{display.view.unassigned_people}</span>
<span>跨区域待核对:{display.view.conflicting_people}</span>
</div>
) : null}
{p.dirty && !p.preview ? (
<p className="qa-ops-muted">勾选已修改,人数仍为已保存结果;请预览后保存。</p>
) : null}
{display?.view.missing_departments.length ? (
<div role="alert" className="qa-ops-warning">
部分已配置部门不在当前通讯录中,原规则保留,需核对:
{display.view.missing_departments.map((id) => (
<span key={id}>
{id}{' '}
<button type="button" disabled={p.busy} onClick={() => p.onRemoveMissing(id)}>
移除此规则
</button>
</span>
))}
</div>
) : null}
<div className="qa-region-tree-scroll">
<table className="qa-region-table">
<thead>
<tr>
<th>直接配置</th>
<th>完整部门路径</th>
<th>{p.dirty && !p.preview ? '已保存归属' : '生效归属'}</th>
<th>依据</th>
</tr>
</thead>
<tbody>
{departments.map((d) => (
<tr key={d.id}>
<td>
<input
type="checkbox"
aria-label={`将 ${d.path} 直接归入 ${p.selectedRegion}`}
checked={p.rules.some((r) => r.department_id === d.id && r.region === p.selectedRegion)}
disabled={p.busy}
onChange={(e) => p.onToggle(d.id, e.target.checked)}
/>
</td>
<td>{d.path}</td>
<td>{d.region || '未归属'}</td>
<td>{d.explicit ? '直接配置' : d.inherited_from ? '继承上级' : '未配置'}</td>
</tr>
))}
</tbody>
</table>
{!departments.length ? <p>没有匹配的部门路径</p> : null}
</div>
<p className="qa-ops-muted">
此处仅预览通讯录归属人数;2.0 使用次数、考核名单与绩效统计尚未接入。不会将未知在职状态当作已确认在职人数。
</p>
</>
)}
</section>
)
}
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<Organization | null>(null),
[preview, setPreview] = useState<Organization | null>(null),
[rules, setRules] = useState<RegionRule[]>([])
const [region, setRegion] = useState<string>(regionNames[0]),
[search, setSearch] = useState(''),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[notice, setNotice] = useState('')
const request = useRef<AbortController | null>(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 (
<RegionMappingView
state={state}
preview={preview}
rules={rules}
selectedRegion={region}
search={search}
busy={busy}
error={error}
notice={notice}
dirty={Boolean(state && signature(rules) !== signature(state.rules))}
onRegion={setRegion}
onSearch={setSearch}
onToggle={(id, checked) => 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()}
/>
)
}
+63
View File
@@ -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()
})
})
+152
View File
@@ -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<string, number>
total_people: number
unassigned_people: number
conflicting_people: number
missing_departments: string[]
}
}
function invalid(): never {
throw new Error('通讯录或区域配置响应格式无效')
}
function obj(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid()
return value as Record<string, unknown>
}
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<Organization> {
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<string, unknown>).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
)
+124
View File
@@ -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;
}
}