409 lines
12 KiB
Go
409 lines
12 KiB
Go
package usage
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"time"
|
||
|
||
"golang.org/x/text/collate"
|
||
"golang.org/x/text/language"
|
||
)
|
||
|
||
type ContextRepository interface {
|
||
ListContext(context.Context, Filters) ([]Event, error)
|
||
}
|
||
|
||
func (s Service) Personal(ctx context.Context, request PersonalRequest) (PersonalReport, error) {
|
||
rangeValue, err := PresetRange(request.Preset, request.Now)
|
||
if err != nil {
|
||
return PersonalReport{}, err
|
||
}
|
||
events, err := s.list(ctx, Filters{OwnerID: request.AccountID, From: rangeValue.From, To: rangeValue.To})
|
||
if err != nil {
|
||
return PersonalReport{}, err
|
||
}
|
||
events = eligible(events)
|
||
recent := records(events)
|
||
if len(recent) > 5 {
|
||
recent = recent[:5]
|
||
}
|
||
return PersonalReport{Preset: request.Preset, Range: rangeValue, Total: len(events), ByCapability: capabilityCounts(events), Recent: recent}, nil
|
||
}
|
||
func (s Service) Admin(ctx context.Context, request AdminRequest) (AdminReport, error) {
|
||
rangeValue, err := adminRange(request)
|
||
if err != nil {
|
||
return AdminReport{}, err
|
||
}
|
||
baseEvents, err := s.list(ctx, Filters{From: rangeValue.From, To: rangeValue.To})
|
||
if err != nil {
|
||
return AdminReport{}, err
|
||
}
|
||
baseEvents = eligible(baseEvents)
|
||
injectedOrganizations, err := s.organizationOptions(ctx, request.Requester)
|
||
if err != nil {
|
||
return AdminReport{}, err
|
||
}
|
||
baseViews := recordsWithOrganizations(baseEvents, injectedOrganizations)
|
||
views := filterRecords(baseViews, request)
|
||
events := eventsForRecords(baseEvents, views)
|
||
recent := views
|
||
if len(recent) > 100 {
|
||
recent = recent[:100]
|
||
}
|
||
optionViews := baseViews
|
||
if request.OrganizationID != "" {
|
||
optionViews = filterRecords(baseViews, AdminRequest{Requester: Requester{OrganizationID: request.OrganizationID}})
|
||
}
|
||
accounts := map[string]bool{}
|
||
organizations := map[string]bool{}
|
||
for _, e := range events {
|
||
accounts[e.OwnerID] = true
|
||
if e.OrganizationID != "" {
|
||
organizations[e.OrganizationID] = true
|
||
}
|
||
}
|
||
return AdminReport{Range: rangeValue, Summary: Summary{Total: len(events), ActiveAccounts: len(accounts), ActiveOrganizations: len(organizations), AveragePerDay: math.Round(float64(len(events))/float64(rangeValue.DayCount)*10) / 10}, Trend: trend(events, rangeValue), ByCapability: capabilityCounts(events), ByProvider: providerCounts(events), Organizations: organizationRows(views), Accounts: accountRows(views), Recent: recent, Options: Options{Organizations: mergeOrganizationOptions(injectedOrganizations, optionViews), Accounts: accountOptions(optionViews), Capabilities: []Option{{"image.generate", "图片生成"}, {"video.generate", "视频生成"}}, Providers: providerOptions(baseEvents)}}, nil
|
||
}
|
||
|
||
func (s Service) organizationOptions(ctx context.Context, requester Requester) ([]Option, error) {
|
||
if s.OrganizationOptions == nil {
|
||
return []Option{}, nil
|
||
}
|
||
options, err := s.OrganizationOptions.ListOrganizationOptions(ctx, requester)
|
||
if options == nil {
|
||
options = []Option{}
|
||
}
|
||
return options, err
|
||
}
|
||
|
||
func recordsWithOrganizations(events []Event, organizations []Option) []Record {
|
||
names := make(map[string]string, len(organizations))
|
||
for _, option := range organizations {
|
||
names[option.Value] = option.Label
|
||
}
|
||
views := records(events)
|
||
for i := range views {
|
||
if events[i].OrganizationName == "" {
|
||
if name := names[views[i].OrganizationID]; name != "" {
|
||
views[i].OrganizationName = name
|
||
}
|
||
}
|
||
}
|
||
return views
|
||
}
|
||
|
||
func filterRecords(records []Record, request AdminRequest) []Record {
|
||
out := make([]Record, 0, len(records))
|
||
for _, record := range records {
|
||
if request.OrganizationID != "" && record.OrganizationID != request.OrganizationID || request.OwnerID != "" && record.OwnerID != request.OwnerID || request.Capability != "" && record.Capability != request.Capability || request.Provider != "" && record.Provider != request.Provider {
|
||
continue
|
||
}
|
||
out = append(out, record)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func eventsForRecords(events []Event, records []Record) []Event {
|
||
byID := make(map[string]Event, len(events))
|
||
for _, event := range events {
|
||
byID[event.ID] = event
|
||
}
|
||
out := make([]Event, 0, len(records))
|
||
for _, record := range records {
|
||
if event, ok := byID[record.ID]; ok {
|
||
out = append(out, event)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
func (s Service) list(ctx context.Context, filters Filters) ([]Event, error) {
|
||
if contextual, ok := s.Repository.(ContextRepository); ok {
|
||
return contextual.ListContext(ctx, filters)
|
||
}
|
||
return s.Repository.List(filters)
|
||
}
|
||
func eligible(events []Event) []Event {
|
||
seen := map[string]bool{}
|
||
out := make([]Event, 0, len(events))
|
||
for _, e := range events {
|
||
if e.Source == "api" || e.Provider == "mock" || seen[e.JobID] {
|
||
continue
|
||
}
|
||
seen[e.JobID] = true
|
||
out = append(out, e)
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||
return out
|
||
}
|
||
func records(events []Event) []Record {
|
||
out := make([]Record, 0, len(events))
|
||
for _, e := range events {
|
||
name := e.AccountDisplayName
|
||
if name == "" {
|
||
name = e.AccountUsername
|
||
}
|
||
if name == "" {
|
||
sum := sha256.Sum256([]byte(e.OwnerID))
|
||
name = fmt.Sprintf("历史账号 %X", sum[:4])
|
||
}
|
||
orgName := e.OrganizationName
|
||
organizationID := e.OrganizationID
|
||
if organizationID == "" {
|
||
organizationID = UnassignedOrganizationID
|
||
}
|
||
if orgName == "" {
|
||
orgName = "未归属组织"
|
||
}
|
||
out = append(out, Record{ID: e.ID, JobID: e.JobID, OwnerID: e.OwnerID, AccountName: name, AccountUsername: e.AccountUsername, OrganizationID: organizationID, OrganizationName: orgName, Capability: e.Capability, CapabilityLabel: capabilityLabel(e.Capability), Provider: e.Provider, ProviderLabel: providerLabel(e.Provider), ReqKey: e.ReqKey, CreatedAt: e.CreatedAt})
|
||
}
|
||
return out
|
||
}
|
||
func capabilityCounts(events []Event) []CountItem {
|
||
counts := map[string]int{}
|
||
for _, e := range events {
|
||
counts[e.Capability]++
|
||
}
|
||
return []CountItem{{"image.generate", "图片生成", counts["image.generate"]}, {"video.generate", "视频生成", counts["video.generate"]}}
|
||
}
|
||
func providerCounts(events []Event) []CountItem {
|
||
counts := map[string]int{}
|
||
for _, e := range events {
|
||
key := e.Provider
|
||
if key == "" {
|
||
key = "unknown"
|
||
}
|
||
counts[key]++
|
||
}
|
||
out := make([]CountItem, 0, len(counts))
|
||
for key, count := range counts {
|
||
out = append(out, CountItem{key, providerLabel(key), count})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Count != out[j].Count {
|
||
return out[i].Count > out[j].Count
|
||
}
|
||
return out[i].Key < out[j].Key
|
||
})
|
||
return out
|
||
}
|
||
func providerOptions(events []Event) []Option {
|
||
seen := map[string]bool{}
|
||
out := []Option{}
|
||
for _, e := range events {
|
||
if e.Provider != "" && !seen[e.Provider] {
|
||
seen[e.Provider] = true
|
||
out = append(out, Option{e.Provider, providerLabel(e.Provider)})
|
||
}
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Label < out[j].Label })
|
||
return out
|
||
}
|
||
func mergeOrganizationOptions(injected []Option, records []Record) []Option {
|
||
labels := make(map[string]string, len(injected)+len(records))
|
||
for _, option := range injected {
|
||
if option.Value != "" {
|
||
labels[option.Value] = option.Label
|
||
}
|
||
}
|
||
for _, record := range records {
|
||
id := record.OrganizationID
|
||
if id == "" {
|
||
id = UnassignedOrganizationID
|
||
}
|
||
labels[id] = record.OrganizationName
|
||
}
|
||
out := make([]Option, 0, len(labels))
|
||
for value, label := range labels {
|
||
if label == "" {
|
||
label = value
|
||
}
|
||
out = append(out, Option{Value: value, Label: label})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Value == UnassignedOrganizationID {
|
||
return false
|
||
}
|
||
if out[j].Value == UnassignedOrganizationID {
|
||
return true
|
||
}
|
||
if out[i].Label != out[j].Label {
|
||
return out[i].Label < out[j].Label
|
||
}
|
||
return out[i].Value < out[j].Value
|
||
})
|
||
return out
|
||
}
|
||
func accountOptions(records []Record) []Option {
|
||
labels := make(map[string]string, len(records))
|
||
for _, record := range records {
|
||
if _, exists := labels[record.OwnerID]; exists {
|
||
continue
|
||
}
|
||
label := record.AccountName
|
||
if record.AccountUsername != "" {
|
||
label += "(" + record.AccountUsername + ")"
|
||
}
|
||
labels[record.OwnerID] = label
|
||
}
|
||
out := make([]Option, 0, len(labels))
|
||
for value, label := range labels {
|
||
out = append(out, Option{Value: value, Label: label})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Label != out[j].Label {
|
||
return out[i].Label < out[j].Label
|
||
}
|
||
return out[i].Value < out[j].Value
|
||
})
|
||
return out
|
||
}
|
||
func trend(events []Event, r DateRange) []TrendPoint {
|
||
counts := map[string]int{}
|
||
monthly := r.DayCount > 62
|
||
for _, e := range events {
|
||
if parsed, err := time.Parse(time.RFC3339, e.CreatedAt); err == nil {
|
||
layout := "2006-01-02"
|
||
if monthly {
|
||
layout = "2006-01"
|
||
}
|
||
counts[parsed.In(shanghai).Format(layout)]++
|
||
}
|
||
}
|
||
out := []TrendPoint{}
|
||
if monthly {
|
||
for key, count := range counts {
|
||
out = append(out, TrendPoint{key, key, count})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date })
|
||
return out
|
||
}
|
||
start, _ := time.ParseInLocation("2006-01-02", r.StartDate, shanghai)
|
||
end, _ := time.ParseInLocation("2006-01-02", r.EndDate, shanghai)
|
||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||
key := day.Format("2006-01-02")
|
||
out = append(out, TrendPoint{key, key[5:], counts[key]})
|
||
}
|
||
return out
|
||
}
|
||
func organizationRows(records []Record) any {
|
||
by := map[string]*organizationRow{}
|
||
for _, v := range records {
|
||
id := v.OrganizationID
|
||
if id == "" {
|
||
id = UnassignedOrganizationID
|
||
}
|
||
item := by[id]
|
||
if item == nil {
|
||
item = &organizationRow{OrganizationID: id, OrganizationName: v.OrganizationName, accounts: map[string]bool{}}
|
||
by[id] = item
|
||
}
|
||
item.Count++
|
||
item.accounts[v.OwnerID] = true
|
||
item.AccountCount = len(item.accounts)
|
||
if v.CreatedAt > item.LastUsedAt {
|
||
item.LastUsedAt = v.CreatedAt
|
||
}
|
||
}
|
||
out := make([]organizationRow, 0, len(by))
|
||
for _, v := range by {
|
||
out = append(out, *v)
|
||
}
|
||
zhCN := collate.New(language.Chinese)
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if out[i].Count != out[j].Count {
|
||
return out[i].Count > out[j].Count
|
||
}
|
||
if out[i].OrganizationID == UnassignedOrganizationID {
|
||
return false
|
||
}
|
||
if out[j].OrganizationID == UnassignedOrganizationID {
|
||
return true
|
||
}
|
||
return zhCN.CompareString(out[i].OrganizationName, out[j].OrganizationName) < 0
|
||
})
|
||
return out
|
||
}
|
||
func accountRows(records []Record) any {
|
||
by := map[string]*accountRow{}
|
||
order := make([]string, 0, len(records))
|
||
for _, v := range records {
|
||
item := by[v.OwnerID]
|
||
if item == nil {
|
||
item = &accountRow{OwnerID: v.OwnerID, AccountName: v.AccountName, AccountUsername: v.AccountUsername, OrganizationID: v.OrganizationID, OrganizationName: v.OrganizationName}
|
||
by[v.OwnerID] = item
|
||
order = append(order, v.OwnerID)
|
||
}
|
||
item.Count++
|
||
if v.CreatedAt > item.LastUsedAt {
|
||
item.LastUsedAt = v.CreatedAt
|
||
}
|
||
}
|
||
out := make([]accountRow, 0, len(by))
|
||
for _, id := range order {
|
||
out = append(out, *by[id])
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
||
return out
|
||
}
|
||
func adminRange(request AdminRequest) (DateRange, error) {
|
||
if request.StartDate == "" && request.EndDate == "" {
|
||
return PresetRange(PresetMonth, request.Now)
|
||
}
|
||
fallback, _ := PresetRange(PresetMonth, request.Now)
|
||
start, end := request.StartDate, request.EndDate
|
||
if start == "" {
|
||
start = fallback.StartDate
|
||
}
|
||
if end == "" {
|
||
end = fallback.EndDate
|
||
}
|
||
from, err := time.ParseInLocation("2006-01-02", start, shanghai)
|
||
if err != nil {
|
||
return DateRange{}, err
|
||
}
|
||
to, err := time.ParseInLocation("2006-01-02", end, shanghai)
|
||
if err != nil || to.Before(from) {
|
||
return DateRange{}, ErrInvalidDateRange
|
||
}
|
||
dayCount := int(to.Sub(from).Hours()/24) + 1
|
||
if dayCount > 3660 {
|
||
return DateRange{}, ErrDateRangeTooLong
|
||
}
|
||
label := start
|
||
if start != end {
|
||
label = start + " 至 " + end
|
||
}
|
||
return DateRange{From: from.Format(time.RFC3339), To: to.AddDate(0, 0, 1).Format(time.RFC3339), StartDate: start, EndDate: end, Label: label, DayCount: dayCount}, nil
|
||
}
|
||
func capabilityLabel(value string) string {
|
||
if value == "video.generate" {
|
||
return "视频生成"
|
||
}
|
||
return "图片生成"
|
||
}
|
||
func providerLabel(value string) string {
|
||
switch value {
|
||
case "volcengine-visual":
|
||
return "即梦图片"
|
||
case "evolink":
|
||
return "EvoLink"
|
||
case "seedance":
|
||
return "Seedance"
|
||
case "seedream":
|
||
return "Seedream 5.0 Pro"
|
||
case "bailian":
|
||
return "阿里云百炼"
|
||
case "minimax":
|
||
return "MiniMax H3"
|
||
case "mock":
|
||
return "系统"
|
||
default:
|
||
return "未知服务商"
|
||
}
|
||
}
|
||
|
||
var _ Reporter = Service{}
|