Files
pipu-music/services/api/internal/wave/onboarding.go
T
mrleo1nid fff91c446b
ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
feat(recommendations): collect demographics and advance age cohorts
2026-10-22 14:19:52 +03:00

164 lines
5.6 KiB
Go

package wave
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель
// берёт их как начальный вкус, пока своей истории мало. Пропуск сохраняется пустым выбором.
const (
onboardingArtists = 40
onboardingGenres = 24
)
// GetOnboarding реализует gen.StrictServerInterface.
func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestObject) (gen.GetOnboardingResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
out := gen.GetOnboarding200JSONResponse{}
var raw []byte
if err := h.pool.QueryRow(ctx, `
SELECT (SELECT onboarding || jsonb_build_object('age_group', rec_age_group(onboarding))
FROM user_profiles_rec WHERE user_id = $1)`, p.UserID).Scan(&raw); err != nil {
return nil, fmt.Errorf("wave: onboarding state: %w", err)
}
if len(raw) > 0 {
out.Choice = &gen.OnboardingChoice{}
if err := json.Unmarshal(raw, out.Choice); err != nil {
return nil, err
}
}
rows, err := h.pool.Query(ctx, `
SELECT a.id, a.name, count(*) AS n,
(SELECT al.id FROM albums al WHERE al.album_artist_id = a.id
ORDER BY (SELECT count(*) FROM tracks t WHERE t.album_id = al.id) DESC, al.id LIMIT 1)
FROM artists a JOIN track_artists ta ON ta.artist_id = a.id AND ta.role = 'main'
GROUP BY a.id ORDER BY n DESC, a.name LIMIT $1`, onboardingArtists)
if err != nil {
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
}
type artist struct {
id, album *uuid.UUID
name string
n int
}
var artists []artist
for rows.Next() {
var a artist
var id uuid.UUID
if err := rows.Scan(&id, &a.name, &a.n, &a.album); err != nil {
rows.Close()
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
}
a.id = &id
artists = append(artists, a)
}
rows.Close()
dto := h.dto
for _, a := range artists {
item := struct {
CoverColors []string `json:"cover_colors"`
CoverUrl *string `json:"cover_url"`
Id uuid.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{Id: *a.id, Name: a.name, TrackCount: a.n, CoverColors: []string{}}
if a.album != nil {
if al, err := h.catalog.Album(ctx, *a.album); err == nil {
sum := dto.AlbumSummary(al)
item.CoverUrl, item.CoverColors = sum.CoverUrl, sum.CoverColors
}
}
out.Artists = append(out.Artists, item)
}
grows, err := h.pool.Query(ctx, `
SELECT g.name, count(DISTINCT tg.track_id) AS n FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
GROUP BY g.name ORDER BY n DESC, g.name LIMIT $1`, onboardingGenres)
if err != nil {
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
}
for grows.Next() {
var g struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
}
if err := grows.Scan(&g.Name, &g.TrackCount); err != nil {
grows.Close()
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
}
out.Genres = append(out.Genres, g)
}
grows.Close()
if out.Artists == nil {
out.Artists = []struct {
CoverColors []string `json:"cover_colors"`
CoverUrl *string `json:"cover_url"`
Id uuid.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{}
}
if out.Genres == nil {
out.Genres = []struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{}
}
out.Required = out.Choice == nil
return out, nil
}
// SaveOnboarding реализует gen.StrictServerInterface.
func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequestObject) (gen.SaveOnboardingResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if req.Body == nil {
return nil, apperr.Validation("body", "common.bad_request", "body required", nil)
}
if req.Body.Gender != nil && *req.Body.Gender != "male" && *req.Body.Gender != "female" && *req.Body.Gender != "other" {
return nil, apperr.Validation("gender", "common.bad_request", "invalid gender", nil)
}
if req.Body.AgeGroup != nil {
switch *req.Body.AgeGroup {
case "under_13", "13_19", "20_29", "30_39", "40_49", "50_59", "60_plus":
default:
return nil, apperr.Validation("age_group", "common.bad_request", "invalid age group", nil)
}
}
ids := req.Body.ArtistIds
if ids == nil {
ids = []uuid.UUID{}
}
genres := req.Body.Genres
if genres == nil {
genres = []string{}
}
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres, "gender": req.Body.Gender, "age_group": req.Body.AgeGroup})
if _, err := h.pool.Exec(ctx, `
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2::jsonb || jsonb_build_object('age_group_year', EXTRACT(YEAR FROM CURRENT_DATE)::int), now())
ON CONFLICT (user_id) DO UPDATE SET onboarding = CASE
WHEN rec_age_group(user_profiles_rec.onboarding) = EXCLUDED.onboarding->>'age_group'
THEN EXCLUDED.onboarding || jsonb_build_object(
'age_group', user_profiles_rec.onboarding->'age_group',
'age_group_year', user_profiles_rec.onboarding->'age_group_year')
ELSE EXCLUDED.onboarding END, updated_at = now()`,
p.UserID, raw); err != nil {
return nil, fmt.Errorf("wave: save onboarding: %w", err)
}
return gen.SaveOnboarding204Response{}, nil
}