Files
services/controllers/users.js
T
2026-06-04 11:10:51 +08:00

859 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { query, transaction } = require('../config/database')
const crypto = require('crypto')
const { paginate } = require('../utils/pagination')
const { DEFAULT_PASSWORD } = require('../config/constants')
const { hashPassword, verifyPassword, needsRehash } = require('../utils/password')
const { signAccess, signRefresh, verify, ACCESS_TTL, REFRESH_TTL } = require('../utils/jwt')
const { toLegacyToken } = require('../utils/legacy-token')
const { extractToken, getUserByToken } = require('../middleware/auth')
function generateToken() {
return crypto.randomBytes(32).toString('hex')
}
async function issueTokenPair(user) {
const access = signAccess(user)
const refresh = signRefresh(user)
const dbToken = generateToken()
await query('UPDATE users SET token = ? WHERE id = ?', [dbToken, user.id])
return { access, refresh, legacy: toLegacyToken(dbToken), accessTtl: ACCESS_TTL, refreshTtl: REFRESH_TTL }
}
function publicUser(user, tokenObj) {
const roleMap = { 0: 'customer', 1: 'staff', 2: 'admin' }
const base = {
id: user.id,
phone: user.phone,
name: user.name,
avatar: user.avatar,
points: user.points,
role: roleMap[user.role] || 'customer',
roleCode: user.role
}
if (tokenObj) {
return { ...base, token: tokenObj.access, accessToken: tokenObj.access, refreshToken: tokenObj.refresh, legacyToken: tokenObj.legacy, accessTtl: tokenObj.accessTtl, refreshTtl: tokenObj.refreshTtl }
}
return base
}
// 用户登录(支持双重身份)
async function login(ctx) {
const { phone, password, loginType } = ctx.request.body
if (!phone || !password) {
ctx.body = { code: 400, message: '请输入手机号和密码' }
return
}
const users = await query('SELECT * FROM users WHERE phone = ?', [phone])
if (users.length === 0) {
ctx.body = { code: 401, message: '用户不存在' }
return
}
const user = users[0]
if (user.status === 0) {
ctx.body = { code: 401, message: '账号已被禁用' }
return
}
if (!verifyPassword(password, user.password)) {
ctx.body = { code: 401, message: '密码错误' }
return
}
if (loginType === 'admin' && user.role !== 1 && user.role !== 2) {
ctx.body = { code: 401, message: '该账号不是管理员或店员账号' }
return
}
if (loginType === 'staff' && user.role !== 1) {
ctx.body = { code: 401, message: '该账号不是店员账号' }
return
}
if (loginType === 'customer' && user.role !== 0) {
ctx.body = { code: 401, message: '该账号不是普通用户账号' }
return
}
let tokenObj
if (needsRehash(user.password)) {
const upgraded = hashPassword(password)
await query('UPDATE users SET password = ? WHERE id = ?', [upgraded, user.id])
}
tokenObj = await issueTokenPair(user)
ctx.body = { code: 200, data: publicUser(user, tokenObj) }
}
// 用户注册(普通用户)
// 注册频率限制(IP+手机号维度)
const registerLimiter = new Map()
const REGISTER_LIMIT_WINDOW = 60 * 1000 // 1分钟
const REGISTER_LIMIT_MAX = 3 // 每分钟最多3次
function checkRegisterLimit(ip, phone) {
const key = `${ip}:${phone}`
const now = Date.now()
const record = registerLimiter.get(key)
if (!record || now - record.time > REGISTER_LIMIT_WINDOW) {
registerLimiter.set(key, { time: now, count: 1 })
return true
}
if (record.count >= REGISTER_LIMIT_MAX) {
return false
}
record.count++
return true
}
// 定期清理过期记录
setInterval(() => {
const now = Date.now()
for (const [key, record] of registerLimiter) {
if (now - record.time > REGISTER_LIMIT_WINDOW) {
registerLimiter.delete(key)
}
}
}, 60000)
async function register(ctx) {
const { phone, password, name } = ctx.request.body
if (!phone || !password || !name) {
ctx.body = { code: 400, message: '请填写完整信息' }
return
}
// 注册频率限制
const clientIp = ctx.ip || 'unknown'
if (!checkRegisterLimit(clientIp, phone)) {
ctx.body = { code: 429, message: '注册过于频繁,请稍后再试' }
return
}
if (password.length < 8) {
ctx.body = { code: 400, message: '密码至少8位' }
return
}
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
ctx.body = { code: 400, message: '密码需包含字母和数字' }
return
}
const existing = await query('SELECT * FROM users WHERE phone = ?', [phone])
if (existing.length > 0) {
ctx.body = { code: 400, message: '该手机号已注册' }
return
}
const result = await query(
'INSERT INTO users (phone, password, name, avatar, points, role) VALUES (?, ?, ?, ?, ?, ?)',
[phone, hashPassword(password), name, '', 0, 0]
)
ctx.body = {
code: 200,
message: '注册成功',
data: { id: result.insertId, phone, name, avatar: '', points: 0, role: 0 }
}
}
async function requireAuth(ctx) {
const user = await getUserByToken(extractToken(ctx))
if (!user) {
ctx.status = 401
ctx.body = { code: 401, message: '未登录或登录已失效' }
return null
}
return user
}
async function requireStaffAuth(ctx) {
const user = await getUserByToken(extractToken(ctx))
if (!user) {
ctx.status = 401
ctx.body = { code: 401, message: '未登录或登录已失效' }
return null
}
if (user.role !== 1 && user.role !== 2) {
ctx.status = 403
ctx.body = { code: 403, message: '权限不足,仅店员或管理员可操作' }
return null
}
return user
}
async function requireAdminAuth(ctx) {
const user = await getUserByToken(extractToken(ctx))
if (!user) {
ctx.status = 401
ctx.body = { code: 401, message: '未登录或登录已失效' }
return null
}
if (user.role !== 2) {
ctx.status = 403
ctx.body = { code: 403, message: '权限不足,仅管理员可操作' }
return null
}
return user
}
async function createUser(phone, name, role) {
const existing = await query('SELECT * FROM users WHERE phone = ?', [phone])
if (existing.length > 0) return { conflict: true }
const result = await query(
'INSERT INTO users (phone, password, name, avatar, points, role) VALUES (?, ?, ?, ?, ?, ?)',
[phone, hashPassword(DEFAULT_PASSWORD), name, '', 0, role]
)
return {
conflict: false,
data: {
id: result.insertId,
phone,
name,
avatar: '',
points: 0,
role
}
}
}
// 店员注册(需要管理员权限)
async function registerStaff(ctx) {
const { phone, name } = ctx.request.body
if (!phone || !name) {
ctx.body = { code: 400, message: '请填写手机号和姓名' }
return
}
const operator = await requireAdminAuth(ctx)
if (!operator) return
const result = await createUser(phone, name, 1)
if (result.conflict) {
ctx.body = { code: 400, message: '该手机号已注册' }
return
}
ctx.body = {
code: 200,
message: `店员注册成功,默认密码为${DEFAULT_PASSWORD}`,
data: result.data
}
}
// 店员帮助用户注册(需要店员权限)
async function registerByStaff(ctx) {
const { phone, name } = ctx.request.body
if (!phone || !name) {
ctx.body = { code: 400, message: '请填写手机号和姓名' }
return
}
const operator = await requireStaffAuth(ctx)
if (!operator) return
const result = await createUser(phone, name, 0)
if (result.conflict) {
ctx.body = { code: 400, message: '该手机号已注册' }
return
}
ctx.body = {
code: 200,
message: `用户注册成功,默认密码为${DEFAULT_PASSWORD}`,
data: result.data
}
}
// 获取用户信息
async function getUserInfo(ctx) {
const userId = parseInt(ctx.query.id)
if (!userId) {
ctx.body = { code: 400, message: '缺少用户ID' }
return
}
const users = await query('SELECT * FROM users WHERE id = ? AND status = 1', [userId])
if (users.length > 0) {
const user = users[0]
ctx.body = {
code: 200,
data: {
id: user.id,
phone: user.phone,
name: user.name,
avatar: user.avatar,
points: user.points,
role: user.role
}
}
} else {
ctx.body = {
code: 404,
message: '用户不存在'
}
}
}
// 获取用户列表(管理员操作)
async function getUsers(ctx) {
const operator = await requireAdminAuth(ctx)
if (!operator) return
let sql = 'SELECT id, phone, name, points, role, status, created_at FROM users WHERE status = 1'
const params = []
if (ctx.query.role !== undefined) {
sql += ' AND role = ?'
params.push(parseInt(ctx.query.role))
}
if (ctx.query.keyword) {
sql += ' AND (phone LIKE ? OR name LIKE ?)'
params.push(`%${ctx.query.keyword}%`, `%${ctx.query.keyword}%`)
}
sql += ' ORDER BY created_at DESC'
const result = await paginate(query, sql, params, ctx.query.page, ctx.query.pageSize)
ctx.body = {
code: 200,
...result
}
}
const USER_UPDATEABLE_FIELDS = ['name', 'avatar', 'points', 'status', 'role']
// 更新用户信息(管理员可改任意人;本人仅可改 name/avatar
async function updateUser(ctx) {
const operator = await requireAuth(ctx)
if (!operator) return
const userId = parseInt(ctx.params.id)
const body = ctx.request.body
let allowedFields = USER_UPDATEABLE_FIELDS
if (operator.role !== 2) {
if (userId !== operator.id) {
ctx.body = { code: 403, message: '无权修改他人信息' }
return
}
if ('role' in ctx.request.body) {
ctx.body = { code: 403, message: '无权修改角色' }
return
}
allowedFields = ['name', 'avatar']
}
const filtered = {}
for (const key of allowedFields) {
if (key in body) {
filtered[key] = body[key]
}
}
const { name, avatar, points, status, role } = filtered
const updateFields = []
const updateParams = []
if (name !== undefined) {
updateFields.push('name = ?')
updateParams.push(name)
}
if (avatar !== undefined) {
updateFields.push('avatar = ?')
updateParams.push(avatar)
}
if (points !== undefined) {
updateFields.push('points = ?')
updateParams.push(parseInt(points))
}
if (status !== undefined) {
updateFields.push('status = ?')
updateParams.push(parseInt(status))
}
if (role !== undefined) {
const roleVal = parseInt(role)
if (![0, 1, 2].includes(roleVal)) {
ctx.body = { code: 400, message: '角色值无效,必须为 0(用户)/1(店员)/2(管理员)' }
return
}
updateFields.push('role = ?')
updateParams.push(roleVal)
}
if (updateFields.length === 0) {
ctx.body = {
code: 400,
message: '没有需要更新的字段'
}
return
}
updateParams.push(userId)
const result = await query(`UPDATE users SET ${updateFields.join(', ')} WHERE id = ?`, updateParams)
if (result.affectedRows > 0) {
ctx.body = {
code: 200,
message: '更新成功'
}
} else {
ctx.body = {
code: 404,
message: '用户不存在'
}
}
}
// 删除/禁用用户(管理员操作)
async function deleteUser(ctx) {
const operator = await requireAdminAuth(ctx)
if (!operator) return
const userId = parseInt(ctx.params.id)
if (userId === operator.id) {
ctx.body = { code: 400, message: '不能禁用自己' }
return
}
const result = await query('UPDATE users SET status = 0 WHERE id = ?', [userId])
if (result.affectedRows > 0) {
ctx.body = {
code: 200,
message: '禁用成功'
}
} else {
ctx.body = {
code: 404,
message: '用户不存在'
}
}
}
// 修改密码
async function changePassword(ctx) {
const { phone, oldPassword, newPassword } = ctx.request.body
if (!phone || !oldPassword || !newPassword) {
ctx.body = {
code: 400,
message: '请填写完整信息'
}
return
}
const users = await query('SELECT * FROM users WHERE phone = ? AND status = 1', [phone])
if (users.length === 0) {
ctx.body = {
code: 404,
message: '用户不存在'
}
return
}
const user = users[0]
if (!verifyPassword(oldPassword, user.password)) {
ctx.body = {
code: 400,
message: '原密码错误'
}
return
}
// 如果是旧版MD5哈希,登录时已自动升级;此处确保修改密码后使用scrypt
await query('UPDATE users SET password = ? WHERE id = ?', [hashPassword(newPassword), user.id])
ctx.body = {
code: 200,
message: '密码修改成功'
}
}
// 重置密码(管理员操作)
async function resetPassword(ctx) {
const operator = await requireAdminAuth(ctx)
if (!operator) return
const { userId } = ctx.request.body
if (!userId) {
ctx.body = {
code: 400,
message: '请指定用户ID'
}
return
}
const defaultPassword = DEFAULT_PASSWORD
const result = await query('UPDATE users SET password = ? WHERE id = ?', [hashPassword(defaultPassword), userId])
if (result.affectedRows > 0) {
ctx.body = {
code: 200,
message: `密码已重置为${DEFAULT_PASSWORD}`
}
} else {
ctx.body = {
code: 404,
message: '用户不存在'
}
}
}
// 调整积分(店员或管理员可操作)
async function addPoints(ctx) {
const operator = await requireStaffAuth(ctx)
if (!operator) return
const { userId, points, description } = ctx.request.body
if (!userId || points === undefined) {
ctx.body = { code: 400, message: '请指定用户ID和积分变动值' }
return
}
const pointsInt = parseInt(points)
if (isNaN(pointsInt)) {
ctx.body = { code: 400, message: '积分值无效' }
return
}
const MAX_DELTA = 100000
if (Math.abs(pointsInt) > MAX_DELTA) {
ctx.body = { code: 400, message: `单次积分变动不能超过 ${MAX_DELTA}` }
return
}
if (pointsInt === 0) {
ctx.body = { code: 400, message: '积分变动值不能为0' }
return
}
if (pointsInt < 0) {
ctx.body = { code: 400, message: '不支持直接扣除积分,请使用专门的积分扣除接口' }
return
}
const users = await query('SELECT * FROM users WHERE id = ? AND status = 1', [userId])
if (users.length === 0) {
ctx.body = { code: 404, message: '用户不存在' }
return
}
const user = users[0]
const newPoints = user.points + pointsInt
await transaction(async (conn) => {
await conn.execute('UPDATE users SET points = ? WHERE id = ?', [newPoints, userId])
await conn.execute(
'INSERT INTO points_logs (user_id, type, amount, description) VALUES (?, ?, ?, ?)',
[userId, 'earn', Math.abs(pointsInt), description || '店员/管理员增加积分']
)
})
ctx.body = {
code: 200,
message: '积分增加成功',
data: { userId, oldPoints: user.points, newPoints, change: pointsInt }
}
}
// 退出登录(使服务端 token 失效)
async function logout(ctx) {
const user = await getUserByToken(extractToken(ctx))
if (user) {
await query('UPDATE users SET token = NULL WHERE id = ?', [user.id])
}
ctx.body = { code: 200, message: '已退出登录' }
}
// 刷新 access token
async function refreshToken(ctx) {
const { refreshToken: token } = ctx.request.body
if (!token) {
ctx.status = 400
ctx.body = { code: 400, message: '缺少 refreshToken' }
return
}
const payload = verify(token)
if (!payload || payload.type !== 'refresh') {
ctx.status = 401
ctx.body = { code: 401, message: 'refreshToken 无效或已过期' }
return
}
const users = await query('SELECT * FROM users WHERE id = ? AND status = 1', [payload.sub])
if (users.length === 0) {
ctx.status = 401
ctx.body = { code: 401, message: '账号不存在或已禁用' }
return
}
const user = users[0]
const tokenObj = await issueTokenPair(user)
ctx.body = { code: 200, data: publicUser(user, tokenObj) }
}
// 获取积分记录
async function getPointsLogs(ctx) {
const userId = parseInt(ctx.query.userId)
if (!userId) {
ctx.body = {
code: 400,
message: '请指定用户ID'
}
return
}
const logs = await query(
'SELECT * FROM points_logs WHERE user_id = ? ORDER BY created_at DESC',
[userId]
)
ctx.body = {
code: 200,
data: logs
}
}
// 微信一键登录
async function wechatLogin(ctx) {
const { code } = ctx.request.body
if (!code) {
ctx.body = { code: 400, message: '缺少微信登录code' }
return
}
const fetch = require('node-fetch')
const APPID = process.env.WECHAT_APPID
const SECRET = process.env.WECHAT_SECRET
if (!APPID || !SECRET) {
ctx.body = { code: 500, message: '微信登录未配置' }
return
}
try {
// 调用微信接口获取 openid 和 session_key
const res = await fetch(
`https://api.weixin.qq.com/sns/jscode2session?appid=${APPID}&secret=${SECRET}&js_code=${code}&grant_type=authorization_code`
)
const data = await res.json()
if (data.errcode) {
ctx.body = { code: 400, message: `微信登录失败: ${data.errmsg}` }
return
}
const { openid } = data
// 查找是否已有绑定该 openid 的用户
const users = await query('SELECT * FROM users WHERE openid = ? AND status = 1', [openid])
if (users.length > 0) {
// 已有用户,直接登录
const user = users[0]
const tokenObj = await issueTokenPair(user)
ctx.body = { code: 200, data: { ...publicUser(user, tokenObj), isNewUser: false } }
} else {
// 新用户,自动注册
const phone = `wx_${openid.slice(0, 10)}`
const name = `微信用户`
const result = await query(
'INSERT INTO users (phone, password, name, avatar, points, role, openid) VALUES (?, ?, ?, ?, ?, ?, ?)',
[phone, hashPassword(Math.random().toString(36)), name, '', 0, 0, openid]
)
const newUser = { id: result.insertId, phone, name, avatar: '', points: 0, role: 0, openid }
const tokenObj = await issueTokenPair(newUser)
ctx.body = { code: 200, data: { ...publicUser(newUser, tokenObj), isNewUser: true } }
}
} catch (err) {
console.error('微信登录异常:', err)
ctx.body = { code: 500, message: '微信登录失败' }
}
}
// ============ 忘记密码 - 验证码相关 ============
// 内存中存储验证码(生产环境应使用 Redis)
const resetCodeStore = new Map()
// 生成6位数字验证码
function generateVerifyCode() {
return String(Math.floor(100000 + Math.random() * 900000))
}
// 发送重置密码验证码
async function sendResetCode(ctx) {
const { phone } = ctx.request.body
if (!phone || phone.length !== 11) {
ctx.body = { code: 400, message: '请输入正确的手机号' }
return
}
// 检查用户是否存在
const users = await query('SELECT id FROM users WHERE phone = ?', [phone])
if (users.length === 0) {
ctx.body = { code: 404, message: '该手机号未注册' }
return
}
// 检查发送频率(60秒内不可重复发送)
const existing = resetCodeStore.get(phone)
if (existing && Date.now() - existing.createdAt < 60000) {
ctx.body = { code: 429, message: '验证码发送过于频繁,请稍后再试' }
return
}
const code = generateVerifyCode()
resetCodeStore.set(phone, {
code,
createdAt: Date.now(),
verified: false,
attempts: 0
})
// TODO: 接入短信服务发送验证码,目前仅输出到日志
console.log(`[忘记密码] 手机号 ${phone} 的验证码: ${code}`)
ctx.body = { code: 200, message: '验证码已发送' }
}
// 验证重置密码验证码
async function verifyResetCode(ctx) {
const { phone, code } = ctx.request.body
if (!phone || !code) {
ctx.body = { code: 400, message: '请输入手机号和验证码' }
return
}
const stored = resetCodeStore.get(phone)
if (!stored) {
ctx.body = { code: 400, message: '请先获取验证码' }
return
}
// 验证码5分钟过期
if (Date.now() - stored.createdAt > 5 * 60 * 1000) {
resetCodeStore.delete(phone)
ctx.body = { code: 400, message: '验证码已过期,请重新获取' }
return
}
// 最多尝试5次
if (stored.attempts >= 5) {
resetCodeStore.delete(phone)
ctx.body = { code: 400, message: '验证码错误次数过多,请重新获取' }
return
}
if (stored.code !== code) {
stored.attempts += 1
ctx.body = { code: 400, message: '验证码错误' }
return
}
// 标记为已验证
stored.verified = true
ctx.body = { code: 200, message: '验证成功' }
}
// 通过验证码重置密码
async function resetPasswordWithCode(ctx) {
const { phone, code, newPassword } = ctx.request.body
if (!phone || !code || !newPassword) {
ctx.body = { code: 400, message: '参数不完整' }
return
}
if (newPassword.length < 8) {
ctx.body = { code: 400, message: '密码至少8位' }
return
}
if (!/[a-zA-Z]/.test(newPassword) || !/[0-9]/.test(newPassword)) {
ctx.body = { code: 400, message: '密码需包含字母和数字' }
return
}
const stored = resetCodeStore.get(phone)
if (!stored || !stored.verified) {
ctx.body = { code: 400, message: '请先验证手机号' }
return
}
// 验证码已验证但需确认code一致
if (stored.code !== code) {
ctx.body = { code: 400, message: '验证码不正确' }
return
}
// 验证通过后10分钟内有效
if (Date.now() - stored.createdAt > 10 * 60 * 1000) {
resetCodeStore.delete(phone)
ctx.body = { code: 400, message: '验证已过期,请重新操作' }
return
}
// 更新密码
const result = await query('UPDATE users SET password = ? WHERE phone = ?', [hashPassword(newPassword), phone])
// 清除验证码记录
resetCodeStore.delete(phone)
if (result.affectedRows > 0) {
ctx.body = { code: 200, message: '密码重置成功' }
} else {
ctx.body = { code: 404, message: '用户不存在' }
}
}
module.exports = {
requireAuth,
requireStaffAuth,
requireAdminAuth,
login,
wechatLogin,
register,
registerStaff,
registerByStaff,
getUserInfo,
getUsers,
updateUser,
deleteUser,
changePassword,
resetPassword,
sendResetCode,
verifyResetCode,
resetPasswordWithCode,
addPoints,
getPointsLogs,
logout,
refreshToken
}