319 lines
11 KiB
Go
319 lines
11 KiB
Go
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)})
|
||
}
|