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