Skip to content

表单提交 - 用户注册防重 ​

概述 ​

用户注册是典型的幂等性场景。用户可能因为网络延迟、页面刷新或误操作而多次点击注册按钮,必须保证同一用户只创建一次账户。

核心挑战 ​

  1. 重复点击:注册按钮被多次点击
  2. 页面刷新:浏览器重新发送 POST 请求
  3. 网络重试:客户端自动重试超时请求
  4. 并发注册:同一用户使用不同邮箱尝试注册

完整实现方案 ​

1. 数据库设计(PostgreSQL) ​

sql
-- 用户表
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- 唯一标识
    email VARCHAR(255) NOT NULL,
    username VARCHAR(50) NOT NULL,
    phone VARCHAR(20),
    
    -- 认证信息
    password_hash VARCHAR(255) NOT NULL,
    
    -- 幂等性控制
    registration_token VARCHAR(64) UNIQUE,
    
    -- 状态
    status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, active, suspended
    email_verified BOOLEAN NOT NULL DEFAULT FALSE,
    
    -- 时间戳
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    last_login_at TIMESTAMP WITH TIME ZONE,
    
    -- 唯一约束
    CONSTRAINT uk_users_email UNIQUE (email),
    CONSTRAINT uk_users_username UNIQUE (username),
    CONSTRAINT uk_users_phone UNIQUE (phone) FILTER (WHERE phone IS NOT NULL)
);

-- 索引
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_created_at ON users(created_at);

-- 注册审计表
CREATE TABLE registration_audit (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) NOT NULL,
    username VARCHAR(50) NOT NULL,
    ip_address INET,
    user_agent TEXT,
    registration_token VARCHAR(64),
    status VARCHAR(20) NOT NULL, -- success, duplicate, failed
    error_message TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_registration_audit_email ON registration_audit(email);
CREATE INDEX idx_registration_audit_token ON registration_audit(registration_token);

2. C# 实体类 ​

csharp
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

public class User
{
    [Key]
    public Guid Id { get; set; }
    
    [Required]
    [MaxLength(255)]
    [EmailAddress]
    public string Email { get; set; }
    
    [Required]
    [MaxLength(50)]
    public string Username { get; set; }
    
    [MaxLength(20)]
    public string? Phone { get; set; }
    
    [Required]
    public string PasswordHash { get; set; }
    
    [MaxLength(64)]
    public string? RegistrationToken { get; set; }
    
    [Required]
    [MaxLength(20)]
    public string Status { get; set; } = "pending";
    
    public bool EmailVerified { get; set; }
    
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
    public DateTime? LastLoginAt { get; set; }
}

public class RegistrationAudit
{
    [Key]
    public Guid Id { get; set; }
    
    [Required]
    [MaxLength(255)]
    public string Email { get; set; }
    
    [Required]
    [MaxLength(50)]
    public string Username { get; set; }
    
    public string? IpAddress { get; set; }
    public string? UserAgent { get; set; }
    
    [MaxLength(64)]
    public string? RegistrationToken { get; set; }
    
    [Required]
    [MaxLength(20)]
    public string Status { get; set; }
    
    public string? ErrorMessage { get; set; }
    
    public DateTime CreatedAt { get; set; }
}

3. 注册服务实现 ​

csharp
public class RegistrationService
{
    private readonly AppDbContext _dbContext;
    private readonly IPasswordHasher _passwordHasher;
    private readonly IEmailService _emailService;
    private readonly ILogger<RegistrationService> _logger;
    
