feat(qaops): 增加个人自然月用量与零记录人员明细

This commit is contained in:
姚章浩
2026-10-17 19:59:33 +08:00
parent 56b8cabed9
commit 9556db1ef0
18 changed files with 1248 additions and 10 deletions
+2
View File
@@ -4,6 +4,8 @@
### Added
- 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。
- 问答运营按稳定身份关联当前治理通讯录,缺失部门/区域补全并保留源快照及目录/区域版本;空列判断包含完整筛选结果,不混合配置已变化的分页。新增完整时间/关键词范围的使用统计,问答计次、名册内人员去重,跨区/未匹配/身份缺失分列,不输出绩效结论;不回写 DobotAI 原表。
- 「问答运营 → 区域使用」支持管理员从网页发起持久化通讯录同步,重复请求重放、进行中操作合并;按服务器固定范围注册后台执行器,未接线或未检测到心跳时不创建新任务。界面显示真实任务状态、不编造百分比;失败保留旧快照与区域规则,同步完成不覆盖未保存草稿。旧执行器丢失租约后不能激活结果,重启接管和已提交快照的续跑使用原任务。
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。
+1
View File
@@ -99,6 +99,7 @@
}
}
},
"/api/v1/qa-ops/usage/people": {"post": {"tags": ["QA operations"], "operationId": "queryQAOperationsMonthlyPeople", "summary": "按北京时间自然月列出当前名册人员的2.0用量(含零记录),仅管理员,不返回原始员工标识,不作为在职/考核名单", "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "完整名册缺失或目录/区域版本变化,请同步/重新查询,不返回伪零"}}, "description": "只读查询,不写治理库或生产库;姓名筛选置于 JSON 请求体,不进入 URL;整名册指标不随人员筛选变化。第2页起须带首页目录、区域版本及 end 截止。", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "required": ["month"], "properties": {"month": {"type": "string", "pattern": "^(?!0000)[0-9]{4}-(0[1-9]|1[0-2])$"}, "cutoff": {"type": "string", "format": "date-time"}, "region": {"type": "string", "enum": ["国内工业", "APAC", "EMEA", "America", "unassigned", "cross_region"]}, "department_id": {"type": "string"}, "activity": {"type": "string", "enum": ["all", "used", "zero"]}, "search": {"type": "string", "maxLength": 100}, "page": {"type": "integer", "minimum": 1, "maximum": 100000, "default": 1}, "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 30}, "directory_id": {"type": "string"}, "revision_id": {"type": "string"}}}}}}, "parameters": [{"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-usage-read"]}}]}},
"/api/v1/qa-ops/usage": {"get": {"tags": ["QA operations"], "operationId": "getQAOperationsUsage", "summary": "完整筛选范围内按当前通讯录和区域版本回看2.0问答使用;固定租户、管理员访问,不输出绩效结论", "parameters": [{"name": "start", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "end", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "keyword", "in": "query", "schema": {"type": "string", "maxLength": 200}}], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}}}},
"/api/v1/qa-ops/status": {
"get": {
+312
View File
@@ -0,0 +1,312 @@
package httpapi
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
_ "time/tzdata"
"unicode/utf8"
)
type monthlyFilter struct {
Month, Region, Department, Activity, Search string
Start, End, MonthEnd time.Time
Page, Limit int
}
type monthlyPerson struct {
Key string `json:"key"`
Name string `json:"name"`
State string `json:"state"`
Region string `json:"region"`
Departments []string `json:"departments"`
Questions int64 `json:"questions"`
Succeeded int64 `json:"succeeded"`
Failed int64 `json:"failed"`
Other int64 `json:"other"`
}
type monthlyDepartment struct {
ID string `json:"id"`
Path string `json:"path"`
}
type monthlyPeople struct {
Organization dobotaiops.OrganizationVersion `json:"organization"`
Source string `json:"source"`
Timezone string `json:"timezone"`
Month string `json:"month"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
MonthEnd time.Time `json:"month_end"`
AsOf time.Time `json:"as_of"`
Partial bool `json:"partial"`
Questions int64 `json:"questions"`
LinkedQuestions int64 `json:"linked_questions"`
UnresolvedQuestions int64 `json:"unresolved_questions"`
RosterPeople int `json:"roster_people"`
UsedPeople int `json:"used_people"`
ZeroPeople int `json:"zero_people"`
MissingNames int `json:"missing_names"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
HasMore bool `json:"has_more"`
Rows []monthlyPerson `json:"rows"`
DepartmentOptions []monthlyDepartment `json:"department_options"`
}
func parseMonthlyFilter(values url.Values, now time.Time) (monthlyFilter, error) {
f := monthlyFilter{Month: values.Get("month"), Region: values.Get("region"), Department: values.Get("department_id"), Activity: values.Get("activity"), Search: values.Get("search"), Page: 1, Limit: 30}
allowed := map[string]bool{"month": true, "cutoff": true, "region": true, "department_id": true, "activity": true, "search": true, "page": true, "limit": true, "directory_id": true, "revision_id": true}
for key, v := range values {
if !allowed[key] || len(v) != 1 {
return f, dobotaiops.ErrInvalidQuery
}
}
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return f, dobotaiops.ErrUnavailable
}
f.Start, err = time.ParseInLocation("2006-01", f.Month, loc)
if err != nil || len(f.Month) != 7 || f.Start.Year() < 1 || f.Start.Format("2006-01") != f.Month || !f.Start.Before(now) {
return f, dobotaiops.ErrInvalidQuery
}
f.MonthEnd = f.Start.AddDate(0, 1, 0)
f.End = f.MonthEnd
if now.Before(f.End) {
f.End = now.In(loc)
}
if raw := values.Get("cutoff"); raw != "" {
cutoff, e := time.Parse(time.RFC3339Nano, raw)
if e != nil || !cutoff.After(f.Start) || cutoff.After(f.MonthEnd) || cutoff.After(now) {
return f, dobotaiops.ErrInvalidQuery
}
f.End = cutoff.In(loc)
}
if f.Activity == "" {
f.Activity = "all"
}
if f.Activity != "all" && f.Activity != "used" && f.Activity != "zero" {
return f, dobotaiops.ErrInvalidQuery
}
validRegion := f.Region == "" || f.Region == "unassigned" || f.Region == "cross_region"
for _, r := range qaopsorg.RegionNames {
validRegion = validRegion || f.Region == r
}
if !validRegion {
return f, dobotaiops.ErrInvalidQuery
}
if !utf8.ValidString(f.Search) || utf8.RuneCountInString(f.Search) > 100 || strings.ContainsRune(f.Search, 0) {
return f, dobotaiops.ErrInvalidQuery
}
for key, target := range map[string]*int{"page": &f.Page, "limit": &f.Limit} {
if raw := values.Get(key); raw != "" {
n, e := strconv.Atoi(raw)
if e != nil || n < 1 || n > 100000 {
return f, dobotaiops.ErrInvalidQuery
}
*target = n
}
}
if f.Limit > 100 || f.Page > 1 && (!values.Has("directory_id") || !values.Has("revision_id")) {
return f, dobotaiops.ErrInvalidQuery
}
return f, nil
}
func monthlyHasDepartment(person qaopsorg.PersonAttribution, department string, parents map[string]string) bool {
if department == "" {
return true
}
for _, id := range person.DepartmentIDs {
for cur := id; cur != ""; cur = parents[cur] {
if cur == department {
return true
}
}
}
return false
}
func monthlyResult(groups dobotaiops.UsageGroups, p *qaopsorg.Attribution, f monthlyFilter) monthlyPeople {
result := monthlyPeople{Organization: attributionVersion(p), Source: "2.0", Timezone: "Asia/Shanghai", Month: f.Month, Start: f.Start, End: f.End, MonthEnd: f.MonthEnd, AsOf: groups.AsOf, Partial: f.End.Before(f.MonthEnd), Page: f.Page, Limit: f.Limit, Rows: []monthlyPerson{}, DepartmentOptions: []monthlyDepartment{}}
totals := map[string]dobotaiops.UsageGroup{}
for _, group := range groups.Groups {
result.Questions += group.Total
person := p.Resolve(group.Identity.StaffID, group.Identity.UnionID)
if person.PersonID == "" {
result.UnresolvedQuestions += group.Total
continue
}
n := totals[person.PersonID]
n.Total += group.Total
n.Succeeded += group.Succeeded
n.Failed += group.Failed
totals[person.PersonID] = n
result.LinkedQuestions += group.Total
}
parents := map[string]string{}
for _, d := range p.Metadata.View.Departments {
parents[d.ID] = d.ParentID
result.DepartmentOptions = append(result.DepartmentOptions, monthlyDepartment{ID: d.ID, Path: d.Path})
}
rows := []monthlyPerson{}
people := p.Members()
result.RosterPeople = len(people)
search := strings.ToLower(f.Search)
for _, person := range people {
n := totals[person.PersonID]
if n.Total > 0 {
result.UsedPeople++
} else {
result.ZeroPeople++
}
if person.Name == "" {
result.MissingNames++
}
if f.Region != "" && f.Region != person.Region && f.Region != person.State {
continue
}
if !monthlyHasDepartment(person, f.Department, parents) {
continue
}
if f.Activity == "used" && n.Total == 0 || f.Activity == "zero" && n.Total > 0 {
continue
}
if search != "" && !strings.Contains(strings.ToLower(person.Name), search) && !strings.Contains(strings.ToLower(strings.Join(person.Departments, ";")), search) {
continue
}
// This is a stable row key, not an anonymisation claim or a new user identifier.
sum := sha256.Sum256([]byte(p.Metadata.DirectoryID + "\x00" + person.PersonID))
rows = append(rows, monthlyPerson{Key: "person-" + hex.EncodeToString(sum[:]), Name: person.Name, State: person.State, Region: person.Region, Departments: person.Departments, Questions: n.Total, Succeeded: n.Succeeded, Failed: n.Failed, Other: n.Total - n.Succeeded - n.Failed})
}
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
if a.Name == "" && b.Name != "" {
return false
}
if a.Name != "" && b.Name == "" {
return true
}
if a.Name != b.Name {
return a.Name < b.Name
}
return a.Key < b.Key
})
result.Total = len(rows)
start := (f.Page - 1) * f.Limit
if start < len(rows) {
end := min(start+f.Limit, len(rows))
result.Rows = rows[start:end]
result.HasMore = end < len(rows)
}
return result
}
// Names remain in the bounded read-query body, not URLs/access logs. The source
// reader and governance store are still strictly read-only on this route.
func monthlyRequestValues(w http.ResponseWriter, r *http.Request) (url.Values, error) {
values := url.Values{}
if r.URL.RawQuery != "" || r.Header.Get("X-Governance-Action") != "qa-usage-read" {
return nil, dobotaiops.ErrInvalidQuery
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
token, err := decoder.Token()
if err != nil || token != json.Delim('{') {
return nil, dobotaiops.ErrInvalidQuery
}
for decoder.More() {
token, err = decoder.Token()
key, ok := token.(string)
if err != nil || !ok || values.Has(key) {
return nil, dobotaiops.ErrInvalidQuery
}
var raw json.RawMessage
if decoder.Decode(&raw) != nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, dobotaiops.ErrInvalidQuery
}
if key == "page" || key == "limit" {
var n int
if json.Unmarshal(raw, &n) != nil {
return nil, dobotaiops.ErrInvalidQuery
}
values.Set(key, strconv.Itoa(n))
} else {
var text string
if json.Unmarshal(raw, &text) != nil {
return nil, dobotaiops.ErrInvalidQuery
}
values.Set(key, text)
}
}
if _, err = decoder.Token(); err != nil {
return nil, dobotaiops.ErrInvalidQuery
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return nil, dobotaiops.ErrInvalidQuery
}
return values, nil
}
func qaMonthlyPeople(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
values, err := monthlyRequestValues(w, r)
if err != nil {
qaOpsError(w, err)
return
}
f, err := parseMonthlyFilter(values, config.Now())
if err != nil {
qaOpsError(w, err)
return
}
p, err := recordAttribution(r.Context(), config)
if err != nil {
qaOpsError(w, err)
return
}
if p.Metadata.State != "ready" {
writeJSON(w, 409, map[string]string{"code": "directory_missing", "error": "尚无完整通讯录,请先从区域使用页同步;不能生成零记录人员名单"})
return
}
if values.Has("directory_id") || values.Has("revision_id") {
if !values.Has("directory_id") || !values.Has("revision_id") {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
if values.Get("directory_id") != p.Metadata.DirectoryID || values.Get("revision_id") != p.Metadata.RevisionID {
writeJSON(w, 409, map[string]string{"code": "organization_changed", "error": "通讯录或区域配置已变化,请重新查询个人月明细"})
return
}
}
if f.Department != "" {
found := false
for _, d := range p.Metadata.View.Departments {
found = found || d.ID == f.Department
}
if !found {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
}
reader, ok := config.QAOps.(qaUsageReader)
if !ok || config.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
groups, err := reader.ReadUsageGroups(r.Context(), dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: config.QAOpsTenantKey, Start: f.Start, End: f.End}})
if err != nil {
qaOpsError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": monthlyResult(groups, p, f)})
}
+221
View File
@@ -0,0 +1,221 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/http/httptest"
"strconv"
"net/url"
"strings"
"testing"
"time"
)
type monthlyReaderSpy struct {
attributedReader
query dobotaiops.RecordQuery
calls int
}
func (s *monthlyReaderSpy) ReadUsageGroups(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error) {
s.calls++
s.query = q
return s.attributedReader.ReadUsageGroups(ctx, q)
}
func monthlyRequest(h http.Handler, path, token string) *httptest.ResponseRecorder {
u, _ := url.Parse(path)
body := map[string]any{}
for k, v := range u.Query() {
if len(v) != 1 {
body[k] = v
continue
}
if k == "page" || k == "limit" {
if n, e := strconv.Atoi(v[0]); e == nil {
body[k] = n
} else {
body[k] = v[0]
}
} else {
body[k] = v[0]
}
}
raw, _ := json.Marshal(body)
r := httptest.NewRequest(http.MethodPost, u.Path, bytes.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Governance-Action", "qa-usage-read")
if token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func monthlyHandler(t *testing.T) (http.Handler, *attributionOrgSpy, *monthlyReaderSpy) {
t.Helper()
d := qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "示例根"}, {ID: "child", ParentID: "root", Name: "子部门"}}, People: []qaopsorg.Person{{ID: "fixture-private-staff", Name: "同名示例", UnionID: "fixture-private-union", DepartmentIDs: []string{"root"}}, {ID: "private-zero", Name: "同名示例", DepartmentIDs: []string{"child"}}, {ID: "private-cross", Name: "跨区域示例", DepartmentIDs: []string{"root", "child"}}, {ID: "private-unnamed", DepartmentIDs: []string{"child"}}}}
now := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
p, err := qaopsorg.BuildAttribution(qaopsorg.State{State: "ready", DirectoryID: "monthly-directory", DirectorySource: "weekly_report_live", DirectorySyncedAt: &now, RevisionID: "monthly-regions", Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "国内工业"}, {DepartmentID: "child", Region: "APAC"}}}, d)
if err != nil {
t.Fatal(err)
}
org := &attributionOrgSpy{projection: p}
reader := &monthlyReaderSpy{}
auth := newFakeAuthStore()
auth.sessions["attribution-admin"] = "usr-boss"
auth.sessions["attribution-viewer"] = "usr-eye"
h, err := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: reader, QAOpsTenantKey: "fixed", QAOrg: org, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return h, org, reader
}
func TestQAMonthlyPeopleIncludesZeroUseAndKeepsUnresolvedEvents(t *testing.T) {
h, _, reader := monthlyHandler(t)
path := "/api/v1/qa-ops/usage/people?month=2026-10&limit=2"
w := monthlyRequest(h, path, "attribution-admin")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var result struct {
Data struct {
Month string
Timezone string
Partial bool
Questions int64
LinkedQuestions int64 `json:"linked_questions"`
UnresolvedQuestions int64 `json:"unresolved_questions"`
RosterPeople int `json:"roster_people"`
UsedPeople int `json:"used_people"`
ZeroPeople int `json:"zero_people"`
MissingNames int `json:"missing_names"`
Total int
HasMore bool `json:"has_more"`
Rows []struct {
Key string
Name string
Questions int64
State string
}
}
}
if json.Unmarshal(w.Body.Bytes(), &result) != nil {
t.Fatal("invalid monthly JSON")
}
v := result.Data
if v.Month != "2026-10" || v.Timezone != "Asia/Shanghai" || !v.Partial || v.Questions != 7 || v.LinkedQuestions != 5 || v.UnresolvedQuestions != 2 || v.RosterPeople != 4 || v.UsedPeople != 1 || v.ZeroPeople != 3 || v.MissingNames != 1 || v.Total != 4 || len(v.Rows) != 2 || !v.HasMore {
t.Fatal("monthly roster, zero count or alias dedup wrong", w.Body.String())
}
if reader.query.Window.TenantKey != "fixed" || reader.query.Window.Start.Format(time.RFC3339) != "2026-10-01T00:00:00+08:00" || reader.query.Window.End.Format(time.RFC3339) != "2026-10-17T16:00:00+08:00" {
t.Fatal("month was not normalized to the named timezone")
}
if strings.Contains(w.Body.String(), "private-") || strings.Contains(w.Body.String(), "performance") || strings.Contains(w.Body.String(), "staff_id") {
t.Fatal("raw identities or unapproved verdict exposed")
}
page2 := monthlyRequest(h, path+"&page=2&directory_id=monthly-directory&revision_id=monthly-regions", "attribution-admin")
if page2.Code != 200 {
t.Fatal(page2.Body.String())
}
for _, extra := range []string{"&activity=zero", "&region=cross_region", "&department_id=child", "&search=" + url.QueryEscape("子部门")} {
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+extra, "attribution-admin")
if r.Code != 200 {
t.Fatal("valid monthly filter failed", r.Body.String())
}
}
}
func TestQAMonthlyScopeAndVersionFailClosed(t *testing.T) {
h, org, reader := monthlyHandler(t)
base := "/api/v1/qa-ops/usage/people?month=2026-10"
for _, tc := range []struct {
suffix, token string
status int
}{{"", "", 401}, {"", "attribution-viewer", 403}, {"&tenant=forged", "attribution-admin", 400}, {"&threshold=5", "attribution-admin", 400}, {"&month=duplicate", "attribution-admin", 400}, {"&page=0", "attribution-admin", 400}, {"&limit=101", "attribution-admin", 400}, {"&activity=under5", "attribution-admin", 400}, {"&region=invalid", "attribution-admin", 400}, {"&department_id=invalid", "attribution-admin", 400}, {"&page=2", "attribution-admin", 400}, {"&page=2&directory_id=changed&revision_id=monthly-regions", "attribution-admin", 409}, {"&cutoff=2026-11-01T00:00:00Z", "attribution-admin", 400}} {
w := monthlyRequest(h, base+tc.suffix, tc.token)
if w.Code != tc.status {
t.Fatalf("monthly %s: %d want %d", tc.suffix, w.Code, tc.status)
}
}
for _, month := range []string{"2026-1", " 2026-10", "2026-13", "0000-01", "2026-11"} {
r := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month="+url.QueryEscape(month), "attribution-admin")
if r.Code != 400 {
t.Fatal("invalid/future month accepted")
}
}
if reader.calls != 0 {
t.Fatal("invalid or unauthorized monthly request reached source")
}
org.projection, _ = qaopsorg.BuildAttribution(qaopsorg.State{State: "empty"}, qaopsorg.Directory{})
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 409 {
t.Fatal("no roster presented as zero-use list")
}
org.err = qaopsorg.ErrUnavailable
if w := monthlyRequest(h, base, "attribution-admin"); w.Code != 503 {
t.Fatal("store failure hidden as empty")
}
}
func TestQAMonthlyPersonFiltersDoNotGuessNamesOrDuplicateDescendantMembers(t *testing.T) {
h, _, _ := monthlyHandler(t)
for _, tc := range []struct {
filter string
total int
}{{"&activity=used", 1}, {"&activity=zero", 3}, {"&region=cross_region", 1}, {"&region=APAC", 2}, {"&department_id=child", 3}, {"&department_id=root", 4}, {"&search=" + url.QueryEscape("同名示例"), 2}, {"&search=" + url.QueryEscape("子部门"), 3}, {"&search=%25_", 0}} {
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2026-10"+tc.filter, "attribution-admin")
var payload struct{ Data monthlyPeople }
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &payload) != nil || payload.Data.Total != tc.total || payload.Data.Questions != 7 {
t.Fatal("person-scope filter did not preserve source totals or person dedup", tc.filter)
}
for _, r := range payload.Data.Rows {
if tc.filter == "&activity=zero" && r.Questions != 0 {
t.Fatal("used member labeled zero")
}
}
}
}
func TestQAMonthlyReadBodyRejectsDuplicateNullTrailingAndOversizedInput(t *testing.T) {
h, _, reader := monthlyHandler(t)
for _, body := range []string{`{"month":"2026-10","month":"2026-10"}`, `{"month":"2026-10","search":null}`, `{"month":"2026-10","page":"1"}`, `{"month":"2026-10"}{}`, `[]`, `{"month":"2026-10","search":"` + strings.Repeat("x", 9000) + `"}`} {
r := httptest.NewRequest("POST", "/api/v1/qa-ops/usage/people", strings.NewReader(body))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
r.Header.Set("X-Governance-Action", "qa-usage-read")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 400 {
t.Fatal("invalid read body reached source", w.Code)
}
}
for _, method := range []string{"POST", "GET"} {
r := httptest.NewRequest(method, "/api/v1/qa-ops/usage/people?search=sensitive-example", strings.NewReader(`{"month":"2026-10"}`))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "attribution-admin"})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 400 && w.Code != 405 {
t.Fatal("URL-based name query accepted")
}
}
if reader.calls != 0 {
t.Fatal("invalid body read production source")
}
}
func TestQAMonthlyPastLeapMonthUsesClosedCalendarWindow(t *testing.T) {
h, _, reader := monthlyHandler(t)
w := monthlyRequest(h, "/api/v1/qa-ops/usage/people?month=2024-02", "attribution-admin")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if reader.query.Window.End.Format(time.RFC3339) != "2024-03-01T00:00:00+08:00" || reader.query.Window.End.Sub(reader.query.Window.Start) != 29*24*time.Hour {
t.Fatal("leap month boundary incorrect")
}
var raw struct{ Data struct{ Partial bool } }
_ = json.Unmarshal(w.Body.Bytes(), &raw)
if raw.Data.Partial {
t.Fatal("past natural month mislabeled as current progress")
}
}
+1
View File
@@ -175,6 +175,7 @@ func NewHandler(config Config) (http.Handler, error) {
config.NewID = newID
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/qa-ops/usage/people", func(w http.ResponseWriter, r *http.Request) { qaMonthlyPeople(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/usage", func(w http.ResponseWriter, r *http.Request) { qaOpsUsage(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/organization/sync", func(w http.ResponseWriter, r *http.Request) { qaOrganizationSync(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/organization/sync", func(w http.ResponseWriter, r *http.Request) { qaOrganizationSync(w, r, config) })
+19 -5
View File
@@ -6,10 +6,12 @@ import (
)
type PersonAttribution struct {
State string `json:"state"`
PersonID string `json:"-"`
Departments []string `json:"departments"`
Region string `json:"region"`
Name string `json:"name"`
DepartmentIDs []string `json:"-"`
State string `json:"state"`
PersonID string `json:"-"`
Departments []string `json:"departments"`
Region string `json:"region"`
}
type Attribution struct {
Metadata State
@@ -17,6 +19,18 @@ type Attribution struct {
unions map[string]string
}
func (p *Attribution) Members() []PersonAttribution {
result := []PersonAttribution{}
if p == nil {
return result
}
for _, person := range p.people {
result = append(result, person)
}
sort.Slice(result, func(i, j int) bool { return result[i].PersonID < result[j].PersonID })
return result
}
func BuildAttribution(state State, directory Directory) (*Attribution, error) {
p := &Attribution{Metadata: state, people: map[string]PersonAttribution{}, unions: map[string]string{}}
if state.State == "empty" {
@@ -43,7 +57,7 @@ func BuildAttribution(state State, directory Directory) (*Attribution, error) {
regions[d.Region] = true
}
}
item := PersonAttribution{State: "unassigned", PersonID: person.ID, Departments: []string{}}
item := PersonAttribution{Name: person.Name, DepartmentIDs: append([]string{}, person.DepartmentIDs...), State: "unassigned", PersonID: person.ID, Departments: []string{}}
for path := range paths {
item.Departments = append(item.Departments, path)
}
+30
View File
@@ -0,0 +1,30 @@
package qaopsorg
import (
"encoding/json"
"strings"
"testing"
)
func TestMembersKeepNamedAndUnnamedRosterPeopleWithoutRawIdentifiers(t *testing.T) {
d := Directory{Departments: []Department{{ID: "root", Name: "fixture"}}, People: []Person{{ID: "private-a", Name: "<源端姓名>", UnionID: "private-union", DepartmentIDs: []string{"root"}}, {ID: "private-zero", DepartmentIDs: []string{"root"}}}}
p, err := BuildAttribution(State{State: "ready", DirectoryID: "directory"}, d)
if err != nil {
t.Fatal(err)
}
members := p.Members()
if len(members) != 2 {
t.Fatal("zero-use roster member omitted")
}
if p.Resolve("private-a", "").Name != "<源端姓名>" || p.Resolve("private-zero", "").Name != "" {
t.Fatal("display name lost or guessed")
}
raw, _ := json.Marshal(members)
if strings.Contains(string(raw), "private-") || strings.Contains(string(raw), "department_ids") {
t.Fatal("internal identifiers serialized")
}
d.People[0].Name = "bad\x00name"
if _, err = BuildAttribution(State{State: "ready", DirectoryID: "directory"}, d); err == nil {
t.Fatal("invalid display name accepted")
}
}
+2 -1
View File
@@ -15,6 +15,7 @@ type Department struct {
Name string `json:"name"`
}
type Person struct {
Name string `json:"name,omitempty"`
ID string `json:"id"`
UnionID string `json:"union_id,omitempty"`
DepartmentIDs []string `json:"department_ids"`
@@ -107,7 +108,7 @@ func Preview(directory Directory, rules []Rule) (PreviewResult, error) {
}
people := map[string]bool{}
for _, person := range directory.People {
if !validID(person.ID) || people[person.ID] || len(person.DepartmentIDs) > 1000 {
if !validID(person.ID) || people[person.ID] || len(person.DepartmentIDs) > 1000 || len(person.Name) > 1000 || strings.ContainsRune(person.Name, 0) {
return result, ErrInvalid
}
people[person.ID] = true
+4 -1
View File
@@ -36,6 +36,9 @@ def _validate_directory(departments, people):
seen.add(current)
current = int(departments[current].get('parent_id') or 0)
for identity, person in people.items():
name = person.get('name')
if name is not None and (not isinstance(name, str) or len(name.encode('utf-8')) > 1000 or '\x00' in name):
raise DirectorySyncError('directory_integrity_failed')
if not identity or str(person.get('user_id') or '') != str(identity):
raise DirectorySyncError('directory_integrity_failed')
if not person.get('department_ids') or any(int(d) not in departments for d in person['department_ids']):
@@ -78,7 +81,7 @@ def collect_directory(client, fetch_departments, fetch_users, get_detail, logger
# List membership provides stable staff IDs; do not reuse the old
# guessed primary department, employment policy or openId fallback.
'people': [
{'id': str(identity), 'union_id': str((p.get('raw_payload') or {}).get('unionid') or (p.get('raw_payload') or {}).get('unionId') or ''), 'department_ids': [str(d) for d in sorted(p['department_ids'])]}
{'id': str(identity), **({'name': p['name']} if p.get('name') else {}), 'union_id': str((p.get('raw_payload') or {}).get('unionid') or (p.get('raw_payload') or {}).get('unionId') or ''), 'department_ids': [str(d) for d in sorted(p['department_ids'])]}
for identity, p in sorted(people.items())
],
},
+7
View File
@@ -24,6 +24,13 @@ def root_detail(client,logger,department_id):
return {'id':department_id,'parent_id':0,'name':'真实源端返回的示例根部门'},False,False
class DirectoryBridgeTests(unittest.TestCase):
def test_real_display_name_is_retained_without_guessing_or_requiring_it(self):
def users(*args,**kwargs):
return {'u':{'user_id':'u','name':'<示例员工>','department_ids':{10},'raw_payload':{}}},set(),set()
result=sync.collect_directory(FakeClient(),full_departments,users,root_detail,logging.getLogger('fixture'))
self.assertEqual(result['directory']['people'][0].get('name'),'<示例员工>')
result=sync.collect_directory(FakeClient(),full_departments,full_users,root_detail,logging.getLogger('fixture'))
self.assertNotIn('name',result['directory']['people'][0])
def test_fetches_the_real_missing_root_and_preserves_stable_identities(self):
result=sync.collect_directory(FakeClient(),full_departments,full_users,root_detail,logging.getLogger('fixture'))
self.assertTrue(result['complete'])
+37
View File
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, it, expect } from 'vitest'
import { QAMonthlyView } from './QAMonthly'
import { monthlyFixture } from './qaMonthly.fixture'
import { parseQAMonthly } from './qaMonthly'
describe('personal monthly view', () => {
it('shows a partial month as progress, escapes source names and does not imply eligibility', () => {
const html = renderToStaticMarkup(
<QAMonthlyView
data={parseQAMonthly(monthlyFixture)}
loading={false}
error=""
onRetry={() => {}}
onPage={() => {}}
/>
)
for (const text of ['本源零记录', '当月进度', '姓名未同步', '不是考核名单', '当前通讯录', '未关联'])
expect(html).toContain(text)
expect(html).not.toContain('<script>')
expect(html).not.toContain('达标率')
expect(html).not.toContain('绩效合格')
})
it('does not display stale rows or fake zeros on errors', () => {
const html = renderToStaticMarkup(
<QAMonthlyView
data={parseQAMonthly(monthlyFixture)}
loading={false}
error="统计不可用"
onRetry={() => {}}
onPage={() => {}}
/>
)
expect(html).toContain('统计不可用')
expect(html).not.toContain('<table')
expect(html).not.toContain('共 0')
})
})
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useState } from 'react'
import type { FormEvent } from 'react'
import { beijingMonth, fetchQAMonthly } from './qaMonthly'
import type { MonthlyPeople, MonthlyQuery } from './qaMonthly'
import { usageLabels } from './qaUsage'
const stamp = (v: string) => new Date(v).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
interface ViewProps {
data: MonthlyPeople | null
loading: boolean
error: string
onRetry: () => void
onPage: (page: number) => void
}
export function QAMonthlyView({ data, loading, error, onRetry, onPage }: ViewProps) {
return (
<section className="qa-ops-results qa-monthly-results" aria-label="个人月度使用明细">
{loading ? (
<p className="qa-ops-muted" role="status">
正在读取个人月明细…
</p>
) : null}
{error ? (
<div className="error-banner" role="alert">
{error}{' '}
<button type="button" onClick={onRetry} disabled={loading}>
重新查询
</button>
</div>
) : null}
{data && !loading && !error ? (
<>
<div className="qa-ops-result-bar">
<strong>
{data.month} · {data.partial ? '当月进度 / 截止时点' : '完整自然月时间范围'}
</strong>
<span>仅 DobotAI 2.0 · 北京时间</span>
</div>
<p className="qa-ops-muted">
全名册:{data.roster_people} 人 · 已关联提问 {data.used_people} 人 · 本源零记录 {data.zero_people}{' '}
人。名册来自当前通讯录,不是在职证明,也不是考核名单。
</p>
<p className="qa-ops-muted">
本月范围内 {data.questions} 条问答,已关联 {data.linked_questions} 条,未关联 {data.unresolved_questions}{' '}
条。0 仅表示本源未关联到提问,不等于实际未使用;成功/失败不是答案正确性结论。
</p>
{data.missing_names > 0 ? (
<p className="qa-ops-warning">
{data.missing_names} 人姓名未同步,请重新同步通讯录;不根据姓名猜身份,不用原始标识代替姓名。
</p>
) : null}
<details className="qa-ops-organization">
<summary>统计范围与组织版本</summary>
<p>
范围:{stamp(data.start)} 至 {stamp(data.end)}(不含结束)。当前通讯录同步于{' '}
{stamp(data.organization.synced_at!)}
;不代表提问时部门或正式绩效分母。自然月时间范围完整不代表已归档全部历史。目录:
{data.organization.directory_id};区域:{data.organization.revision_id || '未保存'}。
</p>
</details>
<div className="qa-ops-result-bar">
<span>
符合人员筛选 {data.total} 人 · 第 {data.page} 页
</span>
<span>上方全名册指标不随人员筛选变化</span>
</div>
{data.rows.length ? (
<div className="qa-ops-table-scroll" aria-label="个人月明细表格滚动区" tabIndex={0}>
<table className="qa-ops-table qa-monthly-table">
<thead>
<tr>
{['姓名', '当前部门完整路径', '区域 / 待核对状态', '问答次数', '成功', '失败', '其他'].map((v) => (
<th key={v}>{v}</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<tr key={row.key}>
<td>{row.name || '姓名未同步'}</td>
<td>{row.departments.join(';') || '无部门记录'}</td>
<td>{usageLabels[row.region || row.state]}</td>
<td>{row.questions}</td>
<td>{row.succeeded}</td>
<td>{row.failed}</td>
<td>{row.other}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="qa-ops-empty">没有符合人员筛选的记录,请调整条件;这不是通讯录为空。</p>
)}
<footer className="qa-ops-page-bar">
<span>本次读取:{stamp(data.as_of)}</span>
<div>
{data.page > 1 ? (
<button type="button" onClick={() => onPage(data.page - 1)}>
上一页
</button>
) : null}
{data.has_more ? (
<button type="button" onClick={() => onPage(data.page + 1)}>
下一页
</button>
) : null}
</div>
</footer>
</>
) : null}
</section>
)
}
export function QAMonthly() {
const [draft, setDraft] = useState(() => ({
month: beijingMonth(),
cutoff: '',
region: '',
department_id: '',
activity: 'all',
search: '',
}))
const [request, setRequest] = useState<MonthlyQuery>({ month: draft.month })
const [data, setData] = useState<MonthlyPeople | null>(null),
[options, setOptions] = useState<MonthlyPeople['department_options']>([]),
[loading, setLoading] = useState(false),
[error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
setLoading(true)
setData(null)
setError('')
fetchQAMonthly(request, controller.signal)
.then((next) => {
if (!controller.signal.aborted) {
setData(next)
setOptions(next.department_options)
}
})
.catch((reason) => {
if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : '个人月明细读取失败')
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false)
})
return () => controller.abort()
}, [request])
const change = (key: keyof typeof draft, value: string) => {
setDraft({ ...draft, [key]: value })
setData(null)
setError('')
}
const query = () => {
try {
setRequest({
...draft,
cutoff: draft.cutoff ? new Date(draft.cutoff + '+08:00').toISOString() : undefined,
page: 1,
})
} catch {
setData(null)
setError('截止时间无效,请按北京时间填写')
}
}
const submit = (event: FormEvent) => {
event.preventDefault()
query()
}
const page = (n: number) => {
if (data)
setRequest({
...request,
cutoff: data.end,
page: n,
directory_id: data.organization.directory_id,
revision_id: data.organization.revision_id,
})
}
return (
<>
<form className="qa-ops-query qa-monthly-query" onSubmit={submit} aria-label="个人月明细筛选">
<label>
自然月(北京时间)
<input
type="month"
required
value={draft.month}
disabled={loading}
onChange={(e) => change('month', e.target.value)}
/>
</label>
<label>
截止时间(可留空)
<input
type="datetime-local"
value={draft.cutoff}
disabled={loading}
onChange={(e) => change('cutoff', e.target.value)}
/>
</label>
<label>
区域 / 状态
<select value={draft.region} disabled={loading} onChange={(e) => change('region', e.target.value)}>
<option value="">全部区域 / 状态</option>
{['国内工业', 'APAC', 'EMEA', 'America', 'unassigned', 'cross_region'].map((key) => (
<option key={key} value={key}>
{usageLabels[key]}
</option>
))}
</select>
</label>
<label>
部门(含子部门)
<select
value={draft.department_id}
disabled={loading}
onChange={(e) => change('department_id', e.target.value)}
>
<option value="">全部部门</option>
{options.map((d) => (
<option value={d.id} key={d.id}>
{d.path}
</option>
))}
</select>
</label>
<label>
使用记录
<select value={draft.activity} disabled={loading} onChange={(e) => change('activity', e.target.value)}>
<option value="all">全部人员</option>
<option value="used">已关联提问</option>
<option value="zero">本源零记录</option>
</select>
</label>
<label>
姓名 / 部门路径
<input
type="search"
maxLength={100}
value={draft.search}
disabled={loading}
onChange={(e) => change('search', e.target.value)}
/>
</label>
<button type="submit" disabled={loading}>
查询个人月明细
</button>
</form>
<QAMonthlyView data={data} loading={loading} error={error} onRetry={query} onPage={page} />
</>
)
}
+15 -1
View File
@@ -6,6 +6,7 @@ import type { QAQuery, QARecord, QARecordPage, QAStatus } from './qaOps'
import './qaOps.css'
import { QARegions } from './QARegions'
import { QAUsage } from './QAUsage'
import { QAMonthly } from './QAMonthly'
import { usageLabels } from './qaUsage'
interface ViewProps {
@@ -276,6 +277,7 @@ function initialDraft() {
export function QAOperations() {
const [view, setView] = useState<'records' | 'regions' | 'usage'>('records')
const [usageMode, setUsageMode] = useState<'regions' | 'monthly'>('regions')
const auth = useOptionalAuth()
const canRead = auth?.identity?.role === 'admin'
const [draft, setDraft] = useState(initialDraft)
@@ -427,7 +429,19 @@ export function QAOperations() {
区域使用
</button>
</nav>
{view === 'regions' ? (
{view === 'usage' ? (
<nav className="qa-region-tabs" aria-label="使用统计视图">
<button type="button" aria-pressed={usageMode === 'regions'} onClick={() => setUsageMode('regions')}>
区域汇总
</button>
<button type="button" aria-pressed={usageMode === 'monthly'} onClick={() => setUsageMode('monthly')}>
个人月明细
</button>
</nav>
) : null}
{view === 'usage' && usageMode === 'monthly' ? (
<QAMonthly />
) : view === 'regions' ? (
<QARegions />
) : (
<>
+54
View File
@@ -0,0 +1,54 @@
export const monthlyFixture = {
organization: {
state: 'ready',
directory_id: 'd',
directory_source: 'weekly_report_live',
synced_at: '2026-10-17T07:59:50Z',
revision_id: 'r',
basis: 'current_directory',
},
source: '2.0',
timezone: 'Asia/Shanghai',
month: '2026-10',
start: '2026-10-01T00:00:00+08:00',
end: '2026-10-17T16:00:00+08:00',
month_end: '2026-11-01T00:00:00+08:00',
as_of: '2026-10-17T08:00:01Z',
partial: true,
questions: 3,
linked_questions: 2,
unresolved_questions: 1,
roster_people: 2,
used_people: 1,
zero_people: 1,
missing_names: 1,
total: 2,
page: 1,
limit: 30,
has_more: false,
department_options: [{ id: 'root', path: '示例部门' }],
rows: [
{
key: 'person-a',
name: '<script>示例姓名</script>',
departments: ['示例部门'],
region: 'APAC',
state: 'matched',
questions: 2,
succeeded: 1,
failed: 1,
other: 0,
},
{
key: 'person-b',
name: '',
departments: ['示例部门'],
region: '',
state: 'unassigned',
questions: 0,
succeeded: 0,
failed: 0,
other: 0,
},
],
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest'
import { parseQAMonthly, fetchQAMonthly, beijingMonth } from './qaMonthly'
import { monthlyFixture } from './qaMonthly.fixture'
describe('personal natural-month contracts', () => {
it('sends personnel filters only in a same-origin no-store read body', async () => {
const fake = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: monthlyFixture }), { status: 200 }))
vi.stubGlobal('fetch', fake)
try {
await fetchQAMonthly({ month: '2026-10', search: '示例敏感筛选' })
expect(fake.mock.calls[0][0]).toBe('/api/v1/qa-ops/usage/people')
expect(fake.mock.calls[0][1]).toMatchObject({ method: 'POST', credentials: 'same-origin', cache: 'no-store' })
expect(JSON.parse(fake.mock.calls[0][1].body).search).toBe('示例敏感筛选')
} finally {
vi.unstubAllGlobals()
}
})
it('selects the month in Beijing rather than the browser timezone', () => {
expect(beijingMonth(new Date('2026-09-30T16:00:00Z'))).toBe('2026-10')
})
it('keeps named and missing-name zero-use members with a separate unresolved bucket', () => {
expect(parseQAMonthly(monthlyFixture)).toMatchObject({ zero_people: 1, missing_names: 1, unresolved_questions: 1 })
})
it('rejects nonconserving totals, fabricated pagination and inconsistent month boundaries', () => {
for (const invalid of [
{ ...monthlyFixture, zero_people: 0 },
{ ...monthlyFixture, linked_questions: 3 },
{ ...monthlyFixture, rows: [monthlyFixture.rows[0]] },
{ ...monthlyFixture, partial: false },
{ ...monthlyFixture, source: '1.0' },
{ ...monthlyFixture, month: '2026-1' },
{ ...monthlyFixture, month_end: '2026-11-02T00:00:00+08:00' },
{ ...monthlyFixture, rows: [monthlyFixture.rows[0], monthlyFixture.rows[0]] },
])
expect(() => parseQAMonthly(invalid)).toThrow()
})
})
+200
View File
@@ -0,0 +1,200 @@
import { parseQAOrganizationVersion } from './qaOps'
import type { QAOrganizationVersion } from './qaOps'
import { getPayload } from './qaOps'
export interface MonthlyQuery {
month: string
cutoff?: string
region?: string
department_id?: string
activity?: string
search?: string
page?: number
limit?: number
directory_id?: string
revision_id?: string
}
export interface MonthlyPerson {
key: string
name: string
departments: string[]
region: string
state: 'matched' | 'unassigned' | 'cross_region'
questions: number
succeeded: number
failed: number
other: number
}
export interface MonthlyPeople {
organization: QAOrganizationVersion
source: '2.0'
timezone: 'Asia/Shanghai'
month: string
start: string
end: string
month_end: string
as_of: string
partial: boolean
questions: number
linked_questions: number
unresolved_questions: number
roster_people: number
used_people: number
zero_people: number
missing_names: number
total: number
page: number
limit: number
has_more: boolean
rows: MonthlyPerson[]
department_options: { id: string; path: string }[]
}
const fail = (): never => {
throw new Error('个人月明细返回格式异常,未展示可能失真的统计')
}
const obj = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : fail()
const text = (v: unknown): string => (typeof v === 'string' ? v : fail())
const count = (v: unknown): number => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : fail())
const flag = (v: unknown): boolean => (typeof v === 'boolean' ? v : fail())
const instant = (v: unknown): string => {
const t = text(v)
return Number.isFinite(Date.parse(t)) ? t : fail()
}
const regions = ['国内工业', 'APAC', 'EMEA', 'America']
export function parseQAMonthly(value: unknown): MonthlyPeople {
const v = obj(value),
organization = parseQAOrganizationVersion(v.organization)
if (organization.state !== 'ready' || v.source !== '2.0' || v.timezone !== 'Asia/Shanghai') fail()
const month = text(v.month)
if (!/^(?!0000)\d{4}-(0[1-9]|1[0-2])$/.test(month)) fail()
const start = instant(v.start),
end = instant(v.end),
month_end = instant(v.month_end),
as_of = instant(v.as_of),
partial = flag(v.partial)
const expectedEnd = new Date(`${month}-01T00:00:00Z`)
expectedEnd.setUTCMonth(expectedEnd.getUTCMonth() + 1)
if (
Date.parse(start) !== Date.parse(`${month}-01T00:00:00+08:00`) ||
Date.parse(month_end) !== expectedEnd.getTime() - 8 * 60 * 60 * 1000 ||
Date.parse(end) <= Date.parse(start) ||
Date.parse(end) > Date.parse(month_end) ||
partial !== Date.parse(end) < Date.parse(month_end)
)
fail()
const questions = count(v.questions),
linked_questions = count(v.linked_questions),
unresolved_questions = count(v.unresolved_questions),
roster_people = count(v.roster_people),
used_people = count(v.used_people),
zero_people = count(v.zero_people),
missing_names = count(v.missing_names),
total = count(v.total),
page = count(v.page),
limit = count(v.limit),
has_more = flag(v.has_more)
if (
questions !== linked_questions + unresolved_questions ||
roster_people !== used_people + zero_people ||
missing_names > roster_people ||
total > roster_people ||
page < 1 ||
page > 100000 ||
limit < 1 ||
limit > 100
)
fail()
if (!Array.isArray(v.rows) || !Array.isArray(v.department_options)) return fail()
const keys = new Set<string>()
const rows = v.rows.map((raw: unknown) => {
const r = obj(raw),
key = text(r.key),
name = text(r.name),
region = text(r.region),
state = text(r.state)
if (
!key ||
keys.has(key) ||
name.length > 1000 ||
!['matched', 'unassigned', 'cross_region'].includes(state) ||
(state === 'matched' && !regions.includes(region)) ||
(state !== 'matched' && region !== '')
)
fail()
keys.add(key)
if (!Array.isArray(r.departments)) return fail()
const q = count(r.questions),
s = count(r.succeeded),
f = count(r.failed),
o = count(r.other)
if (s + f + o !== q) fail()
return {
key,
name,
region,
state: state as MonthlyPerson['state'],
departments: r.departments.map(text),
questions: q,
succeeded: s,
failed: f,
other: o,
}
})
const offset = (page - 1) * limit
if (
rows.length !== Math.max(0, Math.min(limit, total - offset)) ||
has_more !== offset + rows.length < total ||
rows.reduce((n, r) => n + r.questions, 0) > linked_questions
)
fail()
const ids = new Set<string>()
const department_options = v.department_options.map((raw: unknown) => {
const d = obj(raw),
id = text(d.id),
path = text(d.path)
if (!id || !path || ids.has(id)) fail()
ids.add(id)
return { id, path }
})
return {
organization,
source: '2.0',
timezone: 'Asia/Shanghai',
month,
start,
end,
month_end,
as_of,
partial,
questions,
linked_questions,
unresolved_questions,
roster_people,
used_people,
zero_people,
missing_names,
total,
page,
limit,
has_more,
rows,
department_options,
}
}
export async function fetchQAMonthly(q: MonthlyQuery, signal?: AbortSignal): Promise<MonthlyPeople> {
return parseQAMonthly(
await getPayload('/api/v1/qa-ops/usage/people', signal, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-usage-read' },
body: JSON.stringify(q),
})
)
}
export function beijingMonth(now = new Date()): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
}).formatToParts(now)
return `${parts.find((p) => p.type === 'year')!.value}-${parts.find((p) => p.type === 'month')!.value}`
}
+48
View File
@@ -7,6 +7,54 @@
height: calc(100dvh - 44px);
overflow: hidden;
}
.qa-ops-query.qa-monthly-query {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.qa-monthly-query label {
min-width: 0;
}
.qa-monthly-query select,
.qa-monthly-query input {
width: 100%;
min-width: 0;
}
.qa-monthly-query select {
min-height: 36px;
padding: 7px;
color: var(--ink-strong);
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
}
.qa-monthly-results > .qa-ops-muted {
margin: 6px 14px;
font-size: 12px;
line-height: 1.5;
}
.qa-ops-table.qa-monthly-table {
min-width: 800px;
table-layout: fixed;
}
.qa-monthly-table th:nth-child(1) {
width: 15%;
}
.qa-monthly-table th:nth-child(2) {
width: 30%;
}
.qa-monthly-table th:nth-child(3) {
width: 23%;
}
.qa-monthly-table td:nth-child(1),
.qa-monthly-table td:nth-child(2) {
white-space: normal;
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.qa-ops-query.qa-monthly-query {
grid-template-columns: minmax(0, 1fr);
}
}
.qa-ops-header h2 {
margin: 0 0 5px;
font-size: 21px;
+6 -2
View File
@@ -231,8 +231,12 @@ export function parseQARecordPage(value: unknown): QARecordPage {
}
}
export async function getPayload(path: string, signal?: AbortSignal): Promise<unknown> {
const response = await fetch(path, { credentials: 'same-origin', cache: 'no-store', signal })
export async function getPayload(
path: string,
signal?: AbortSignal,
request: Pick<RequestInit, 'method' | 'headers' | 'body'> = {}
): Promise<unknown> {
const response = await fetch(path, { ...request, credentials: 'same-origin', cache: 'no-store', signal })
const raw: unknown = await response.json().catch(() => null)
if (!response.ok) {
const payload = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : null