fix: upload part tmp+rename (B4), transactional last-admin DeleteUser (B5), RowsAffected order (B13)

This commit is contained in:
2026-09-14 19:42:11 +08:00
parent 8fbc58eaaa
commit b82a891c50
4 changed files with 108 additions and 27 deletions
+44 -10
View File
@@ -2,12 +2,23 @@ package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrLastAdmin is returned by DeleteUser when attempting to delete the last admin.
var ErrLastAdmin = errors.New("cannot delete the last admin")
// IsUniqueViolation reports whether err is a PostgreSQL unique constraint violation (23505).
func IsUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
type Store struct{ P *pgxpool.Pool }
func New(p *pgxpool.Pool) *Store { return &Store{P: p} }
@@ -103,9 +114,31 @@ func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
return out, rows.Err()
}
// DeleteUser deletes a user with a transactional last-admin check (B5).
// Returns ErrLastAdmin if the target is the last admin.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
_, err := s.P.Exec(ctx, "DELETE FROM users WHERE id=$1", id)
return err
tx, err := s.P.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var role string
if err := tx.QueryRow(ctx, "SELECT role FROM users WHERE id=$1 FOR UPDATE", id).Scan(&role); err != nil {
return err // includes pgx.ErrNoRows
}
if role == "admin" {
var n int
if err := tx.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n); err != nil {
return err
}
if n <= 1 {
return ErrLastAdmin
}
}
if _, err := tx.Exec(ctx, "DELETE FROM users WHERE id=$1", id); err != nil {
return err
}
return tx.Commit(ctx)
}
func scanUser(row pgx.Row) (User, error) {
@@ -114,12 +147,7 @@ func scanUser(row pgx.Row) (User, error) {
return u, err
}
// CountAdmins 供 Task 5 的"最后一个 admin 不可删"保护
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
var n int
err := s.P.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n)
return n, err
}
// ---------- libraries ----------
@@ -357,12 +385,18 @@ func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath
func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) {
res, err := s.P.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note)
return res.RowsAffected() > 0, err
if err != nil {
return false, err
}
return res.RowsAffected() > 0, nil
}
func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) {
res, err := s.P.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID)
return res.RowsAffected() > 0, err
if err != nil {
return false, err
}
return res.RowsAffected() > 0, nil
}
func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) {
+48
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"errors"
"os"
"testing"
@@ -129,3 +130,50 @@ func TestProgressUpsertAndJoin(t *testing.T) {
t.Fatalf("views %+v", views)
}
}
func TestDeleteUserLastAdmin(t *testing.T) {
s := setup(t)
ctx := context.Background()
id, err := s.CreateUser(ctx, "onlyadmin", "hash", "admin")
if err != nil {
t.Fatal(err)
}
err = s.DeleteUser(ctx, id)
if !errors.Is(err, ErrLastAdmin) {
t.Fatalf("expected ErrLastAdmin, got %v", err)
}
// Verify user still exists.
_, err = s.GetUserByID(ctx, id)
if err != nil {
t.Fatal("admin should still exist after blocked delete")
}
}
func TestDeleteUserNonLastAdmin(t *testing.T) {
s := setup(t)
ctx := context.Background()
id1, _ := s.CreateUser(ctx, "admin1", "h", "admin")
_, _ = s.CreateUser(ctx, "admin2", "h", "admin")
if err := s.DeleteUser(ctx, id1); err != nil {
t.Fatalf("should allow deleting non-last admin: %v", err)
}
}
func TestDeleteUserMember(t *testing.T) {
s := setup(t)
ctx := context.Background()
id, _ := s.CreateUser(ctx, "member", "h", "member")
if err := s.DeleteUser(ctx, id); err != nil {
t.Fatalf("member delete should succeed: %v", err)
}
}
func TestIsUniqueViolation(t *testing.T) {
s := setup(t)
ctx := context.Background()
_, _ = s.CreateUser(ctx, "dup", "h", "member")
_, err := s.CreateUser(ctx, "dup", "h", "member")
if !IsUniqueViolation(err) {
t.Fatalf("expected unique violation, got %v", err)
}
}