6224ef5980
- Implemented a new Growth page to track practice time and trends. - Added API integration for fetching review data. - Created components for displaying practice statistics and trends. - Updated navigation titles for the main index and growth pages. - Removed unused styles from the index page. - Introduced a Projects management page for adding and editing practice projects. - Developed a Record form for logging practice sessions with validation. - Added utility functions for date manipulation and duration formatting. - Implemented error handling and session management in the practice service. - Created unit tests for the practice service to ensure reliability.
202 lines
6.7 KiB
Go
202 lines
6.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 = []string{"零基础", "基础提升", "软开素质", "足髋训练", "核心臀腿", "小球核心", "天鹅臂颈", "呼吸训练"}
|
|
|
|
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 _, name := range presets {
|
|
if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", newID(), id, name); 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"`
|
|
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.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.Archived, &p.HasRecords); err != nil {
|
|
return nil, err
|
|
}
|
|
projects = append(projects, p)
|
|
}
|
|
return projects, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SaveProject(ctx context.Context, user, id, name string) (Project, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) {
|
|
return Project{}, ErrInvalid
|
|
}
|
|
p := Project{ID: id, Name: name}
|
|
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) VALUES($1,$2,$3)", p.ID, user, name)
|
|
return err
|
|
}
|
|
err := tx.QueryRow(ctx, `UPDATE projects SET name=$3 WHERE user_id=$1 AND id=$2 RETURNING archived,
|
|
EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name).Scan(&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
|
|
}
|