Files
ballet-server/backend/internal/practice/store.go
T
yuxuanhui b0c62d461e feat: add project icon picker component and integrate with project management
- Implemented ProjectIconPicker component for selecting project icons.
- Created ProjectIcon component to display selected icons.
- Updated calendar page to remove unused PageHeading component.
- Modified growth page to enhance UI and improve text clarity.
- Refactored index page to include project icons and improve layout.
- Added mine page for user profile management with avatar selection.
- Introduced projects-sort page for managing project order with drag-and-drop functionality.
- Enhanced projects page to allow icon selection when adding or editing projects.
- Updated record page to streamline user experience and improve form actions.
- Adjusted practice service to support project icon management.
- Added tests for project saving functionality to ensure correct handling of icons.
- Updated global type definitions to support SVG and PNG imports.
2026-09-29 18:40:25 +08:00

261 lines
8.7 KiB
Go

package practice
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const NameLimit = 40
const NoteLimit = 2000
const MinuteLimit = 2147483647 // PostgreSQL integer storage limit; minutes remain exact.
var (
ErrInvalid = errors.New("输入无效,请检查后重试")
ErrUnavailable = errors.New("项目或记录不可用,请刷新后重试")
ErrUnauthorized = errors.New("登录已失效,请重新登录")
ErrArchived = errors.New("该项目已移出常用列表,请选择其他项目")
ErrConflict = errors.New("这次提交已保存了不同内容,请刷新记录后更正")
ErrDeleted = errors.New("这次提交的记录已被删除,不会重复创建")
)
var presets = []struct{ name, iconID string }{
{"零基础", "plie"}, {"基础提升", "tendu"}, {"软开素质", "flexibility"}, {"足髋训练", "foot-hip"},
{"核心臀腿", "core-glutes"}, {"小球核心", "ball-core"}, {"天鹅臂颈", "swan-arms"}, {"呼吸训练", "breathing"},
}
type Store struct {
db *pgxpool.Pool
now func() time.Time
}
// New keeps all ownership, dates, archival and persistence rules behind business operations.
// now is injectable so business-day boundaries can be verified without changing the host clock.
func New(db *pgxpool.Pool, now func() time.Time) *Store {
if now == nil {
now = time.Now
}
return &Store{db: db, now: now}
}
func newID() string { return rand.Text() }
func digest(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) }
// Today is a calendar date in the fixed first-release business timezone, not the device timezone.
func (s *Store) Today() string {
return s.now().In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.DateOnly)
}
type Session struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
}
// Login accepts only a server-verified, app-scoped WeChat identity. User creation,
// presets and the session commit together; deleted presets never reappear on login.
func (s *Store) Login(ctx context.Context, identity string) (Session, error) {
if identity == "" {
return Session{}, ErrUnauthorized
}
tx, err := s.db.Begin(ctx)
if err != nil {
return Session{}, err
}
defer tx.Rollback(ctx)
id := newID()
result, err := tx.Exec(ctx, "INSERT INTO users(id,wechat_identity) VALUES($1,$2) ON CONFLICT(wechat_identity) DO NOTHING", id, identity)
if err != nil {
return Session{}, err
}
if result.RowsAffected() == 1 {
for _, preset := range presets {
if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name,icon_id) VALUES($1,$2,$3,$4)", newID(), id, preset.name, preset.iconID); err != nil {
return Session{}, err
}
}
} else if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE wechat_identity=$1", identity).Scan(&id); err != nil {
return Session{}, err
}
// Serialize session maintenance for this user as well as business writes.
if _, err = tx.Exec(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", id); err != nil {
return Session{}, err
}
if _, err = tx.Exec(ctx, "DELETE FROM sessions WHERE user_id=$1 AND expires_at <= $2", id, s.now()); err != nil {
return Session{}, err
}
session := Session{Token: newID() + newID(), ExpiresAt: s.now().Add(30 * 24 * time.Hour)}
if _, err = tx.Exec(ctx, "INSERT INTO sessions(token_hash,user_id,expires_at) VALUES($1,$2,$3)", digest(session.Token), id, session.ExpiresAt); err != nil {
return Session{}, err
}
if err = tx.Commit(ctx); err != nil {
return Session{}, err
}
return session, nil
}
// Authenticate never accepts a client-provided user ID. Only opaque, unexpired
// bearer tokens are usable; their hashes, not tokens, are kept in PostgreSQL.
func (s *Store) Authenticate(ctx context.Context, token string) (string, error) {
if len(token) != 52 {
return "", ErrUnauthorized
}
var user string
err := s.db.QueryRow(ctx, "SELECT user_id FROM sessions WHERE token_hash=$1 AND expires_at>$2", digest(token), s.now()).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUnauthorized
}
return user, err
}
// write serializes a user's mutations. Project removal, record writes and
// idempotency receipts therefore see a consistent state even across devices.
func (s *Store) write(ctx context.Context, user string, fn func(pgx.Tx) error) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var id string
if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", user).Scan(&id); err != nil {
return err
}
if err = fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
IconID string `json:"iconId"`
Archived bool `json:"archived"`
HasRecords bool `json:"hasRecords"`
}
func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Project, error) {
rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.icon_id,p.archived,EXISTS(SELECT 1 FROM records r WHERE r.user_id=p.user_id AND r.project_id=p.id)
FROM projects p WHERE p.user_id=$1 AND ($2 OR NOT p.archived) ORDER BY p.position`, user, archived)
if err != nil {
return nil, err
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.IconID, &p.Archived, &p.HasRecords); err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// ReorderProjects accepts exactly the owner's active project IDs. Reusing their
// current position slots leaves archived history and automatic append positions intact.
func (s *Store) ReorderProjects(ctx context.Context, user string, ids []string) error {
if ids == nil {
return ErrInvalid
}
return s.write(ctx, user, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `SELECT id,position FROM projects WHERE user_id=$1 AND NOT archived ORDER BY position`, user)
if err != nil {
return err
}
positions := make([]int64, 0, len(ids))
available := make(map[string]bool, len(ids))
for rows.Next() {
var id string
var position int64
if err := rows.Scan(&id, &position); err != nil {
rows.Close()
return err
}
positions = append(positions, position)
available[id] = true
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if len(ids) != len(positions) {
return ErrInvalid
}
for _, id := range ids {
if !available[id] {
return ErrInvalid
}
delete(available, id)
}
for index, id := range ids {
if _, err := tx.Exec(ctx, `UPDATE projects SET position=$3 WHERE user_id=$1 AND id=$2 AND NOT archived`, user, id, positions[index]); err != nil {
return err
}
}
return nil
})
}
// SaveProject persists a name and optional icon choice within the owner's account.
// A nil iconID preserves existing artwork for older clients; an empty string clears it.
// Unknown icon IDs return ErrInvalid, and another owner's project returns ErrUnavailable.
func (s *Store) SaveProject(ctx context.Context, user, id, name string, iconID *string) (Project, error) {
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) {
return Project{}, ErrInvalid
}
if iconID != nil && !validProjectIcon(*iconID) {
return Project{}, ErrInvalid
}
p := Project{ID: id, Name: name}
if iconID != nil {
p.IconID = *iconID
}
err := s.write(ctx, user, func(tx pgx.Tx) error {
if id == "" {
p.ID = newID()
_, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name,icon_id) VALUES($1,$2,$3,$4)", p.ID, user, name, p.IconID)
return err
}
err := tx.QueryRow(ctx, `UPDATE projects SET name=$3,icon_id=COALESCE($4,icon_id) WHERE user_id=$1 AND id=$2 RETURNING icon_id,archived,
EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name, iconID).Scan(&p.IconID, &p.Archived, &p.HasRecords)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
return err
})
return p, err
}
func (s *Store) RemoveProject(ctx context.Context, user, id string) (string, error) {
mode := "deleted"
err := s.write(ctx, user, func(tx pgx.Tx) error {
var used bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)
FROM projects WHERE user_id=$1 AND id=$2`, user, id).Scan(&used)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
if used {
mode = "archived"
_, err = tx.Exec(ctx, "UPDATE projects SET archived=true WHERE user_id=$1 AND id=$2", user, id)
} else {
_, err = tx.Exec(ctx, "DELETE FROM projects WHERE user_id=$1 AND id=$2", user, id)
}
return err
})
return mode, err
}