    public async Task<Result<User>> RegisterAsync(
        RegisterRequest request,
        string registrationToken,
        HttpContext httpContext)
    {
        // 验证 Token
        if (string.IsNullOrWhiteSpace(registrationToken))
        {
            return Result<User>.Failure("Registration token is required");
        }
        
        await using var transaction = await _dbContext.Database.BeginTransactionAsync();
        
        try
        {
            // 1. 检查 Token 是否已使用
            var existingUser = await _dbContext.Users
                .FirstOrDefaultAsync(u => u.RegistrationToken == registrationToken);
            
            if (existingUser != null)
            {
                // Token 已使用,返回已有用户
                _logger.LogInformation("Registration token already used: {Token}", registrationToken);
                
                await RecordAudit(request, registrationToken, httpContext, "duplicate", null);
                
                return Result<User>.Success(existingUser);
            }
            
            // 2. 检查邮箱是否已注册
            var existingEmail = await _dbContext.Users
                .AnyAsync(u => u.Email == request.Email.ToLowerInvariant());
            
            if (existingEmail)
            {
                await RecordAudit(request, registrationToken, httpContext, "failed", 
                    "Email already registered");
                
                return Result<User>.Failure("Email already registered");
            }
            
            // 3. 检查用户名是否已被使用
            var existingUsername = await _dbContext.Users
                .AnyAsync(u => u.Username == request.Username);
            
            if (existingUsername)
            {
                await RecordAudit(request, registrationToken, httpContext, "failed",
                    "Username already taken");
                
                return Result<User>.Failure("Username already taken");
            }
            
            // 4. 创建用户
            var user = new User
            {
                Id = Guid.NewGuid(),
                Email = request.Email.ToLowerInvariant(),
                Username = request.Username,
                Phone = request.Phone,
                PasswordHash = _passwordHasher.HashPassword(request.Password),
                RegistrationToken = registrationToken,
                Status = "active",
                EmailVerified = false,
                CreatedAt = DateTime.UtcNow,
                UpdatedAt = DateTime.UtcNow
            };
            
            _dbContext.Users.Add(user);
            await _dbContext.SaveChangesAsync();
            
            // 5. 发送验证邮件
            try
            {
                await _emailService.SendVerificationEmailAsync(user.Email, user.Id);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to send verification email to {Email}", user.Email);
                // 不中断注册流程
            }
            
            await RecordAudit(request, registrationToken, httpContext, "success", null);
            
            await transaction.CommitAsync();
            
            _logger.LogInformation("User registered successfully: {Email}", user.Email);
            
            return Result<User>.Success(user);
        }
        catch (DbUpdateException ex) when (IsUniqueViolation(ex))
        {
            await transaction.RollbackAsync();
            
            var constraintName = ExtractConstraintName(ex);
            
            _logger.LogWarning("Unique constraint violation during registration: {Constraint}", 
                constraintName);
            
            await RecordAudit(request, registrationToken, httpContext, "failed", 
                $"Database constraint violation: {constraintName}");
            
            if (constraintName.Contains("email"))
            {
                return Result<User>.Failure("Email already registered");
            }
            else if (constraintName.Contains("username"))
            {
                return Result<User>.Failure("Username already taken");
            }
            
            return Result<User>.Failure("Registration failed due to duplicate data");
        }
        catch (Exception ex)
        {
            await transaction.RollbackAsync();
            
            _logger.LogError(ex, "Failed to register user: {Email}", request.Email);
            
            await RecordAudit(request, registrationToken, httpContext, "failed", ex.Message);
            
            return Result<User>.Failure("Registration failed. Please try again later.");
        }
    }
    
    private async Task RecordAudit(
        RegisterRequest request,
        string token,
        HttpContext httpContext,
        string status,
        string? errorMessage)
    {
        var audit = new RegistrationAudit
        {
            Id = Guid.NewGuid(),
            Email = request.Email,
            Username = request.Username,
            IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
            UserAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault(),
            RegistrationToken = token,
            Status = status,
            ErrorMessage = errorMessage,
            CreatedAt = DateTime.UtcNow
        };
        
        _dbContext.RegistrationAudits.Add(audit);
        await _dbContext.SaveChangesAsync();
    }
    
    private bool IsUniqueViolation(DbUpdateException ex)
    {
        return ex.InnerException is PostgresException pgEx && 
               pgEx.SqlState == "23505";
    }
    
