feat: establish v0.70 platform milestone
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-08-29 21:56:51 +08:00
parent d97bbf7cf5
commit e652516147
133 changed files with 12907 additions and 801 deletions
+347
View File
@@ -0,0 +1,347 @@
package billing
import (
"context"
"errors"
"sort"
"time"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const maxUsageOverviewRange = 366 * 24 * time.Hour
type UsageOverviewScope struct {
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
HouseholdID string `json:"household_id,omitempty"`
HouseholdName string `json:"household_name,omitempty"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
}
type UsageOverviewDay struct {
Date string `json:"date"`
DurationMS int64 `json:"duration_ms"`
ConversationCount int `json:"conversation_count"`
CostMicros int64 `json:"cost_micros"`
}
type UsageOverviewDevice struct {
ID string `json:"id"`
Name string `json:"name"`
HouseholdID string `json:"household_id"`
HouseholdName string `json:"household_name"`
DurationMS int64 `json:"duration_ms"`
ConversationCount int `json:"conversation_count"`
CostMicros int64 `json:"cost_micros"`
}
type UsageOverviewConversation struct {
ID string `json:"id"`
HouseholdID string `json:"household_id"`
HouseholdName string `json:"household_name"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
SubjectName string `json:"subject_name"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
DurationMS int64 `json:"duration_ms"`
CostMicros int64 `json:"cost_micros"`
}
type UsageOverview struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Timezone string `json:"timezone"`
Scope UsageOverviewScope `json:"scope"`
TotalDurationMS int64 `json:"total_duration_ms"`
TodayDurationMS int64 `json:"today_duration_ms"`
ConversationCount int `json:"conversation_count"`
AverageDurationMS int64 `json:"average_duration_ms"`
ActiveDays int `json:"active_days"`
CurrentStreakDays int `json:"current_streak_days"`
LongestStreakDays int `json:"longest_streak_days"`
CostMicros int64 `json:"cost_micros"`
Currency string `json:"currency"`
CostAvailable bool `json:"cost_available"`
Daily []UsageOverviewDay `json:"daily"`
Devices []UsageOverviewDevice `json:"devices"`
RecentConversations []UsageOverviewConversation `json:"recent_conversations"`
}
type usageOverviewTarget struct {
tenantID, tenantPublicID, tenantName string
householdID, householdPublicID, householdName string
deviceID, devicePublicID, deviceName string
}
type usageOverviewRow struct {
conversationID, householdID, householdName string
deviceID, deviceName, subjectName, status string
startedAt time.Time
endedAt *time.Time
costMicros int64
currency string
hasCost bool
}
func (s *Service) FamilyUsageOverview(ctx context.Context, principal identity.Principal, householdPublicID, devicePublicID, timezone string, from, to time.Time) (UsageOverview, error) {
if _, err := uuid.Parse(householdPublicID); err != nil {
return UsageOverview{}, ErrInvalid
}
target := usageOverviewTarget{}
var role identity.Role
err := s.pool.QueryRow(ctx, `
SELECT t.id::text,t.public_id::text,t.name,h.id::text,h.public_id::text,h.name,hm.role
FROM user_account ua
JOIN tenant t ON t.id=ua.tenant_id AND t.status='active'
JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=ua.tenant_id AND hm.status='active'
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status<>'deleted'
WHERE h.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`,
householdPublicID, principal.Issuer, principal.Subject).Scan(
&target.tenantID, &target.tenantPublicID, &target.tenantName,
&target.householdID, &target.householdPublicID, &target.householdName, &role)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !identity.Can(role, identity.CapabilityHouseholdRead)) {
return UsageOverview{}, ErrAccessDenied
}
if err != nil {
return UsageOverview{}, err
}
if err = s.resolveUsageOverviewDevice(ctx, &target, devicePublicID, true); err != nil {
return UsageOverview{}, err
}
return s.buildUsageOverview(ctx, target, timezone, from, to)
}
func (s *Service) OpsUsageOverview(ctx context.Context, principal identity.Principal, tenantPublicID, householdPublicID, devicePublicID, timezone string, from, to time.Time) (UsageOverview, error) {
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if err != nil {
return UsageOverview{}, err
}
target := usageOverviewTarget{tenantID: currentActor.tenantID, tenantPublicID: currentActor.tenantPublicID}
if err = s.pool.QueryRow(ctx, `SELECT name FROM tenant WHERE id=$1::uuid`, target.tenantID).Scan(&target.tenantName); err != nil {
return UsageOverview{}, err
}
if householdPublicID != "" {
if _, parseErr := uuid.Parse(householdPublicID); parseErr != nil {
return UsageOverview{}, ErrInvalid
}
err = s.pool.QueryRow(ctx, `SELECT id::text,public_id::text,name FROM household WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status<>'deleted'`, target.tenantID, householdPublicID).Scan(&target.householdID, &target.householdPublicID, &target.householdName)
if errors.Is(err, pgx.ErrNoRows) {
return UsageOverview{}, ErrNotFound
}
if err != nil {
return UsageOverview{}, err
}
}
if err = s.resolveUsageOverviewDevice(ctx, &target, devicePublicID, false); err != nil {
return UsageOverview{}, err
}
return s.buildUsageOverview(ctx, target, timezone, from, to)
}
func (s *Service) resolveUsageOverviewDevice(ctx context.Context, target *usageOverviewTarget, devicePublicID string, family bool) error {
if devicePublicID == "" {
return nil
}
if _, err := uuid.Parse(devicePublicID); err != nil {
return ErrInvalid
}
query := `SELECT d.id::text,d.public_id::text,d.model FROM device d WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid`
args := []any{target.tenantID, devicePublicID}
if target.householdID != "" {
query += ` AND (
EXISTS(SELECT 1 FROM device_binding b WHERE b.tenant_id=d.tenant_id AND b.device_id=d.id AND b.household_id=$3::uuid AND b.unbound_at IS NULL)
OR EXISTS(SELECT 1 FROM conversation c WHERE c.tenant_id=d.tenant_id AND c.device_id=d.id AND c.household_id=$3::uuid)
)`
args = append(args, target.householdID)
}
err := s.pool.QueryRow(ctx, query, args...).Scan(&target.deviceID, &target.devicePublicID, &target.deviceName)
if errors.Is(err, pgx.ErrNoRows) {
if family {
return ErrAccessDenied
}
return ErrNotFound
}
return err
}
func (s *Service) buildUsageOverview(ctx context.Context, target usageOverviewTarget, timezone string, from, to time.Time) (UsageOverview, error) {
location, err := validateUsageOverviewRange(timezone, from, to)
if err != nil {
return UsageOverview{}, err
}
from = from.UTC()
to = to.UTC()
rows, err := s.pool.Query(ctx, `
SELECT c.public_id::text,h.public_id::text,h.name,d.public_id::text,d.model,sp.preferred_name,c.status,c.started_at,c.ended_at,
COALESCE(cost.cost_micros,0)::bigint,COALESCE(cost.currency,''),COALESCE(cost.has_cost,false)
FROM conversation c
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id
JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id
LEFT JOIN LATERAL (
SELECT SUM(ccl.cost_micros)::bigint AS cost_micros,MIN(ccl.currency) AS currency,COUNT(*)>0 AS has_cost
FROM conversation_cost_line ccl WHERE ccl.conversation_id=c.id
) cost ON true
WHERE c.tenant_id=$1::uuid AND c.started_at IS NOT NULL AND c.started_at>=$2 AND c.started_at<$3
AND ($4::uuid IS NULL OR c.household_id=$4::uuid)
AND ($5::uuid IS NULL OR c.device_id=$5::uuid)
ORDER BY c.started_at DESC,c.public_id`, target.tenantID, from, to, nullableUUID(target.householdID), nullableUUID(target.deviceID))
if err != nil {
return UsageOverview{}, err
}
defer rows.Close()
result := UsageOverview{
From: from, To: to, Timezone: timezone,
Scope: UsageOverviewScope{TenantID: target.tenantPublicID, TenantName: target.tenantName, HouseholdID: target.householdPublicID, HouseholdName: target.householdName, DeviceID: target.devicePublicID, DeviceName: target.deviceName},
Daily: []UsageOverviewDay{}, Devices: []UsageOverviewDevice{}, RecentConversations: []UsageOverviewConversation{},
}
daily := map[string]*UsageOverviewDay{}
devices := map[string]*UsageOverviewDevice{}
activeDates := map[string]struct{}{}
currencies := map[string]struct{}{}
today := time.Now().In(location).Format("2006-01-02")
for rows.Next() {
var row usageOverviewRow
if err = rows.Scan(&row.conversationID, &row.householdID, &row.householdName, &row.deviceID, &row.deviceName, &row.subjectName, &row.status, &row.startedAt, &row.endedAt, &row.costMicros, &row.currency, &row.hasCost); err != nil {
return UsageOverview{}, err
}
duration := conversationDurationMS(row.startedAt, row.endedAt, to)
date := row.startedAt.In(location).Format("2006-01-02")
day := daily[date]
if day == nil {
day = &UsageOverviewDay{Date: date}
daily[date] = day
}
day.DurationMS += duration
day.ConversationCount++
day.CostMicros += row.costMicros
activeDates[date] = struct{}{}
device := devices[row.deviceID]
if device == nil {
device = &UsageOverviewDevice{ID: row.deviceID, Name: row.deviceName, HouseholdID: row.householdID, HouseholdName: row.householdName}
devices[row.deviceID] = device
}
device.DurationMS += duration
device.ConversationCount++
device.CostMicros += row.costMicros
result.TotalDurationMS += duration
result.ConversationCount++
result.CostMicros += row.costMicros
if date == today {
result.TodayDurationMS += duration
}
if row.hasCost {
result.CostAvailable = true
currencies[row.currency] = struct{}{}
}
if len(result.RecentConversations) < 20 {
result.RecentConversations = append(result.RecentConversations, UsageOverviewConversation{
ID: row.conversationID, HouseholdID: row.householdID, HouseholdName: row.householdName,
DeviceID: row.deviceID, DeviceName: row.deviceName, SubjectName: row.subjectName, Status: row.status,
StartedAt: row.startedAt, EndedAt: row.endedAt, DurationMS: duration, CostMicros: row.costMicros,
})
}
}
if err = rows.Err(); err != nil {
return UsageOverview{}, err
}
if len(currencies) > 1 {
return UsageOverview{}, errors.New("usage overview contains multiple currencies")
}
for currency := range currencies {
result.Currency = currency
}
if result.ConversationCount > 0 {
result.AverageDurationMS = result.TotalDurationMS / int64(result.ConversationCount)
}
for _, day := range daily {
result.Daily = append(result.Daily, *day)
}
sort.Slice(result.Daily, func(i, j int) bool { return result.Daily[i].Date < result.Daily[j].Date })
for _, device := range devices {
result.Devices = append(result.Devices, *device)
}
sort.Slice(result.Devices, func(i, j int) bool {
if result.Devices[i].DurationMS == result.Devices[j].DurationMS {
return result.Devices[i].ID < result.Devices[j].ID
}
return result.Devices[i].DurationMS > result.Devices[j].DurationMS
})
result.ActiveDays = len(activeDates)
result.CurrentStreakDays, result.LongestStreakDays = usageStreaks(activeDates, time.Now().In(location))
return result, nil
}
func validateUsageOverviewRange(timezone string, from, to time.Time) (*time.Location, error) {
if timezone == "" || from.IsZero() || !to.After(from) || to.Sub(from) > maxUsageOverviewRange || to.After(time.Now().UTC().Add(5*time.Minute)) {
return nil, ErrInvalid
}
location, err := time.LoadLocation(timezone)
if err != nil {
return nil, ErrInvalid
}
return location, nil
}
func conversationDurationMS(startedAt time.Time, endedAt *time.Time, rangeEnd time.Time) int64 {
end := rangeEnd
if endedAt != nil && endedAt.Before(end) {
end = *endedAt
}
if end.Before(startedAt) {
return 0
}
return end.Sub(startedAt).Milliseconds()
}
func usageStreaks(active map[string]struct{}, now time.Time) (current, longest int) {
if len(active) == 0 {
return 0, 0
}
dates := make([]time.Time, 0, len(active))
for value := range active {
date, err := time.ParseInLocation("2006-01-02", value, now.Location())
if err == nil {
dates = append(dates, date)
}
}
sort.Slice(dates, func(i, j int) bool { return dates[i].Before(dates[j]) })
streak := 0
for index, date := range dates {
if index == 0 || date.Equal(dates[index-1].AddDate(0, 0, 1)) {
streak++
} else {
streak = 1
}
if streak > longest {
longest = streak
}
}
latest := dates[len(dates)-1]
today, _ := time.ParseInLocation("2006-01-02", now.Format("2006-01-02"), now.Location())
if latest.Before(today.AddDate(0, 0, -1)) {
return 0, longest
}
current = 1
for index := len(dates) - 1; index > 0 && dates[index].Equal(dates[index-1].AddDate(0, 0, 1)); index-- {
current++
}
return current, longest
}
func nullableUUID(value string) any {
if value == "" {
return nil
}
return value
}
+106
View File
@@ -0,0 +1,106 @@
package billing
import (
"context"
"errors"
"os"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
)
func TestValidateUsageOverviewRange(t *testing.T) {
now := time.Now().UTC()
if _, err := validateUsageOverviewRange("Asia/Shanghai", now.Add(-30*24*time.Hour), now); err != nil {
t.Fatalf("valid range: %v", err)
}
for _, test := range []struct {
name string
timezone string
from time.Time
to time.Time
}{
{name: "timezone", timezone: "not/a-zone", from: now.Add(-time.Hour), to: now},
{name: "reversed", timezone: "Asia/Shanghai", from: now, to: now.Add(-time.Hour)},
{name: "too long", timezone: "Asia/Shanghai", from: now.Add(-367 * 24 * time.Hour), to: now},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := validateUsageOverviewRange(test.timezone, test.from, test.to); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestUsageStreaks(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 29, 18, 0, 0, 0, location)
active := map[string]struct{}{
"2026-08-20": {}, "2026-08-21": {},
"2026-08-26": {}, "2026-08-27": {}, "2026-08-28": {}, "2026-08-29": {},
}
current, longest := usageStreaks(active, now)
if current != 4 || longest != 4 {
t.Fatalf("current=%d longest=%d", current, longest)
}
current, longest = usageStreaks(map[string]struct{}{"2026-08-20": {}}, now)
if current != 0 || longest != 1 {
t.Fatalf("stale current=%d longest=%d", current, longest)
}
}
func TestConversationDurationMS(t *testing.T) {
start := time.Date(2026, 8, 29, 10, 0, 0, 0, time.UTC)
end := start.Add(15*time.Minute + 250*time.Millisecond)
if got := conversationDurationMS(start, &end, start.Add(time.Hour)); got != 900250 {
t.Fatalf("duration=%d", got)
}
if got := conversationDurationMS(start, nil, start.Add(3*time.Minute)); got != 180000 {
t.Fatalf("active duration=%d", got)
}
}
func TestUsageOverviewScopeIsolationIntegration(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := database.Open(ctx, databaseURL, "usage-overview-test")
if err != nil {
t.Fatal(err)
}
defer pool.Close()
service, err := NewService(pool, audit.NewRepository())
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
owner := identity.Principal{Issuer: billingTestIssuer, Subject: "00000000-0000-0000-0000-000000000101"}
var operatorIssuer, operatorSubject string
if err = pool.QueryRow(ctx, `SELECT oidc_issuer,oidc_subject FROM user_account WHERE platform_role='ops_admin' AND account_kind='operator' AND status='active' ORDER BY created_at LIMIT 1`).Scan(&operatorIssuer, &operatorSubject); err != nil {
t.Fatal(err)
}
operator := identity.Principal{Issuer: operatorIssuer, Subject: operatorSubject}
family, err := service.FamilyUsageOverview(ctx, owner, "10000000-0000-0000-0000-000000000211", billingDevicePublicID, "Asia/Shanghai", now.Add(-365*24*time.Hour), now)
if err != nil || family.Scope.HouseholdID != "10000000-0000-0000-0000-000000000211" || family.Scope.DeviceID != billingDevicePublicID {
t.Fatalf("family overview=%#v err=%v", family.Scope, err)
}
if _, err = service.FamilyUsageOverview(ctx, owner, "20000000-0000-0000-0000-000000000211", "", "Asia/Shanghai", now.Add(-24*time.Hour), now); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("cross-household error=%v", err)
}
ops, err := service.OpsUsageOverview(ctx, operator, billingTenantPublicID, "10000000-0000-0000-0000-000000000211", billingDevicePublicID, "Asia/Shanghai", now.Add(-365*24*time.Hour), now)
if err != nil || ops.Scope.TenantID != billingTenantPublicID || ops.Scope.HouseholdID == "" || ops.Scope.DeviceID == "" {
t.Fatalf("ops overview=%#v err=%v", ops.Scope, err)
}
if _, err = service.OpsUsageOverview(ctx, operator, billingOtherTenantID, "", "", "Asia/Shanghai", now.Add(-24*time.Hour), now); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("cross-tenant error=%v", err)
}
}
+1 -1
View File
@@ -376,7 +376,7 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
return actor{}, ErrInvalid
}
var result actor
err := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.role)
err := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.role)
if errors.Is(err, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied
}
+2 -2
View File
@@ -19,8 +19,8 @@ const (
billingTestIssuer = "http://localhost:58180/realms/voice-local"
billingTenantPublicID = "10000000-0000-0000-0000-000000000010"
billingOtherTenantID = "20000000-0000-0000-0000-000000000010"
billingAdminSubject = "00000000-0000-0000-0000-000000000101"
billingApproverSubject = "00000000-0000-0000-0000-000000000102"
billingAdminSubject = "00000000-0000-0000-0000-000000000104"
billingApproverSubject = "00000000-0000-0000-0000-000000000105"
billingDevicePublicID = "10000000-0000-0000-0000-000000000511"
billingTestSource = "integration://billing-service"
billingTestChangePrefix = "费用集成测试"