661 lines
17 KiB
JavaScript
661 lines
17 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, 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
|
||
}
|