    private string ExtractConstraintName(DbUpdateException ex)
    {
        if (ex.InnerException is PostgresException pgEx)
        {
            return pgEx.ConstraintName ?? "";
        }
        return "";
    }
}

public class RegisterRequest
{
    [Required]
    [EmailAddress]
    public string Email { get; set; }
    
    [Required]
    [MinLength(3)]
    [MaxLength(50)]
    public string Username { get; set; }
    
    [Phone]
    public string? Phone { get; set; }
    
    [Required]
    [MinLength(8)]
    public string Password { get; set; }
    
    [Required]
    [Compare("Password")]
    public string ConfirmPassword { get; set; }
}

4. Minimal API 实现 ​

csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);

// 添加服务
builder.Services.AddScoped<RegistrationService>();
builder.Services.AddSingleton<IPasswordHasher, BCryptPasswordHasher>();
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();

var app = builder.Build();

// 生成注册 Token
app.MapPost("/api/auth/register/token", async (
    IHttpContextAccessor httpContextAccessor,
    IDistributedCache cache) =>
{
    var token = Guid.NewGuid().ToString("N");
    
    // 存储 Token,30 分钟过期
    await cache.SetStringAsync(
        $"reg_token:{token}",
        "valid",
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
        });
    
    return Results.Ok(new { token });
})
.WithName("GetRegistrationToken")
.WithOpenApi();

// 用户注册
app.MapPost("/api/auth/register", async (
    RegisterRequest request,
    [FromHeader(Name = "X-Registration-Token")] string registrationToken,
    RegistrationService registrationService,
    HttpContext httpContext) =>
{
    if (string.IsNullOrWhiteSpace(registrationToken))
    {
        return Results.BadRequest(new { error = "Registration token is required" });
    }
    
    // 验证 Token 是否存在
    var cache = httpContext.RequestServices.GetRequiredService<IDistributedCache>();
    var tokenValue = await cache.GetStringAsync($"reg_token:{registrationToken}");
    
    if (string.IsNullOrEmpty(tokenValue))
    {
        return Results.Gone(new { error = "Registration token expired or invalid" });
    }
    
    var result = await registrationService.RegisterAsync(
        request, 
        registrationToken, 
        httpContext);
    
    if (!result.IsSuccess)
    {
        return result.Error.Contains("already") 
            ? Results.Conflict(new { error = result.Error })
            : Results.BadRequest(new { error = result.Error });
    }
    
    // 删除 Token(一次性使用)
    await cache.RemoveAsync($"reg_token:{registrationToken}");
    
    return Results.Created($"/api/users/{result.Data.Id}", new
    {
        id = result.Data.Id,
        email = result.Data.Email,
        username = result.Data.Username
    });
})
.WithName("RegisterUser")
.WithOpenApi();

app.Run();

前端实现 ​

React 组件 ​

tsx
import { useState } from 'react';

