package postgres import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "telesrv/internal/domain" "telesrv/internal/store/postgres/sqlcgen" ) // AIComposeStore 用 PostgreSQL 实现 store.AIComposeStore。 type AIComposeStore struct { db sqlcgen.DBTX } func NewAIComposeStore(db sqlcgen.DBTX) *AIComposeStore { return &AIComposeStore{db: db} } const aiComposeToneColumns = `id, access_hash, owner_user_id, slug, title, emoji_id, prompt, display_author, installs_count, created_at, updated_at` func (s *AIComposeStore) CreateAIComposeTone(ctx context.Context, tone domain.AIComposeTone) error { if tone.ID == 0 || tone.AccessHash == 0 || tone.OwnerUserID == 0 || tone.Slug == "" { return domain.ErrAIComposeToneInvalid } createdAt := time.Now() if tone.CreatedAt > 0 { createdAt = time.Unix(tone.CreatedAt, 0) } updatedAt := createdAt if tone.UpdatedAt > 0 { updatedAt = time.Unix(tone.UpdatedAt, 0) } _, err := s.db.Exec(ctx, ` INSERT INTO ai_compose_tones ( id, access_hash, owner_user_id, slug, title, emoji_id, prompt, display_author, installs_count, created_at, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, tone.ID, tone.AccessHash, tone.OwnerUserID, tone.Slug, tone.Title, tone.EmojiID, tone.Prompt, tone.DisplayAuthor, tone.InstallsCount, createdAt, updatedAt) if err != nil { if isUniqueViolation(err) { return domain.ErrAIComposeToneInvalid } return fmt.Errorf("insert ai compose tone: %w", err) } return nil } func (s *AIComposeStore) UpdateAIComposeTone(ctx context.Context, tone domain.AIComposeTone) error { updatedAt := time.Now() if tone.UpdatedAt > 0 { updatedAt = time.Unix(tone.UpdatedAt, 0) } tag, err := s.db.Exec(ctx, ` UPDATE ai_compose_tones SET title = $3, emoji_id = $4, prompt = $5, display_author = $6, updated_at = $7 WHERE id = $1 AND owner_user_id = $2`, tone.ID, tone.OwnerUserID, tone.Title, tone.EmojiID, tone.Prompt, tone.DisplayAuthor, updatedAt) if err != nil { return fmt.Errorf("update ai compose tone: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrAIComposeToneNotFound } return nil } func (s *AIComposeStore) DeleteAIComposeTone(ctx context.Context, ownerUserID, toneID int64) error { tag, err := s.db.Exec(ctx, `DELETE FROM ai_compose_tones WHERE id = $1 AND owner_user_id = $2`, toneID, ownerUserID) if err != nil { return fmt.Errorf("delete ai compose tone: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrAIComposeToneNotFound } return nil } func (s *AIComposeStore) GetAIComposeToneByID(ctx context.Context, id, accessHash int64) (domain.AIComposeTone, bool, error) { row := s.db.QueryRow(ctx, `SELECT `+aiComposeToneColumns+` FROM ai_compose_tones WHERE id = $1 AND access_hash = $2`, id, accessHash) tone, err := scanAIComposeTone(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.AIComposeTone{}, false, nil } return domain.AIComposeTone{}, false, fmt.Errorf("get ai compose tone by id: %w", err) } return tone, true, nil } func (s *AIComposeStore) GetAIComposeToneBySlug(ctx context.Context, slug string) (domain.AIComposeTone, bool, error) { row := s.db.QueryRow(ctx, `SELECT `+aiComposeToneColumns+` FROM ai_compose_tones WHERE slug = $1`, slug) tone, err := scanAIComposeTone(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.AIComposeTone{}, false, nil } return domain.AIComposeTone{}, false, fmt.Errorf("get ai compose tone by slug: %w", err) } return tone, true, nil } func (s *AIComposeStore) ListAIComposeTonesForUser(ctx context.Context, userID int64) ([]domain.AIComposeTone, error) { rows, err := s.db.Query(ctx, ` SELECT `+aiComposeToneColumns+`, (owner_user_id = $1) AS creator, true AS saved FROM ai_compose_tones WHERE owner_user_id = $1 UNION ALL SELECT `+prefixAIComposeToneColumns("t")+`, false AS creator, true AS saved FROM ai_compose_tones t JOIN ai_compose_tone_saves s ON s.tone_id = t.id WHERE s.user_id = $1 AND t.owner_user_id <> $1 ORDER BY creator DESC, updated_at DESC, id ASC`, userID) if err != nil { return nil, fmt.Errorf("list ai compose tones: %w", err) } defer rows.Close() out := make([]domain.AIComposeTone, 0) for rows.Next() { tone, err := scanAIComposeToneWithFlags(rows) if err != nil { return nil, fmt.Errorf("scan ai compose tone: %w", err) } out = append(out, tone) } return out, rows.Err() } func (s *AIComposeStore) SaveAIComposeTone(ctx context.Context, userID, toneID int64) error { return withTx(ctx, s.db, "save ai compose tone", func(tx pgx.Tx) error { var ownerUserID int64 if err := tx.QueryRow(ctx, `SELECT owner_user_id FROM ai_compose_tones WHERE id = $1`, toneID).Scan(&ownerUserID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrAIComposeToneNotFound } return fmt.Errorf("select ai compose tone owner: %w", err) } if ownerUserID == userID { return nil } tag, err := tx.Exec(ctx, ` INSERT INTO ai_compose_tone_saves (user_id, tone_id, saved_at) VALUES ($1,$2,now()) ON CONFLICT (user_id, tone_id) DO NOTHING`, userID, toneID) if err != nil { return fmt.Errorf("save ai compose tone: %w", err) } if tag.RowsAffected() > 0 { if _, err := tx.Exec(ctx, `UPDATE ai_compose_tones SET installs_count = installs_count + 1 WHERE id = $1`, toneID); err != nil { return fmt.Errorf("increment ai compose tone installs: %w", err) } } return nil }) } func (s *AIComposeStore) UnsaveAIComposeTone(ctx context.Context, userID, toneID int64) error { _, err := s.db.Exec(ctx, `DELETE FROM ai_compose_tone_saves WHERE user_id = $1 AND tone_id = $2`, userID, toneID) if err != nil { return fmt.Errorf("unsave ai compose tone: %w", err) } return nil } func (s *AIComposeStore) SavedAIComposeToneCount(ctx context.Context, userID int64) (int, error) { var count int if err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM ( SELECT id FROM ai_compose_tones WHERE owner_user_id = $1 UNION SELECT tone_id FROM ai_compose_tone_saves WHERE user_id = $1 ) x`, userID).Scan(&count); err != nil { return 0, fmt.Errorf("count ai compose tones: %w", err) } return count, nil } func scanAIComposeTone(row pgx.Row) (domain.AIComposeTone, error) { var ( tone domain.AIComposeTone createdAt time.Time updatedAt time.Time ) if err := row.Scan(&tone.ID, &tone.AccessHash, &tone.OwnerUserID, &tone.Slug, &tone.Title, &tone.EmojiID, &tone.Prompt, &tone.DisplayAuthor, &tone.InstallsCount, &createdAt, &updatedAt); err != nil { return domain.AIComposeTone{}, err } tone.CreatedAt = createdAt.Unix() tone.UpdatedAt = updatedAt.Unix() if tone.DisplayAuthor { tone.AuthorID = tone.OwnerUserID } return tone, nil } func scanAIComposeToneWithFlags(row pgx.Row) (domain.AIComposeTone, error) { var ( tone domain.AIComposeTone createdAt time.Time updatedAt time.Time ) if err := row.Scan(&tone.ID, &tone.AccessHash, &tone.OwnerUserID, &tone.Slug, &tone.Title, &tone.EmojiID, &tone.Prompt, &tone.DisplayAuthor, &tone.InstallsCount, &createdAt, &updatedAt, &tone.Creator, &tone.Saved); err != nil { return domain.AIComposeTone{}, err } tone.CreatedAt = createdAt.Unix() tone.UpdatedAt = updatedAt.Unix() if tone.DisplayAuthor { tone.AuthorID = tone.OwnerUserID } return tone, nil } func prefixAIComposeToneColumns(prefix string) string { return prefix + `.id, ` + prefix + `.access_hash, ` + prefix + `.owner_user_id, ` + prefix + `.slug, ` + prefix + `.title, ` + prefix + `.emoji_id, ` + prefix + `.prompt, ` + prefix + `.display_author, ` + prefix + `.installs_count, ` + prefix + `.created_at, ` + prefix + `.updated_at` }