diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go index fbe3bb6..830ef81 100644 --- a/backend/internal/http/handler/admin_read.go +++ b/backend/internal/http/handler/admin_read.go @@ -108,7 +108,10 @@ func (h *AdminReadHandler) Logs(c *gin.Context) { userName = item.UserID } var accountName any - if item.AccountID != "" { + if item.AccountEmail != "" { + // Email stamped on the row itself survives account deletion/re-import. + accountName = item.AccountEmail + } else if item.AccountID != "" { if label, ok := accountByID[item.AccountID]; ok { accountName = label } else { diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 4d4c70e..e65713e 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -284,7 +284,10 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) { // which upstream account (email) fulfilled their generation. var accountName, accountID any if userID == "" { - if item.AccountID != "" { + if item.AccountEmail != "" { + // Email stamped on the row itself survives account deletion/re-import. + accountName = item.AccountEmail + } else if item.AccountID != "" { if label, ok := accountByID[item.AccountID]; ok { accountName = label } else { diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 6f370d6..1acbccf 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -48,12 +48,12 @@ type BannedWord struct { // BannedWordHit records one blocked request: which word matched, who sent it, // and when. Feeds the admin 违禁词触发列表. type BannedWordHit struct { - ID string `gorm:"primaryKey;size:32"` - WordID string `gorm:"size:32;index"` - Word string `gorm:"size:255;index;not null"` - UserID string `gorm:"size:32;index"` - UserName string `gorm:"size:255"` // snapshot of name/email at hit time - Prompt string `gorm:"type:text"` + ID string `gorm:"primaryKey;size:32"` + WordID string `gorm:"size:32;index"` + Word string `gorm:"size:255;index;not null"` + UserID string `gorm:"size:32;index"` + UserName string `gorm:"size:255"` // snapshot of name/email at hit time + Prompt string `gorm:"type:text"` CreatedAt time.Time `gorm:"index"` } @@ -99,9 +99,13 @@ type EventLog struct { // stamped when the upstream call begins. Drives the accounts view's live // in-flight count (pending events per account) and lets an abandoned-event // purge attribute the failure back to the account it was using. - AccountID string `gorm:"size:64;index"` - UserID string `gorm:"size:32;index"` - Cost float64 `gorm:"not null;default:0"` + AccountID string `gorm:"size:64;index"` + // AccountEmail denormalizes the account's email at stamp time, so log rows + // keep showing which mailbox fulfilled the generation even after the account + // is deleted or re-imported under a different ID. + AccountEmail string `gorm:"size:255"` + UserID string `gorm:"size:32;index"` + Cost float64 `gorm:"not null;default:0"` // Refunded marks that this event's up-front charge has already been credited // back, so the normal failure path and the abandoned-purge sweep can never // double-refund the same generation. diff --git a/backend/internal/repo/event_repo.go b/backend/internal/repo/event_repo.go index 6890ba4..64b1432 100644 --- a/backend/internal/repo/event_repo.go +++ b/backend/internal/repo/event_repo.go @@ -600,11 +600,11 @@ func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (boo // SetAccount stamps which provider account is fulfilling an in-flight event. // Called when generation commits to a token, so the accounts view can count // pending events per account and an abandoned-event purge can attribute back. -func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID string) error { +func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, accountEmail string) error { return r.db.WithContext(ctx). Model(&model.EventLog{}). Where("id = ?", eventID). - Update("account_id", accountID).Error + Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error } // InFlightByAccount counts pending (in-flight) events grouped by account_id, for diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index db4ef4b..3b24645 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -1457,7 +1457,7 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token refreshOnAuth func(tokenID string) (model.TokenAccount, bool), tempFailover bool, ) ([]byte, error, bool, bool) { - _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) authRefreshed := false tempAttempts := 0 @@ -1711,7 +1711,7 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod var data []byte done, failover := func() (bool, bool) { defer s.acctRelease(ctx, token.ID, eventID) - _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) teamID := "" if token.Meta != nil { @@ -1851,7 +1851,7 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod var data []byte done, failover := func() (bool, bool) { defer s.acctRelease(ctx, token.ID, eventID) - _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) baseURL := stringValue(token.Meta["base_url"]) d, genErr := s.custom.GenerateImage(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, quality, refs) @@ -1919,7 +1919,7 @@ func (s *V1Service) generateCustomVideo(ctx context.Context, eventID string, mod var data []byte done, failover := func() (bool, bool) { defer s.acctRelease(ctx, token.ID, eventID) - _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) baseURL := stringValue(token.Meta["base_url"]) d, url, genErr := s.custom.GenerateVideo(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, durationSeconds, downloadResult) @@ -2091,7 +2091,7 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model var data []byte done, failover := func() (bool, bool) { defer s.acctRelease(ctx, token.ID, eventID) - _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) d, meta, genErr := s.grok.GenerateVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult) if genErr == nil { @@ -2193,7 +2193,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod var data []byte done, failover := func() (bool, bool) { defer s.acctRelease(ctx, token.ID, eventID) - _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail) _ = s.tokens.TouchLastUsed(ctx, token.ID) teamID := "" if token.Meta != nil {