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 }