feat: add billing and usage domain cores
This commit is contained in:
1 parent
48dd5d07c8
commit
5e60bb40e7
14 files changed
+931
No files matched your search
@@ -0,0 +1,99 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Preset string
|
||||
|
||||
const (
|
||||
PresetToday Preset = "today"
|
||||
Preset7Days Preset = "7d"
|
||||
Preset30Days Preset = "30d"
|
||||
PresetMonth Preset = "month"
|
||||
)
|
||||
|
||||
var ErrForbidden = errors.New("usage scope forbidden")
|
||||
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
type DateRange struct {
|
||||
From, To, StartDate, EndDate string
|
||||
DayCount int
|
||||
}
|
||||
|
||||
func PresetRange(preset Preset, now time.Time) (DateRange, error) {
|
||||
today := now.In(shanghai)
|
||||
start := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, shanghai)
|
||||
switch preset {
|
||||
case PresetToday:
|
||||
case Preset7Days:
|
||||
start = start.AddDate(0, 0, -6)
|
||||
case Preset30Days:
|
||||
start = start.AddDate(0, 0, -29)
|
||||
case PresetMonth:
|
||||
start = time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, shanghai)
|
||||
default:
|
||||
return DateRange{}, fmt.Errorf("unknown usage preset %q", preset)
|
||||
}
|
||||
end := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, shanghai)
|
||||
return DateRange{From: start.Format(time.RFC3339), To: end.AddDate(0, 0, 1).Format(time.RFC3339), StartDate: start.Format("2006-01-02"), EndDate: end.Format("2006-01-02"), DayCount: int(end.Sub(start).Hours()/24) + 1}, nil
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID, OwnerID, JobID, Source, Capability, Provider, ReqKey, AccountUsername, AccountDisplayName, TenantID, OrganizationID, OrganizationName, EstimatedUnit, Currency, CreatedAt string
|
||||
Quantity int
|
||||
ChargedAmountFen *int64
|
||||
}
|
||||
type Filters struct{ OwnerID, OrganizationID, Capability, Provider, From, To string }
|
||||
type Requester struct{ AccountID, OrganizationID, Role string }
|
||||
type Repository interface {
|
||||
Insert(Event) (Event, bool, error)
|
||||
List(Filters) ([]Event, error)
|
||||
}
|
||||
type Service struct{ Repository Repository }
|
||||
|
||||
func (s Service) Record(event Event) (*Event, error) {
|
||||
if event.Source == "api" || event.Provider == "mock" {
|
||||
return nil, nil
|
||||
}
|
||||
saved, _, err := s.Repository.Insert(event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &saved, nil
|
||||
}
|
||||
func (s Service) Report(who Requester, filters Filters) ([]Event, error) {
|
||||
switch who.Role {
|
||||
case "super_admin":
|
||||
case "organization_admin":
|
||||
if filters.OrganizationID != "" && filters.OrganizationID != who.OrganizationID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
filters.OrganizationID = who.OrganizationID
|
||||
case "user":
|
||||
filters.OwnerID = who.AccountID
|
||||
default:
|
||||
filters.OwnerID = who.AccountID
|
||||
}
|
||||
events, err := s.Repository.List(filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]Event, 0, len(events))
|
||||
for _, e := range events {
|
||||
if e.Source == "api" || e.Provider == "mock" || seen[e.JobID] || filters.OwnerID != "" && e.OwnerID != filters.OwnerID || filters.OrganizationID != "" && e.OrganizationID != filters.OrganizationID || filters.Capability != "" && e.Capability != filters.Capability || filters.Provider != "" && e.Provider != filters.Provider {
|
||||
continue
|
||||
}
|
||||
seen[e.JobID] = true
|
||||
if who.Role == "organization_admin" {
|
||||
e.AccountUsername = ""
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUsageCoreFixtureIsConsumedByGo(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../contracts/usage/core-v1.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
TimeZone string `json:"timeZone"`
|
||||
DedupeKey string `json:"dedupeKey"`
|
||||
Excluded struct {
|
||||
Sources []string `json:"sources"`
|
||||
Providers []string `json:"providers"`
|
||||
} `json:"excluded"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fixture.TimeZone != "Asia/Shanghai" || fixture.DedupeKey != "jobId" || len(fixture.Excluded.Sources) != 1 || fixture.Excluded.Sources[0] != "api" || len(fixture.Excluded.Providers) != 1 || fixture.Excluded.Providers[0] != "mock" {
|
||||
t.Fatalf("fixture = %#v", fixture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetUsesShanghaiCalendarBoundaries(t *testing.T) {
|
||||
rangeValue, err := PresetRange(PresetMonth, time.Date(2026, 7, 27, 16, 30, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rangeValue.StartDate != "2026-07-01" || rangeValue.EndDate != "2026-07-28" || rangeValue.From != "2026-07-01T00:00:00+08:00" || rangeValue.To != "2026-07-29T00:00:00+08:00" || rangeValue.DayCount != 28 {
|
||||
t.Fatalf("range = %#v", rangeValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDedupesEligibleEventsAndScopesReports(t *testing.T) {
|
||||
repo := &memoryRepository{}
|
||||
service := Service{Repository: repo}
|
||||
for _, event := range []Event{
|
||||
{ID: "1", JobID: "job-1", OwnerID: "a", Source: "platform", Provider: "bailian", OrganizationID: "org-1", AccountUsername: "secret", CreatedAt: "2026-07-03T01:00:00Z"},
|
||||
{ID: "2", JobID: "job-1", OwnerID: "a", Source: "platform", Provider: "bailian", OrganizationID: "org-1", CreatedAt: "2026-07-03T01:01:00Z"},
|
||||
{ID: "3", JobID: "job-api", OwnerID: "a", Source: "api", Provider: "bailian", OrganizationID: "org-1", CreatedAt: "2026-07-03T01:02:00Z"},
|
||||
{ID: "4", JobID: "job-mock", OwnerID: "a", Source: "platform", Provider: "mock", OrganizationID: "org-1", CreatedAt: "2026-07-03T01:03:00Z"},
|
||||
{ID: "5", JobID: "job-2", OwnerID: "b", Source: "platform", Provider: "seedance", OrganizationID: "org-2", AccountUsername: "other-secret", CreatedAt: "2026-07-03T01:04:00Z"},
|
||||
} {
|
||||
if _, err := service.Record(event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
personal, err := service.Report(Requester{AccountID: "a", Role: "user"}, Filters{})
|
||||
if err != nil || len(personal) != 1 || personal[0].OwnerID != "a" {
|
||||
t.Fatalf("personal = %#v, %v", personal, err)
|
||||
}
|
||||
admin, err := service.Report(Requester{AccountID: "admin", OrganizationID: "org-1", Role: "organization_admin"}, Filters{})
|
||||
if err != nil || len(admin) != 1 || admin[0].OrganizationID != "org-1" || admin[0].AccountUsername != "" {
|
||||
t.Fatalf("admin = %#v, %v", admin, err)
|
||||
}
|
||||
super, err := service.Report(Requester{AccountID: "root", Role: "super_admin"}, Filters{})
|
||||
if err != nil || len(super) != 2 || super[0].AccountUsername == "" {
|
||||
t.Fatalf("super = %#v, %v", super, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizationAdminCannotEscapeOrganizationScope(t *testing.T) {
|
||||
_, err := (Service{Repository: &memoryRepository{}}).Report(Requester{Role: "organization_admin", OrganizationID: "org-1"}, Filters{OrganizationID: "org-2"})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type memoryRepository struct{ events []Event }
|
||||
|
||||
func (r *memoryRepository) Insert(event Event) (Event, bool, error) {
|
||||
for _, current := range r.events {
|
||||
if current.JobID == event.JobID {
|
||||
return current, false, nil
|
||||
}
|
||||
}
|
||||
r.events = append(r.events, event)
|
||||
return event, true, nil
|
||||
}
|
||||
func (r *memoryRepository) List(filters Filters) ([]Event, error) {
|
||||
return append([]Event(nil), r.events...), nil
|
||||
}
|
||||
Reference in new issue
Block a user