function RegistrationForm() {
    const [formData, setFormData] = useState({
        email: '',
        username: '',
        phone: '',
        password: '',
        confirmPassword: ''
    });
    const [isSubmitting, setIsSubmitting] = useState(false);
    const [registrationToken, setRegistrationToken] = useState<string>('');
    const [error, setError] = useState<string>('');
    
    // 获取注册 Token
    useEffect(() => {
        fetch('/api/auth/register/token')
            .then(res => res.json())
            .then(data => setRegistrationToken(data.token))
            .catch(err => console.error('Failed to get registration token', err));
    }, []);
    
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        
        if (isSubmitting || !registrationToken) {
            return;
        }
        
        // 前端验证
        if (formData.password !== formData.confirmPassword) {
            setError('Passwords do not match');
            return;
        }
        
        setIsSubmitting(true);
        setError('');
        
        try {
            const response = await fetch('/api/auth/register', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Registration-Token': registrationToken
                },
                body: JSON.stringify({
                    email: formData.email,
                    username: formData.username,
                    phone: formData.phone,
                    password: formData.password,
                    confirmPassword: formData.confirmPassword
                })
            });
            
            if (response.status === 410) {
                setError('Registration token expired. Please refresh the page.');
                return;
            }
            
            if (response.status === 409) {
                const data = await response.json();
                setError(data.error);
                return;
            }
            
            if (!response.ok) {
                throw new Error('Registration failed');
            }
            
            const user = await response.json();
            
            // 注册成功,跳转到验证页面
            window.location.href = `/auth/verify-email?email=${encodeURIComponent(user.email)}`;
            
        } catch (err) {
            setError('Registration failed. Please try again.');
        } finally {
            setIsSubmitting(false);
        }
    };
    
    return (
        <form onSubmit={handleSubmit}>
            <h2>Create Account</h2>
            
            {error && (
                <div className="alert alert-error">
                    {error}
                </div>
            )}
            
            <div className="form-group">
                <label htmlFor="email">Email</label>
                <input
                    type="email"
                    id="email"
                    value={formData.email}
                    onChange={e => setFormData({...formData, email: e.target.value})}
                    required
                />
            </div>
            
            <div className="form-group">
                <label htmlFor="username">Username</label>
                <input
                    type="text"
                    id="username"
                    value={formData.username}
                    onChange={e => setFormData({...formData, username: e.target.value})}
                    minLength={3}
                    maxLength={50}
                    required
                />
            </div>
            
            <div className="form-group">
                <label htmlFor="phone">Phone (Optional)</label>
                <input
                    type="tel"
                    id="phone"
                    value={formData.phone}
                    onChange={e => setFormData({...formData, phone: e.target.value})}
                />
            </div>
            
            <div className="form-group">
                <label htmlFor="password">Password</label>
                <input
                    type="password"
                    id="password"
                    value={formData.password}
                    onChange={e => setFormData({...formData, password: e.target.value})}
                    minLength={8}
                    required
                />
            </div>
            
            <div className="form-group">
                <label htmlFor="confirmPassword">Confirm Password</label>
                <input
                    type="password"
                    id="confirmPassword"
                    value={formData.confirmPassword}
                    onChange={e => setFormData({...formData, confirmPassword: e.target.value})}
                    minLength={8}
                    required
                />
            </div>
            
            <button type="submit" disabled={isSubmitting || !registrationToken}>
                {isSubmitting ? 'Creating Account...' : 'Create Account'}
            </button>
        </form>
    );
}

监控与告警 ​

指标收集 ​

csharp
public class RegistrationMetrics
{
    private readonly Counter<long> _registrationsTotal;
    private readonly Counter<long> _duplicateRegistrations;
    private readonly Counter<long> _failedRegistrations;
    private readonly Histogram<double> _registrationTime;
    
    public void RecordRegistration(bool success, bool isDuplicate, double durationMs)
    {
        _registrationsTotal.Add(1);
        
        if (isDuplicate)
        {
            _duplicateRegistrations.Add(1);
        }
        
        if (!success)
        {
            _failedRegistrations.Add(1);
        }
        
        _registrationTime.Record(durationMs);
    }
}

最佳实践总结 ​

✅ DO ​

  1. 使用注册 Token:每个注册表单有唯一 Token
  2. 多重验证:邮箱、用户名唯一约束
  3. 记录审计日志:追踪所有注册尝试
  4. 前端防重:按钮禁用 + Token 机制
  5. 友好提示:明确告知用户重复注册

❌ DON'T ​

  1. 不要仅依赖前端验证:后端必须有幂等性保证
  2. 不要暴露详细信息:避免枚举攻击
  3. 不要忽略审计:便于排查问题
  4. 不要忘记清理 Token:避免存储膨胀

总结 ​

用户注册的幂等性设计需要多层防护:

  1. 前端层:按钮禁用、表单 Token
  2. 传输层:Registration-Token 请求头
  3. 应用层:Token 验证、唯一性检查
  4. 数据层:唯一索引、事务控制

通过组合使用这些技术,可以有效防止重复注册,保证数据一致性。

Released under the MIT License.