包含: - furniture-3d-system:Next.js 16 全栈应用(资产中心、产品管理、3D装配动画) - autocad-plugin:AutoCAD 家具导出插件源码 - 资产中心改造:编辑弹窗化、删除功能、Toast反馈、自定义Select组件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 KiB
20 KiB
用户认证系统设计
📋 核心需求
- 注册方式:邮箱 + 姓名/花名 + 密码
- 登录方式:支持邮箱或姓名/花名登录
- 姓名唯一性:用户自己确保姓名唯一,重复时提示修改
- 智能提示:仅在密码错误时提示可能的匹配用户
- 姓名显示:不隐藏后缀,完整显示
- 首次登录:超级管理员首次登录强制修改邮箱、姓名和密码
🗄️ 数据库模型
model User {
id String @id @default(cuid())
// 登录凭证
email String @unique // 邮箱(必填,用于登录)
name String @unique // 姓名/花名(必填,用于登录和显示)
password String
// 首次登录标识
isFirstLogin Boolean @default(true) // 超级管理员首次登录需强制修改信息
// 其他信息
avatar String?
phone String?
// 角色与权限
role Role @default(DESIGNER)
// 组织架构
departmentId String?
department Department? @relation("DepartmentMembers", fields: [departmentId], references: [id])
teamId String?
team Team? @relation("TeamMembers", fields: [teamId], references: [id])
// 管理关系
managedDepartmentId String?
managedDepartment Department? @relation("DepartmentManager", fields: [managedDepartmentId], references: [id])
managedTeamId String?
managedTeam Team? @relation("TeamLeader", fields: [managedTeamId], references: [id])
// 状态
status UserStatus @default(ACTIVE)
// 关联
createdProducts Product[] @relation("ProductCreator")
ownedSKUs ProductSKU[] @relation("SKUOwner")
feedbacks Feedback[] @relation("FeedbackSubmitter")
assignedFeedbacks Feedback[] @relation("FeedbackAssignee")
// 操作日志
activityLogs ActivityLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@index([email])
@@index([name])
@@index([departmentId])
@@index([teamId])
@@index([status])
}
📝 注册流程
1. 注册接口
async function registerUser(data: {
email: string
name: string
password: string
phone?: string
}) {
// 1. 验证邮箱格式
if (!isValidEmail(data.email)) {
throw new Error('邮箱格式不正确')
}
// 2. 验证邮箱唯一性
const existingEmail = await prisma.user.findUnique({
where: { email: data.email }
})
if (existingEmail) {
throw new Error('该邮箱已被注册')
}
// 3. 验证姓名唯一性(用户自己确保)
const existingName = await prisma.user.findUnique({
where: { name: data.name }
})
if (existingName) {
throw new Error(
`姓名/花名"${data.name}"已被使用,请使用其他名称\n` +
`建议:\n` +
`- 添加部门后缀:${data.name}-设计部\n` +
`- 添加数字后缀:${data.name}01\n` +
`- 使用花名或昵称`
)
}
// 4. 验证密码强度
if (!isStrongPassword(data.password)) {
throw new Error('密码强度不足:至少8位,包含大小写字母和数字')
}
// 5. 创建用户
const user = await prisma.user.create({
data: {
email: data.email,
name: data.name,
password: await hashPassword(data.password),
phone: data.phone,
role: 'DESIGNER',
status: 'ACTIVE',
isFirstLogin: false // 普通用户注册后不需要强制修改
}
})
// 6. 记录日志
await createActivityLog({
userId: user.id,
type: 'USER_REGISTER',
action: `用户注册: ${user.name} (${user.email})`
})
return {
success: true,
user: {
id: user.id,
email: user.email,
name: user.name
}
}
}
2. 注册界面
function RegisterForm() {
const [formData, setFormData] = useState({
email: '',
name: '',
password: '',
confirmPassword: ''
})
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 验证密码一致性
if (formData.password !== formData.confirmPassword) {
setError('两次输入的密码不一致')
return
}
try {
await registerUser(formData)
// 注册成功,跳转到登录页
router.push('/login?registered=true')
} catch (err) {
setError(err.message)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">
邮箱 <span className="text-red-500">*</span>
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="请输入邮箱"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
姓名/花名 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="请输入姓名或花名"
required
className="w-full px-4 py-3 rounded-lg border"
/>
<p className="text-xs text-gray-500 mt-1">
姓名/花名将用于登录和显示,请确保唯一性
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
密码 <span className="text-red-500">*</span>
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="至少8位,包含大小写字母和数字"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
确认密码 <span className="text-red-500">*</span>
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="请再次输入密码"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm whitespace-pre-line">
{error}
</div>
)}
<button
type="submit"
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
注册
</button>
</form>
)
}
🔐 登录流程
1. 登录接口
async function loginUser(credentials: {
identifier: string // 邮箱或姓名
password: string
}) {
// 1. 查找用户(支持邮箱或姓名)
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: credentials.identifier },
{ name: credentials.identifier }
]
},
include: {
department: true,
team: true
}
})
if (!user) {
throw new Error('用户不存在')
}
// 2. 检查用户状态
if (user.status === 'INACTIVE') {
throw new Error('账号已停用,请联系管理员')
}
if (user.status === 'SUSPENDED') {
throw new Error('账号已暂停,请联系管理员')
}
// 3. 验证密码
const isPasswordValid = await verifyPassword(
credentials.password,
user.password
)
if (!isPasswordValid) {
// 密码错误时,提供智能提示
const suggestions = await findSimilarUsers(credentials.identifier)
// 记录登录失败
await createActivityLog({
userId: user.id,
type: 'USER_LOGIN_FAILED',
action: `登录失败: ${credentials.identifier}`,
metadata: { identifier: credentials.identifier }
})
if (suggestions.length > 0) {
throw new Error(
`密码错误\n\n` +
`您是否要登录以下账号?\n` +
suggestions.map(u => `- ${u.name} (${u.email})`).join('\n')
)
}
throw new Error('密码错误')
}
// 4. 更新最后登录时间
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() }
})
// 5. 记录登录成功
await createActivityLog({
userId: user.id,
type: 'USER_LOGIN',
action: `登录成功: ${user.name}`,
metadata: {
identifier: credentials.identifier,
loginMethod: credentials.identifier.includes('@') ? 'email' : 'name'
}
})
// 6. 生成 JWT Token
const token = generateJWT({
userId: user.id,
email: user.email,
name: user.name,
role: user.role
})
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
avatar: user.avatar,
department: user.department,
team: user.team,
isFirstLogin: user.isFirstLogin // 返回首次登录标识
},
token
}
}
// 智能提示:查找相似用户
async function findSimilarUsers(identifier: string): Promise<User[]> {
// 如果输入的是邮箱,不提供建议
if (identifier.includes('@')) {
return []
}
// 查找姓名相似的用户(模糊匹配)
const users = await prisma.user.findMany({
where: {
name: {
contains: identifier,
mode: 'insensitive'
}
},
take: 3 // 最多返回3个建议
})
return users
}
2. 登录界面
function LoginForm() {
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const result = await loginUser({ identifier, password })
// 保存 token
localStorage.setItem('token', result.token)
// 检查是否首次登录
if (result.user.isFirstLogin && result.user.role === 'SUPER_ADMIN') {
// 超级管理员首次登录,跳转到强制修改页面
router.push('/first-login')
} else {
// 正常登录,跳转到首页
router.push('/')
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">
邮箱或姓名
</label>
<input
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="请输入邮箱或姓名/花名"
required
className="w-full px-4 py-3 rounded-lg border"
/>
<p className="text-xs text-gray-500 mt-1">
支持使用邮箱或姓名/花名登录
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm whitespace-pre-line">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '登录中...' : '登录'}
</button>
<div className="text-center text-sm text-gray-500">
还没有账号?
<Link href="/register" className="text-blue-600 hover:underline ml-1">
立即注册
</Link>
</div>
</form>
)
}
🔄 首次登录强制修改
1. 系统初始化
// 系统首次启动时创建默认超级管理员
async function initializeSystem() {
const userCount = await prisma.user.count()
if (userCount === 0) {
console.log('检测到首次安装,创建默认超级管理员...')
const defaultAdmin = await prisma.user.create({
data: {
email: 'admin@company.com',
name: 'admin',
password: await hashPassword('Admin@123456'),
role: 'SUPER_ADMIN',
status: 'ACTIVE',
isFirstLogin: true // 标记为首次登录
}
})
console.log('✅ 默认超级管理员创建成功')
console.log(' 邮箱: admin@company.com')
console.log(' 姓名: admin')
console.log(' 密码: Admin@123456')
console.log(' ⚠️ 首次登录后将强制修改邮箱、姓名和密码!')
return defaultAdmin
}
}
2. 首次登录检查中间件
// 中间件:检查是否首次登录
export async function checkFirstLogin(req: Request) {
const token = req.headers.get('Authorization')?.replace('Bearer ', '')
if (!token) {
return null
}
const decoded = verifyJWT(token)
const user = await prisma.user.findUnique({
where: { id: decoded.userId }
})
// 如果是超级管理员且首次登录,强制跳转到修改页面
if (user && user.role === 'SUPER_ADMIN' && user.isFirstLogin) {
// 只允许访问首次登录修改页面
if (!req.url.includes('/first-login')) {
return Response.redirect('/first-login')
}
}
return user
}
3. 首次登录修改页面
function FirstLoginPage() {
const [formData, setFormData] = useState({
email: '',
name: '',
password: '',
confirmPassword: ''
})
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 验证密码一致性
if (formData.password !== formData.confirmPassword) {
setError('两次输入的密码不一致')
return
}
setLoading(true)
setError('')
try {
await updateFirstLoginInfo(formData)
// 修改成功,重新登录
alert('信息修改成功,请使用新的邮箱和密码重新登录')
router.push('/login')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">首次登录</h1>
<p className="text-sm text-gray-600 mt-2">
为了安全,请立即修改默认的邮箱、姓名和密码
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
新邮箱 <span className="text-red-500">*</span>
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="请输入新的邮箱"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
新姓名/花名 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="请输入新的姓名或花名"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
新密码 <span className="text-red-500">*</span>
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="至少8位,包含大小写字母和数字"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
确认新密码 <span className="text-red-500">*</span>
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="请再次输入新密码"
required
className="w-full px-4 py-3 rounded-lg border"
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '修改中...' : '确认修改'}
</button>
</form>
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-xs text-yellow-800">
⚠️ 修改后需要使用新的邮箱和密码重新登录
</p>
</div>
</div>
</div>
)
}
4. 更新首次登录信息接口
async function updateFirstLoginInfo(data: {
email: string
name: string
password: string
}) {
const currentUser = await getCurrentUser()
// 只有超级管理员且首次登录才能调用此接口
if (currentUser.role !== 'SUPER_ADMIN' || !currentUser.isFirstLogin) {
throw new Error('无权限执行此操作')
}
// 验证新邮箱唯一性
const existingEmail = await prisma.user.findUnique({
where: { email: data.email }
})
if (existingEmail && existingEmail.id !== currentUser.id) {
throw new Error('该邮箱已被使用')
}
// 验证新姓名唯一性
const existingName = await prisma.user.findUnique({
where: { name: data.name }
})
if (existingName && existingName.id !== currentUser.id) {
throw new Error(`姓名/花名"${data.name}"已被使用,请使用其他名称`)
}
// 验证密码强度
if (!isStrongPassword(data.password)) {
throw new Error('密码强度不足:至少8位,包含大小写字母和数字')
}
// 更新用户信息
await prisma.user.update({
where: { id: currentUser.id },
data: {
email: data.email,
name: data.name,
password: await hashPassword(data.password),
isFirstLogin: false // 标记为已完成首次登录
}
})
// 记录日志
await createActivityLog({
userId: currentUser.id,
type: 'USER_FIRST_LOGIN_UPDATE',
action: `首次登录信息修改: ${data.name} (${data.email})`
})
// 清除当前 token(强制重新登录)
await invalidateToken(currentUser.id)
return { success: true }
}
📝 总结
核心特性 ✅
-
注册
- 邮箱 + 姓名/花名 + 密码
- 姓名重复时提示用户修改
- 密码强度验证
-
登录
- 支持邮箱或姓名/花名登录
- 密码错误时智能提示相似用户
- 完整的日志记录
-
首次登录
- 超级管理员首次登录强制修改信息
- 修改邮箱、姓名和密码
- 修改后强制重新登录
-
姓名显示
- 不隐藏后缀,完整显示
- 例如:"张三-设计部"、"李四01"
安全性 ✅
- 密码加密存储
- JWT Token 认证
- 账号状态检查
- 操作日志记录
- 首次登录强制修改
你觉得这个设计如何?可以开始实施了吗? 😊