diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index b5e0bc2..7e25d28 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -5,6 +5,7 @@ import ( "time" "gorm.io/datatypes" + "gorm.io/gorm" ) type User struct { @@ -160,8 +161,12 @@ type ModelConfig struct { // successful generation. Independent of the event_log (which is subject to // retention / manual clearing), so the admin "次数" is a true running total. GenerationCount int64 `gorm:"not null;default:0"` - CreatedAt time.Time - UpdatedAt time.Time + // DeletedAt soft-deletes the row (vivid parity): hidden from every normal + // path, but the row + its counters survive so a re-import revives it + // without losing generation_count or pricing. + DeletedAt gorm.DeletedAt `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time } func (m ModelConfig) EffectiveName() string { diff --git a/backend/internal/repo/channel_repo.go b/backend/internal/repo/channel_repo.go index 73d71a8..b5591a2 100644 --- a/backend/internal/repo/channel_repo.go +++ b/backend/internal/repo/channel_repo.go @@ -212,13 +212,14 @@ func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID strin } // OrphanModelIDs returns local model ids that no channel binds anymore. +// Soft-deleted rows are skipped — they're already gone from every live path. func (r *ChannelRepository) OrphanModelIDs(ctx context.Context) ([]string, error) { var ids []string err := r.db.WithContext(ctx). Table("model_configs"). Select("model_configs.id"). Joins("LEFT JOIN channel_models ON channel_models.model_id = model_configs.id"). - Where("channel_models.model_id IS NULL"). + Where("channel_models.model_id IS NULL AND model_configs.deleted_at IS NULL"). Scan(&ids).Error return ids, err } diff --git a/backend/internal/repo/model_repo.go b/backend/internal/repo/model_repo.go index 367cf8b..43d8187 100644 --- a/backend/internal/repo/model_repo.go +++ b/backend/internal/repo/model_repo.go @@ -44,6 +44,16 @@ func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.Model return &item, nil } +// GetAny includes soft-deleted rows — the import/revive path and orphan +// cleanup need to see rows normal queries hide. +func (r *ModelRepository) GetAny(ctx context.Context, modelID string) (*model.ModelConfig, error) { + var item model.ModelConfig + if err := r.db.WithContext(ctx).Unscoped().First(&item, "(alias <> '' AND alias = ?) OR (alias = '' AND id = ?)", modelID, modelID).Error; err != nil { + return nil, err + } + return &item, nil +} + func (r *ModelRepository) NameMap(ctx context.Context) (map[string]string, error) { items, err := r.List(ctx) if err != nil { @@ -72,18 +82,40 @@ func (r *ModelRepository) Create(ctx context.Context, item *model.ModelConfig) e } func (r *ModelRepository) Update(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) { + return r.update(ctx, modelID, patch, false) +} + +// UpdateAny can reach soft-deleted rows (revive path: patch deleted_at=NULL). +func (r *ModelRepository) UpdateAny(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) { + return r.update(ctx, modelID, patch, true) +} + +func (r *ModelRepository) update(ctx context.Context, modelID string, patch map[string]any, anyState bool) (*model.ModelConfig, error) { patch["updated_at"] = time.Now() - if err := r.db.WithContext(ctx).Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil { + q := r.db.WithContext(ctx) + if anyState { + q = q.Unscoped() + } + if err := q.Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil { return nil, err } var item model.ModelConfig - if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil { + if err := q.First(&item, "id = ?", modelID).Error; err != nil { return nil, err } return &item, nil } +// Delete soft-deletes the model (gorm.DeletedAt): the row keeps its counters +// and pricing so re-importing it later revives the same record. func (r *ModelRepository) Delete(ctx context.Context, modelID string) (int64, error) { res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID) return res.RowsAffected, res.Error } + +// DeleteHard physically removes the row — only for legacy/provider-era rows +// that must not linger as soft-deleted junk. +func (r *ModelRepository) DeleteHard(ctx context.Context, modelID string) (int64, error) { + res := r.db.WithContext(ctx).Unscoped().Delete(&model.ModelConfig{}, "id = ?", modelID) + return res.RowsAffected, res.Error +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 7f3000a..ade07c8 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -381,11 +381,12 @@ func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) { } n := 0 for _, id := range ids { - m, gerr := s.models.Get(ctx, id) + m, gerr := s.models.GetAny(ctx, id) if gerr != nil || m == nil || m.Provider == "channel" { continue } - if _, derr := s.models.Delete(ctx, id); derr == nil { + // provider 时代残留行没有复活路径,直接物理删除,不留软删垃圾。 + if _, derr := s.models.DeleteHard(ctx, id); derr == nil { n++ } } @@ -454,7 +455,8 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe return fmt.Errorf("别名 %s 被多个上游模型复用", localID) } seenAliases[localID] = upID - existing, gerr := s.models.Get(ctx, localID) + // GetAny: 软删行也纳入——重导同一模型时复活原行,计数/定价不丢。 + existing, gerr := s.models.GetAny(ctx, localID) if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) { existing = nil } @@ -481,7 +483,10 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe return fmt.Errorf("model %s: %w", localID, err) } } else { - if _, err := s.models.Update(ctx, localID, patch); err != nil { + if local.DeletedAt.Valid { + patch["deleted_at"] = nil // revive + } + if _, err := s.models.UpdateAny(ctx, localID, patch); err != nil { return fmt.Errorf("model %s: %w", localID, err) } } diff --git a/frontend/src/style.css b/frontend/src/style.css index fb877b1..75f737c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -478,6 +478,8 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi } .btn-primary:hover:not(:disabled) { background: #27272a; box-shadow: 0 10px 22px -8px rgb(9 9 11 / 0.5); } .btn-primary:active:not(:disabled) { transform: scale(0.97); box-shadow: none; } + /* 禁用态:浅灰填充(vivid 同款),不再是变淡的黑块 */ + .btn-primary:disabled { background: var(--surface-2); color: var(--fg-3); border-color: transparent; box-shadow: none; opacity: 1; } .btn-soft { @apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;