859 lines
22 KiB
JavaScript
859 lines
22 KiB
JavaScript
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
|
||
}
|