Files
knowledge-governance/internal/httpapi/qa_monthly.go
T
2026-10-18 21:50:29 +08:00

319 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
if !utf8.ValidString(f.Region) || len(f.Region) > 256 || strings.TrimSpace(f.Region) != f.Region || strings.ContainsAny(f.Region, "\x00\r\n") {
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.Region != "" && f.Region != "unassigned" && f.Region != "cross_region" {
found := false
for _, name := range p.Metadata.RegionLabels() {
found = found || f.Region == name
}
if !found {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
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)})
}