From 5300551e2180f1ab123775487a81e49a6dd71913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E6=B5=B7=E6=B4=8B?= Date: Thu, 4 Jun 2026 08:20:49 +0800 Subject: [PATCH] =?UTF-8?q?API=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 1 + controllers/payment.js | 78 ++++++++++++++- controllers/stock.js | 43 ++++++++- controllers/users.js | 212 +++++++++++++++++++++++++++++++++++++++-- routes/payment.js | 3 + routes/stock.js | 1 + routes/users.js | 3 + 7 files changed, 332 insertions(+), 9 deletions(-) diff --git a/app.js b/app.js index 4758b3e..454a68e 100644 --- a/app.js +++ b/app.js @@ -51,6 +51,7 @@ const errorHandler = async (ctx, next) => { code: 404, message: '接口不存在' } + ctx.status = 404 } } catch (error) { console.error('Server error:', error) diff --git a/controllers/payment.js b/controllers/payment.js index e11e839..2c21eca 100644 --- a/controllers/payment.js +++ b/controllers/payment.js @@ -361,8 +361,84 @@ async function refundPayment(ctx) { } } +/** + * 查询支付结果 + * 调用微信支付订单查询API,确认支付状态 + */ +async function queryPayment(ctx) { + const { orderId } = ctx.params + + if (!orderId) { + ctx.body = { code: 400, message: '订单ID不能为空' } + return + } + + // 查询本地订单 + const orders = await query('SELECT * FROM orders WHERE id = ?', [orderId]) + if (orders.length === 0) { + ctx.body = { code: 404, message: '订单不存在' } + return + } + + const order = orders[0] + + // 如果本地已标记为已支付,直接返回 + if (order.status === 'paid') { + ctx.body = { code: 200, data: { orderId: order.id, status: 'paid', transactionId: order.transaction_id } } + return + } + + // 检查支付配置 + if (!APPID || !MCH_ID || !API_KEY) { + ctx.body = { code: 200, data: { orderId: order.id, status: order.status } } + return + } + + // 调用微信订单查询API + const params = { + appid: APPID, + mch_id: MCH_ID, + out_trade_no: orderId, + nonce_str: generateNonceStr() + } + params.sign = generateSign(params) + + try { + const xmlData = buildXML(params) + const response = await fetch(ORDER_QUERY_URL, { + method: 'POST', + body: xmlData, + headers: { 'Content-Type': 'application/xml' } + }) + + const resultXml = await response.text() + const result = await parseXML(resultXml) + + if (result.return_code !== 'SUCCESS') { + ctx.body = { code: 500, message: '查询支付状态通信失败' } + return + } + + if (result.trade_state === 'SUCCESS') { + // 微信确认已支付,更新本地订单状态 + const transactionId = result.transaction_id || null + await query( + 'UPDATE orders SET status = ?, transaction_id = ? WHERE id = ? AND status != ?', + ['paid', transactionId, orderId, 'paid'] + ) + ctx.body = { code: 200, data: { orderId: order.id, status: 'paid', transactionId } } + } else { + ctx.body = { code: 200, data: { orderId: order.id, status: order.status, tradeState: result.trade_state } } + } + } catch (error) { + console.error('查询支付状态失败:', error) + ctx.body = { code: 200, data: { orderId: order.id, status: order.status } } + } +} + module.exports = { createPayment, paymentNotify, - refundPayment + refundPayment, + queryPayment } diff --git a/controllers/stock.js b/controllers/stock.js index 1e8c5db..ded8438 100644 --- a/controllers/stock.js +++ b/controllers/stock.js @@ -139,8 +139,49 @@ async function getStockByGoodsId(ctx) { } } +// 获取库存变动日志 +async function getStockLogs(ctx) { + const goodsId = ctx.query.goods_id + const page = parseInt(ctx.query.page) || 1 + const pageSize = parseInt(ctx.query.pageSize) || 20 + + await ensureStockLogTable() + + let sql = ` + SELECT + sl.id, + sl.goods_id, + g.name as goods_name, + sl.change_type, + sl.delta, + sl.quantity_after, + sl.operator_id, + sl.remark, + sl.created_at + FROM stock_logs sl + LEFT JOIN goods g ON sl.goods_id = g.id + WHERE 1=1 + ` + const params = [] + + if (goodsId) { + sql += ' AND sl.goods_id = ?' + params.push(goodsId) + } + + sql += ' ORDER BY sl.created_at DESC' + + const result = await paginate(query, sql, params, page, pageSize) + + ctx.body = { + code: 200, + ...result + } +} + module.exports = { getStockList, adjustStock, - getStockByGoodsId + getStockByGoodsId, + getStockLogs } diff --git a/controllers/users.js b/controllers/users.js index 4689d76..09b8f4f 100644 --- a/controllers/users.js +++ b/controllers/users.js @@ -20,13 +20,15 @@ async function issueTokenPair(user) { } 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: user.role + 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 } @@ -62,8 +64,8 @@ async function login(ctx) { return } - if (loginType === 'admin' && user.role !== 2) { - ctx.body = { code: 401, message: '该账号不是管理员账号' } + if (loginType === 'admin' && user.role !== 1 && user.role !== 2) { + ctx.body = { code: 401, message: '该账号不是管理员或店员账号' } return } @@ -88,6 +90,39 @@ async function login(ctx) { } // 用户注册(普通用户) +// 注册频率限制(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) { @@ -95,6 +130,13 @@ async function register(ctx) { 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 @@ -293,7 +335,7 @@ async function getUsers(ctx) { } } -const USER_UPDATEABLE_FIELDS = ['name', 'avatar', 'points', 'status'] +const USER_UPDATEABLE_FIELDS = ['name', 'avatar', 'points', 'status', 'role'] // 更新用户信息(管理员可改任意人;本人仅可改 name/avatar) async function updateUser(ctx) { @@ -309,6 +351,10 @@ async function updateUser(ctx) { ctx.body = { code: 403, message: '无权修改他人信息' } return } + if ('role' in ctx.request.body) { + ctx.body = { code: 403, message: '无权修改角色' } + return + } allowedFields = ['name', 'avatar'] } @@ -319,7 +365,7 @@ async function updateUser(ctx) { } } - const { name, avatar, points, status } = filtered + const { name, avatar, points, status, role } = filtered const updateFields = [] const updateParams = [] @@ -340,6 +386,15 @@ async function updateUser(ctx) { 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 = { @@ -619,7 +674,7 @@ async function wechatLogin(ctx) { // 已有用户,直接登录 const user = users[0] const tokenObj = await issueTokenPair(user) - ctx.body = { code: 200, data: publicUser(user, tokenObj) } + ctx.body = { code: 200, data: { ...publicUser(user, tokenObj), isNewUser: false } } } else { // 新用户,自动注册 const phone = `wx_${openid.slice(0, 10)}` @@ -630,7 +685,7 @@ async function wechatLogin(ctx) { ) 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 } + ctx.body = { code: 200, data: { ...publicUser(newUser, tokenObj), isNewUser: true } } } } catch (err) { console.error('微信登录异常:', err) @@ -638,6 +693,146 @@ async function wechatLogin(ctx) { } } +// ============ 忘记密码 - 验证码相关 ============ + +// 内存中存储验证码(生产环境应使用 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, @@ -653,6 +848,9 @@ module.exports = { deleteUser, changePassword, resetPassword, + sendResetCode, + verifyResetCode, + resetPasswordWithCode, addPoints, getPointsLogs, logout, diff --git a/routes/payment.js b/routes/payment.js index b672ac7..c4ae74e 100644 --- a/routes/payment.js +++ b/routes/payment.js @@ -7,6 +7,9 @@ const router = new Router() // 创建支付(需要用户登录) router.post('/create', requireAuth(), paymentController.createPayment) +// 查询支付结果(需要用户登录) +router.get('/query/:orderId', requireAuth(), paymentController.queryPayment) + // 微信支付回调(无需登录) router.post('/notify', paymentController.paymentNotify) diff --git a/routes/stock.js b/routes/stock.js index 5ca3f1c..2eecd10 100644 --- a/routes/stock.js +++ b/routes/stock.js @@ -5,6 +5,7 @@ const { requireStaffAuth } = require('../middleware/auth') const router = new Router() router.get('/', requireStaffAuth(), stockController.getStockList) +router.get('/logs', requireStaffAuth(), stockController.getStockLogs) router.get('/:id', stockController.getStockByGoodsId) router.post('/:id/adjust', requireStaffAuth(), stockController.adjustStock) diff --git a/routes/users.js b/routes/users.js index 7592814..a3cfb01 100644 --- a/routes/users.js +++ b/routes/users.js @@ -8,6 +8,9 @@ router.post('/login', userController.login) router.post('/wechat-login', userController.wechatLogin) router.post('/register', userController.register) router.post('/change-password', userController.changePassword) +router.post('/send-reset-code', userController.sendResetCode) +router.post('/verify-reset-code', userController.verifyResetCode) +router.post('/reset-password-with-code', userController.resetPasswordWithCode) router.post('/refresh-token', userController.refreshToken) router.get('/info', userController.getUserInfo)