Files
services/controllers/users.js
T
2026-06-03 14:15:55 +08:00

661 lines
17 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, verifyToken, 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 base = {
id: user.id,
phone: user.phone,
name: user.name,
avatar: user.avatar,
points: user.points,
role: 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 !== 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) }
}
// 用户注册(普通用户)
async function register(ctx) {
const { phone, password, name } = ctx.request.body
if (!phone || !password || !name) {
ctx.body = { code: 400, 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']
// 更新用户信息(管理员可改任意人;本人仅可改 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
}
allowedFields = ['name', 'avatar']
}
const filtered = {}
for (const key of allowedFields) {
if (key in body) {
filtered[key] = body[key]
}
}
const { name, avatar, points, status } = 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 (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 = verifyToken(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) }
} 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: '微信登录失败' }
}
}
module.exports = {
requireAuth,
requireStaffAuth,
requireAdminAuth,
login,
wechatLogin,
register,
registerStaff,
registerByStaff,
getUserInfo,
getUsers,
updateUser,
deleteUser,
changePassword,
resetPassword,
addPoints,
getPointsLogs,
logout,
refreshToken
}