feat(qaops): 增加版本化部门区域归属配置与预览
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是实时通讯录同步或使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。
|
||||
- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。
|
||||
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估、人员区域映射尚未接入,不标为第一阶段完成。
|
||||
- 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。
|
||||
- **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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=""[^>]*>保存归属/)
|
||||
})
|
||||
})
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user