533db535f1
build / build (push) Successful in 1m12s
按已确认需求提供独立安全模型配置、真实探测、正式字典字段评估与原问题译中,持久批次支持只补空白、暂停恢复、有限重试、幂等和逐字段依据。覆盖固定规则、本地语言、组织关联与主表有效看板共用治理侧读模型,保留源生产和冻结历史。 验证:完整 make verify 退出0,702项Go与329项前端通过;默认118项外部/隔离集成明确跳过,另跑完整镜像及组织隔离PG竞态。12并发、输入漂移、过期租约、暂停/恢复及有限重试通过。用户授权的百炼 deepseek-v4.1-flash 完成真实后台与浏览器评估/翻译;552行、16列、11块看板及完整PNG、独立配置页测试/保存、重开恢复和零JS异常通过。仅非业务样本外发,自动处理关闭,原payload不变。 上线前治理库已做导出快照原生备份并真实恢复,60表行数与17个QA表内容哈希一致;不修改DobotAI生产或隧道。本提交时尚未部署,线上验收须独立完成。
195 lines
6.3 KiB
Go
195 lines
6.3 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"knowledge-governance/internal/dobotaiops"
|
||
"knowledge-governance/internal/qaopsorg"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var errQAOrganization = errors.New("organization attribution unavailable")
|
||
|
||
type qaAttributionProvider interface {
|
||
GetAttribution(context.Context) (*qaopsorg.Attribution, error)
|
||
}
|
||
|
||
func recordAttribution(ctx context.Context, config Config) (*qaopsorg.Attribution, error) {
|
||
if config.QAOrg == nil {
|
||
return qaopsorg.BuildAttribution(qaopsorg.State{State: "empty"}, qaopsorg.Directory{})
|
||
}
|
||
provider, ok := config.QAOrg.(qaAttributionProvider)
|
||
if !ok {
|
||
return nil, errQAOrganization
|
||
}
|
||
p, err := provider.GetAttribution(ctx)
|
||
if err != nil || p == nil {
|
||
return nil, errQAOrganization
|
||
}
|
||
return p, nil
|
||
}
|
||
func attributionVersion(p *qaopsorg.Attribution) dobotaiops.OrganizationVersion {
|
||
return dobotaiops.OrganizationVersion{State: p.Metadata.State, DirectoryID: p.Metadata.DirectoryID, DirectorySource: p.Metadata.DirectorySource, SyncedAt: p.Metadata.DirectorySyncedAt, RevisionID: p.Metadata.RevisionID, Basis: "current_directory", RegionNames: p.Metadata.RegionLabels()}
|
||
}
|
||
func enrichQARecord(item *dobotaiops.Record, p *qaopsorg.Attribution) {
|
||
trigger := item.TriggerPerson
|
||
if raw, ok := item.MasterFields["trigger_person"]; ok {
|
||
trigger = raw
|
||
}
|
||
person := p.Resolve(item.Identity.StaffID, item.Identity.UnionID, item.Asker, trigger)
|
||
o := &dobotaiops.RecordOrganization{Version: attributionVersion(p), State: person.State, Departments: person.Departments, Region: person.Region, SourceDepartment: item.Department, SourceRegion: item.Region, DepartmentOrigin: "unavailable", RegionOrigin: "unavailable"}
|
||
if item.Department != "" {
|
||
o.DepartmentOrigin = "source_snapshot"
|
||
} else if len(person.Departments) > 0 {
|
||
item.Department = strings.Join(person.Departments, ";")
|
||
o.DepartmentOrigin = "current_directory"
|
||
}
|
||
if item.Region != "" {
|
||
o.RegionOrigin = "source_snapshot"
|
||
} else if person.Region != "" {
|
||
item.Region = person.Region
|
||
o.RegionOrigin = "current_directory"
|
||
}
|
||
item.Organization = o
|
||
}
|
||
func enrichQAPage(page *dobotaiops.RecordPage, p *qaopsorg.Attribution) {
|
||
version := attributionVersion(p)
|
||
page.Organization = &version
|
||
for i := range page.Items {
|
||
enrichQARecord(&page.Items[i], p)
|
||
}
|
||
for _, actor := range page.ScopeActors {
|
||
person := p.Resolve(actor.StaffID, actor.UnionID)
|
||
if len(person.Departments) > 0 {
|
||
dobotaiops.AddRecordColumns(page, "department")
|
||
}
|
||
if person.Region != "" {
|
||
dobotaiops.AddRecordColumns(page, "region")
|
||
}
|
||
}
|
||
}
|
||
|
||
type qaUsageReader interface {
|
||
ReadUsageGroups(context.Context, dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error)
|
||
}
|
||
type qaUsageRow struct {
|
||
Key string `json:"key"`
|
||
Questions int64 `json:"questions"`
|
||
Succeeded int64 `json:"succeeded"`
|
||
Failed int64 `json:"failed"`
|
||
Other int64 `json:"other"`
|
||
Users *int `json:"users"`
|
||
RosterPeople *int `json:"roster_people"`
|
||
}
|
||
type qaUsage struct {
|
||
Organization dobotaiops.OrganizationVersion `json:"organization"`
|
||
AsOf time.Time `json:"as_of"`
|
||
Start time.Time `json:"start"`
|
||
End time.Time `json:"end"`
|
||
Keyword string `json:"keyword"`
|
||
Questions int64 `json:"questions"`
|
||
IdentifiedUsers int `json:"identified_users"`
|
||
Rows []qaUsageRow `json:"rows"`
|
||
}
|
||
|
||
func summarizeQAUsage(groups dobotaiops.UsageGroups, p *qaopsorg.Attribution, query dobotaiops.RecordQuery) qaUsage {
|
||
result := qaUsage{Organization: attributionVersion(p), AsOf: groups.AsOf, Start: query.Window.Start, End: query.Window.End, Keyword: query.Keyword, Rows: []qaUsageRow{}}
|
||
keys := append(append([]string{}, p.Metadata.RegionLabels()...), "unassigned", "cross_region", "unmatched", "identity_conflict", "no_identity", "directory_missing")
|
||
rows := map[string]*qaUsageRow{}
|
||
members := map[string]map[string]bool{}
|
||
all := map[string]bool{}
|
||
for _, key := range keys {
|
||
row := &qaUsageRow{Key: key}
|
||
if p.Metadata.State == "ready" {
|
||
var n int
|
||
valid := true
|
||
switch key {
|
||
case "unassigned":
|
||
n = p.Metadata.View.UnassignedPeople
|
||
case "cross_region":
|
||
n = p.Metadata.View.ConflictingPeople
|
||
default:
|
||
var ok bool
|
||
for _, region := range p.Metadata.RegionDefinitions() {
|
||
if region.Name == key {
|
||
n, ok = p.Metadata.View.Regions[region.ID]
|
||
break
|
||
}
|
||
}
|
||
valid = ok
|
||
}
|
||
if valid {
|
||
row.RosterPeople = &n
|
||
row.Users = new(int)
|
||
}
|
||
}
|
||
rows[key] = row
|
||
members[key] = map[string]bool{}
|
||
}
|
||
for _, group := range groups.Groups {
|
||
person := p.Resolve(group.Identity.StaffID, group.Identity.UnionID)
|
||
key := person.State
|
||
if person.Region != "" {
|
||
key = person.Region
|
||
}
|
||
row := rows[key]
|
||
if row == nil {
|
||
continue
|
||
}
|
||
row.Questions += group.Total
|
||
row.Succeeded += group.Succeeded
|
||
row.Failed += group.Failed
|
||
row.Other += group.Total - group.Succeeded - group.Failed
|
||
result.Questions += group.Total
|
||
if person.PersonID != "" {
|
||
members[key][person.PersonID] = true
|
||
all[person.PersonID] = true
|
||
}
|
||
}
|
||
for _, key := range keys {
|
||
row := rows[key]
|
||
if row.Users != nil {
|
||
*row.Users = len(members[key])
|
||
}
|
||
result.Rows = append(result.Rows, *row)
|
||
}
|
||
result.IdentifiedUsers = len(all)
|
||
return result
|
||
}
|
||
func qaOpsUsage(w http.ResponseWriter, r *http.Request, config Config) {
|
||
if !authorizeQAOps(w, r) {
|
||
return
|
||
}
|
||
reader, ok := config.QAOps.(qaUsageReader)
|
||
if !ok || config.QAOpsTenantKey == "" {
|
||
qaOpsError(w, dobotaiops.ErrUnavailable)
|
||
return
|
||
}
|
||
values := r.URL.Query()
|
||
for key, items := range values {
|
||
if (key != "start" && key != "end" && key != "keyword") || len(items) != 1 {
|
||
qaOpsError(w, dobotaiops.ErrInvalidQuery)
|
||
return
|
||
}
|
||
}
|
||
window, err := dobotaiops.NewWindow(config.QAOpsTenantKey, values.Get("start"), values.Get("end"), 90*24*time.Hour, config.Now())
|
||
if err != nil {
|
||
qaOpsError(w, err)
|
||
return
|
||
}
|
||
p, err := recordAttribution(r.Context(), config)
|
||
if err != nil {
|
||
qaOpsError(w, err)
|
||
return
|
||
}
|
||
query := dobotaiops.RecordQuery{Window: window, Keyword: values.Get("keyword")}
|
||
groups, err := reader.ReadUsageGroups(r.Context(), query)
|
||
if err != nil {
|
||
qaOpsError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"data": summarizeQAUsage(groups, p, query)})
|
||
}
|