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' ? '区域归属' : '问答记录'}
- 当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。 -
-+ 当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。 +
++ {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 ? ( +尚无完整通讯录快照。先接入已验证的全量同步结果,不能使用按需人员缓存或编造部门。
+ ) : ( + <> +勾选已修改,人数仍为已保存结果;请预览后保存。
+ ) : null} + {display?.view.missing_departments.length ? ( +| 直接配置 | +完整部门路径 | +{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 ? '继承上级' : '未配置'} | +
没有匹配的部门路径
: null} ++ 此处仅预览通讯录归属人数;2.0 使用次数、考核名单与绩效统计尚未接入。不会将未知在职状态当作已确认在职人数。 +
+ > + )} +