feat: complete remaining Go backend modules
This commit is contained in:
1 parent
cea2751dc5
commit
aef5a97165
145 files changed
+18376
-199
No files matched your search
@@ -0,0 +1,404 @@
|
||||
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 "bailian":
|
||||
return "阿里云百炼"
|
||||
case "mock":
|
||||
return "系统"
|
||||
default:
|
||||
return "未知服务商"
|
||||
}
|
||||
}
|
||||
|
||||
var _ Reporter = Service{}
|
||||
Reference in new issue
Block a user