feat(qaops): 增加个人自然月用量与零记录人员明细
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 「使用统计 → 个人月明细」按北京时间自然月及截止时点展示当前应用可见名册的人员用量(包含本源零记录),支持部门含子部门、区域/待核对状态、姓名/完整部门路径和已有提问/零记录筛选。稳定身份关联去重、不可关联问答单列、过期组织版本阻断翻页;源端姓名可缺失并提示重新同步,不按姓名匹配身份。姓名筛选通过有界只读 POST 请求体传递、不进入 URL,原始员工标识不返回浏览器;名册不作为在职/考核分母,当月进度不作为绩效结论。
|
||||
|
||||
- 问答运营按稳定身份关联当前治理通讯录,缺失部门/区域补全并保留源快照及目录/区域版本;空列判断包含完整筛选结果,不混合配置已变化的分页。新增完整时间/关键词范围的使用统计,问答计次、名册内人员去重,跨区/未匹配/身份缺失分列,不输出绩效结论;不回写 DobotAI 原表。
|
||||
- 「问答运营 → 区域使用」支持管理员从网页发起持久化通讯录同步,重复请求重放、进行中操作合并;按服务器固定范围注册后台执行器,未接线或未检测到心跳时不创建新任务。界面显示真实任务状态、不编造百分比;失败保留旧快照与区域规则,同步完成不覆盖未保存草稿。旧执行器丢失租约后不能激活结果,重启接管和已提交快照的续跑使用原任务。
|
||||
- 新增治理侧通讯录实时桥接,复用既有周报读取能力,向源端读取真实缺失祖先以补齐部门树;不改旧周报脚本、配置或输出。私有 JSON 与受控子进程执行器拒绝不完整数据、模拟同步、公开文件和异常输出,超时清理临时人员文件。同步默认关闭,容器启用仍需批准的既有周报 SDK/配置及兼容 Python 运行时挂载。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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", "®ion=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}, {"®ion=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}, {"®ion=cross_region", 1}, {"®ion=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")
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user