表单提交 - 用户注册防重
概述
用户注册是典型的幂等性场景。用户可能因为网络延迟、页面刷新或误操作而多次点击注册按钮,必须保证同一用户只创建一次账户。
核心挑战
- 重复点击:注册按钮被多次点击
- 页面刷新:浏览器重新发送 POST 请求
- 网络重试:客户端自动重试超时请求
- 并发注册:同一用户使用不同邮箱尝试注册
完整实现方案
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
- 使用注册 Token:每个注册表单有唯一 Token
- 多重验证:邮箱、用户名唯一约束
- 记录审计日志:追踪所有注册尝试
- 前端防重:按钮禁用 + Token 机制
- 友好提示:明确告知用户重复注册
❌ DON'T
- 不要仅依赖前端验证:后端必须有幂等性保证
- 不要暴露详细信息:避免枚举攻击
- 不要忽略审计:便于排查问题
- 不要忘记清理 Token:避免存储膨胀
总结
用户注册的幂等性设计需要多层防护:
- 前端层:按钮禁用、表单 Token
- 传输层:Registration-Token 请求头
- 应用层:Token 验证、唯一性检查
- 数据层:唯一索引、事务控制
通过组合使用这些技术,可以有效防止重复注册,保证数据一致性。