WuYongJie 5821d73291 初始提交:3D家具安装展示系统
包含:
- furniture-3d-system:Next.js 16 全栈应用(资产中心、产品管理、3D装配动画)
- autocad-plugin:AutoCAD 家具导出插件源码
- 资产中心改造:编辑弹窗化、删除功能、Toast反馈、自定义Select组件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:00:19 +08:00

20 KiB
Raw Blame History

用户认证系统设计

📋 核心需求

  1. 注册方式:邮箱 + 姓名/花名 + 密码
  2. 登录方式:支持邮箱或姓名/花名登录
  3. 姓名唯一性:用户自己确保姓名唯一,重复时提示修改
  4. 智能提示:仅在密码错误时提示可能的匹配用户
  5. 姓名显示:不隐藏后缀,完整显示
  6. 首次登录:超级管理员首次登录强制修改邮箱、姓名和密码

🗄️ 数据库模型

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 }
}

📝 总结

核心特性 ✅

  1. 注册

    • 邮箱 + 姓名/花名 + 密码
    • 姓名重复时提示用户修改
    • 密码强度验证
  2. 登录

    • 支持邮箱或姓名/花名登录
    • 密码错误时智能提示相似用户
    • 完整的日志记录
  3. 首次登录

    • 超级管理员首次登录强制修改信息
    • 修改邮箱、姓名和密码
    • 修改后强制重新登录
  4. 姓名显示

    • 不隐藏后缀,完整显示
    • 例如:"张三-设计部"、"李四01"

安全性 ✅

  • 密码加密存储
  • JWT Token 认证
  • 账号状态检查
  • 操作日志记录
  • 首次登录强制修改

你觉得这个设计如何?可以开始实施了吗? 😊