Files
nofx/store/ai_model.go
T
shinchan-zhai 4f0a922779 feat: add "forgot account" reset flow with wallet preservation
Add account reset functionality for users who forgot their login credentials.
The reset clears authentication data while preserving wallet private keys and
exchange configs, which are automatically adopted by the new account on
re-registration to prevent fund loss.

- Add POST /api/reset-account endpoint
- Add "Forgot account?" button on login page (zh/en/id)
- Orphan ai_models and exchanges are re-assigned to new user on register
- Onboarding reuses existing claw402 wallet instead of generating new one

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:00:56 +08:00

269 lines
7.7 KiB
Go

package store
import (
"errors"
"fmt"
"nofx/crypto"
"nofx/logger"
"strings"
"time"
"gorm.io/gorm"
)
// AIModelStore AI model storage
type AIModelStore struct {
db *gorm.DB
}
// AIModel AI model configuration
type AIModel struct {
ID string `gorm:"primaryKey" json:"id"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Provider string `gorm:"not null" json:"provider"`
Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AIModel) TableName() string { return "ai_models" }
// NewAIModelStore creates a new AIModelStore
func NewAIModelStore(db *gorm.DB) *AIModelStore {
return &AIModelStore{db: db}
}
func (s *AIModelStore) initTables() error {
// For PostgreSQL with existing table, skip AutoMigrate
if s.db.Dialector.Name() == "postgres" {
var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'ai_models'`).Scan(&tableExists)
if tableExists > 0 {
return nil
}
}
return s.db.AutoMigrate(&AIModel{})
}
func (s *AIModelStore) initDefaultData() error {
// No longer pre-populate AI models - create on demand when user configures
return nil
}
// FindOrphanClaw402 finds a claw402 model whose user_id no longer exists in the users table.
// Used to recover wallets after account reset.
func (s *AIModelStore) FindOrphanClaw402() (*AIModel, error) {
var model AIModel
err := s.db.Where("provider = ? AND api_key != '' AND user_id NOT IN (SELECT id FROM users)", "claw402").
First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
// AdoptModel re-assigns an existing model to a new user.
func (s *AIModelStore) AdoptModel(modelID, newUserID string) error {
return s.db.Model(&AIModel{}).Where("id = ?", modelID).
Update("user_id", newUserID).Error
}
// List retrieves user's AI model list
func (s *AIModelStore) List(userID string) ([]*AIModel, error) {
var models []*AIModel
err := s.db.Where("user_id = ?", userID).Order("id").Find(&models).Error
if err != nil {
return nil, err
}
return models, nil
}
// Get retrieves a single AI model
func (s *AIModelStore) Get(userID, modelID string) (*AIModel, error) {
if modelID == "" {
return nil, fmt.Errorf("model ID cannot be empty")
}
candidates := []string{}
if userID != "" {
candidates = append(candidates, userID)
}
if userID != "default" {
candidates = append(candidates, "default")
}
if len(candidates) == 0 {
candidates = append(candidates, "default")
}
for _, uid := range candidates {
var model AIModel
err := s.db.Where("user_id = ? AND id = ?", uid, modelID).First(&model).Error
if err == nil {
return &model, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
return nil, gorm.ErrRecordNotFound
}
// GetByID retrieves an AI model by ID only
func (s *AIModelStore) GetByID(modelID string) (*AIModel, error) {
if modelID == "" {
return nil, fmt.Errorf("model ID cannot be empty")
}
var model AIModel
err := s.db.Where("id = ?", modelID).First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
// GetDefault retrieves the default enabled AI model
func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
if userID == "" {
userID = "default"
}
model, err := s.firstEnabled(userID)
if err == nil {
return model, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if userID != "default" {
return s.firstEnabled("default")
}
return nil, fmt.Errorf("please configure an available AI model in the system first")
}
func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
var model AIModel
err := s.db.Where("user_id = ? AND enabled = ?", userID, true).
Order("updated_at DESC, id ASC").
First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
// GetAnyEnabled returns the first enabled AI model across all users.
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
var model AIModel
err := s.db.Where("enabled = ? AND api_key != ''", true).
Order("updated_at DESC, id ASC").
First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
// Update updates AI model, creates if not exists
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
// Try exact ID match first
var existingModel AIModel
err := s.db.Where("user_id = ? AND id = ?", userID, id).First(&existingModel).Error
if err == nil {
// Update existing model
updates := map[string]interface{}{
"enabled": enabled,
"custom_api_url": customAPIURL,
"custom_model_name": customModelName,
"updated_at": time.Now().UTC(),
}
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
if apiKey != "" {
updates["api_key"] = crypto.EncryptedString(apiKey)
}
return s.db.Model(&existingModel).Updates(updates).Error
}
// Try legacy logic compatibility: use id as provider to search
provider := id
err = s.db.Where("user_id = ? AND provider = ?", userID, provider).First(&existingModel).Error
if err == nil {
logger.Warnf("⚠️ Using legacy provider matching to update model: %s -> %s", provider, existingModel.ID)
updates := map[string]interface{}{
"enabled": enabled,
"custom_api_url": customAPIURL,
"custom_model_name": customModelName,
"updated_at": time.Now().UTC(),
}
if apiKey != "" {
updates["api_key"] = crypto.EncryptedString(apiKey)
}
return s.db.Model(&existingModel).Updates(updates).Error
}
// Create new record
if provider == id && (provider == "deepseek" || provider == "qwen") {
provider = id
} else {
parts := strings.Split(id, "_")
if len(parts) >= 2 {
provider = parts[len(parts)-1]
} else {
provider = id
}
}
// Try to get name from existing model with same provider
var refModel AIModel
var name string
if err := s.db.Where("provider = ?", provider).First(&refModel).Error; err == nil {
name = refModel.Name
} else {
if provider == "deepseek" {
name = "DeepSeek AI"
} else if provider == "qwen" {
name = "Qwen AI"
} else {
name = provider + " AI"
}
}
newModelID := id
if id == provider {
newModelID = fmt.Sprintf("%s_%s", userID, provider)
}
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, name)
newModel := &AIModel{
ID: newModelID,
UserID: userID,
Name: name,
Provider: provider,
Enabled: enabled,
APIKey: crypto.EncryptedString(apiKey),
CustomAPIURL: customAPIURL,
CustomModelName: customModelName,
}
return s.db.Create(newModel).Error
}
// Create creates an AI model
func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
model := &AIModel{
ID: id,
UserID: userID,
Name: name,
Provider: provider,
Enabled: enabled,
APIKey: crypto.EncryptedString(apiKey),
CustomAPIURL: customAPIURL,
}
// Use FirstOrCreate to ignore if already exists
return s.db.Where("id = ?", id).FirstOrCreate(model).Error
}