const jwt = require("jsonwebtoken"); const axios = require("axios"); const crypto = require("crypto"); const QRCode = require("qrcode"); const { User, LadderUser, Store, Match, MatchGame, PointRecord } = require("../models"); const { generateMemberCode, success, error, calculateDistance, formatDateTime, getFullUrl, normalizeAvatarUrl, normalizeAvatarForClient, } = require("../utils/helper"); const { LADDER_LEVEL_NAMES } = require("../config/constants"); const { Op } = require("sequelize"); class UserController { // 微信登录(获取 session_key,用于后续手机号解密) login = async (req, res) => { try { const { code } = req.body; if (!code) { return res.status(400).json(error("缺少登录code", 400)); } // 获取微信openid和session_key const wxRes = await axios.get( "https://api.weixin.qq.com/sns/jscode2session", { params: { appid: process.env.WX_APPID, secret: process.env.WX_SECRET, js_code: code, grant_type: "authorization_code", }, }, ); if (wxRes.data.errcode) { return res .status(400) .json(error("微信登录失败: " + wxRes.data.errmsg, 400)); } const { openid, unionid, session_key } = wxRes.data; // 查找用户 let user = await User.findOne({ where: { openid } }); let isNewUser = !user; const hasPhone = !!(user?.phone); // 返回登录信息(包含session_key用于后续手机号解密) const encryptedSessionKey = this.encryptSessionKey(session_key, openid); const responseData = { openid, unionid, sessionKey: encryptedSessionKey, isNewUser, hasPhone, }; if (hasPhone && user) { // 已有手机号的老用户:直接生成 token 登录,无需再次授权手机号 const token = jwt.sign( { userId: user.id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || "7d" } ); const ladderUsers = await LadderUser.findAll({ where: { user_id: user.id, status: 1 }, include: [{ model: Store, as: "store", attributes: ["id", "name"] }], }); responseData.token = token; responseData.userInfo = { id: user.id, nickname: user.nickname, avatar: normalizeAvatarForClient(user.avatar, req), phone: user.phone, gender: user.gender, memberCode: user.member_code, totalPoints: user.total_points, ladderUsers: ladderUsers.map((lu) => ({ id: lu.id, storeId: lu.store_id, storeName: lu.store?.name, realName: lu.real_name, level: lu.level, levelName: LADDER_LEVEL_NAMES[lu.level] || `Lv${lu.level}`, powerScore: lu.power_score, })), }; } else { responseData.userInfo = user ? { id: user.id, nickname: user.nickname, avatar: normalizeAvatarForClient(user.avatar, req), phone: user.phone, gender: user.gender, memberCode: user.member_code, totalPoints: user.total_points, } : null; } res.json( success( responseData, isNewUser ? "请授权手机号完成注册" : "登录成功", ), ); } catch (err) { console.error("登录失败:", err); res.status(500).json(error("登录失败")); } }; // 加密session_key encryptSessionKey(sessionKey, openid) { const key = crypto .createHash("md5") .update(process.env.JWT_SECRET + openid) .digest(); const iv = Buffer.alloc(16, 0); const cipher = crypto.createCipheriv("aes-128-cbc", key, iv); let encrypted = cipher.update(sessionKey, "utf8", "base64"); encrypted += cipher.final("base64"); return encrypted; } // 解密session_key decryptSessionKey(encryptedSessionKey, openid) { const key = crypto .createHash("md5") .update(process.env.JWT_SECRET + openid) .digest(); const iv = Buffer.alloc(16, 0); const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv); let decrypted = decipher.update(encryptedSessionKey, "base64", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } // 手机号授权登录(解密手机号并完成注册/登录) phoneLogin = async (req, res) => { try { const { openid, unionid, sessionKey, encryptedData, iv, nickname, avatar, gender, } = req.body; if (!openid || !sessionKey || !encryptedData || !iv) { return res.status(400).json(error("参数不完整", 400)); } // 解密session_key let realSessionKey; try { realSessionKey = this.decryptSessionKey(sessionKey, openid); } catch (e) { return res.status(400).json(error("会话已过期,请重新登录", 400)); } // 解密手机号 let phone; try { phone = this.decryptPhoneNumber(realSessionKey, encryptedData, iv); } catch (e) { console.error("手机号解密失败:", e); return res.status(400).json(error("手机号解密失败,请重新授权", 400)); } if (!phone) { return res.status(400).json(error("获取手机号失败", 400)); } const normalizedGender = gender === undefined || gender === null || gender === "" ? undefined : Number(gender); if ( normalizedGender !== undefined && normalizedGender !== 1 && normalizedGender !== 2 ) { return res.status(400).json(error("性别参数无效", 400)); } // 查找或创建用户 let user = await User.findOne({ where: { openid } }); if (!user) { // 尝试通过手机号查找已存在的自动生成用户(可能由管理员后台生成) // 只有当 openid 是自动生成的格式时才允许合并 const existingUser = await User.findOne({ where: { phone } }); if (existingUser && existingUser.openid.startsWith("AUTO_GEN_")) { // 合并账号:更新 openid 为真实的微信 openid console.log( `合并账号: 将自动生成用户 ${existingUser.id} (${existingUser.openid}) 更新为微信用户 ${openid}`, ); const updateData = { openid, unionid, }; if (nickname) updateData.nickname = nickname; if (avatar) updateData.avatar = normalizeAvatarUrl(avatar, req); if ( (existingUser.gender === 0 || existingUser.gender === null) && normalizedGender === undefined ) { return res.status(400).json(error("性别必填", 400)); } if (normalizedGender !== undefined) updateData.gender = normalizedGender; await existingUser.update(updateData); if (nickname) { await LadderUser.update( { real_name: nickname }, { where: { user_id: existingUser.id } }, ); } user = existingUser; } else { // 新用户注册 if (normalizedGender === undefined) { return res.status(400).json(error("性别必填", 400)); } // 规范化头像URL:如果是 https 则保持 https,否则是 http const normalizedAvatar = avatar ? normalizeAvatarUrl(avatar, req) : ""; user = await User.create({ openid, unionid, phone, member_code: generateMemberCode(), nickname: nickname || "新用户", avatar: normalizedAvatar, gender: normalizedGender, status: 1, }); // 关联已存在的天梯用户(通过手机号) await this.linkLadderUsers(user.id, phone); } } else { // 更新用户信息 const updateData = { phone }; if (nickname) updateData.nickname = nickname; // 规范化头像URL:如果是 https 则保持 https,否则是 http if (avatar) updateData.avatar = normalizeAvatarUrl(avatar, req); if (normalizedGender !== undefined) updateData.gender = normalizedGender; await user.update(updateData); if (nickname) { await LadderUser.update( { real_name: nickname }, { where: { user_id: user.id } }, ); } // 如果手机号变化,重新关联天梯用户 await this.linkLadderUsers(user.id, phone); } // 生成token const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || "7d", }); // 获取关联的天梯用户信息 const ladderUsers = await LadderUser.findAll({ where: { user_id: user.id, status: 1 }, include: [{ model: Store, as: "store", attributes: ["id", "name"] }], }); res.json( success( { token, userInfo: { id: user.id, nickname: user.nickname, avatar: normalizeAvatarForClient(user.avatar, req), phone: user.phone, gender: user.gender, memberCode: user.member_code, totalPoints: user.total_points, ladderUsers: ladderUsers.map((lu) => ({ id: lu.id, storeId: lu.store_id, storeName: lu.store?.name, realName: lu.real_name, level: lu.level, levelName: LADDER_LEVEL_NAMES[lu.level] || `Lv${lu.level}`, powerScore: lu.power_score, })), }, }, "登录成功", ), ); } catch (err) { console.error("手机号登录失败:", err); res.status(500).json(error("登录失败")); } }; // 解密微信手机号 decryptPhoneNumber(sessionKey, encryptedData, iv) { const sessionKeyBuffer = Buffer.from(sessionKey, "base64"); const encryptedDataBuffer = Buffer.from(encryptedData, "base64"); const ivBuffer = Buffer.from(iv, "base64"); const decipher = crypto.createDecipheriv( "aes-128-cbc", sessionKeyBuffer, ivBuffer, ); decipher.setAutoPadding(true); let decoded = decipher.update(encryptedDataBuffer, "binary", "utf8"); decoded += decipher.final("utf8"); const result = JSON.parse(decoded); return result.phoneNumber || result.purePhoneNumber; } // 关联天梯用户(通过手机号) async linkLadderUsers(userId, phone) { // 查找所有未关联的天梯用户(相同手机号) const unlinkedLadderUsers = await LadderUser.findAll({ where: { phone, user_id: null, status: 1, }, }); // 批量更新关联 if (unlinkedLadderUsers.length > 0) { await LadderUser.update( { user_id: userId }, { where: { phone, user_id: null } }, ); console.log( `已关联 ${unlinkedLadderUsers.length} 个天梯用户到用户 ${userId}`, ); } } // 更新用户资料(头像、昵称) updateProfile = async (req, res) => { try { const { nickname, avatar, gender } = req.body; const user = req.user; const updateData = {}; if (nickname) updateData.nickname = nickname; // 规范化头像URL:如果是 https 则保持 https,否则是 http if (avatar) updateData.avatar = normalizeAvatarUrl(avatar, req); if (gender !== undefined) { const normalizedGender = Number(gender); if (normalizedGender !== 1 && normalizedGender !== 2) { return res.status(400).json(error("性别参数无效", 400)); } updateData.gender = normalizedGender; } await user.update(updateData); // 用户修改昵称时,同步更新该用户下所有天梯用户的 real_name,保持两边一致 if (nickname) { await LadderUser.update( { real_name: nickname }, { where: { user_id: user.id } }, ); } res.json( success( { nickname: user.nickname, avatar: normalizeAvatarForClient(user.avatar, req), gender: user.gender, }, "更新成功", ), ); } catch (err) { console.error("更新资料失败:", err); res.status(500).json(error("更新失败")); } }; // 获取用户信息(支持 store_id 查询参数,返回该门店的积分) getInfo = async (req, res) => { try { const user = req.user; const { store_id: storeId } = req.query; // 获取天梯信息 const ladderUsers = await LadderUser.findAll({ where: { user_id: user.id, status: 1 }, include: [{ model: Store, as: "store", attributes: ["id", "name"] }], }); // 按门店的积分:从 PointRecord 汇总该门店的积分变动 let storePoints = null; if (storeId) { const sumResult = await PointRecord.sum("points", { where: { user_id: user.id, store_id: storeId, }, }); storePoints = Number(sumResult) || 0; } const result = { id: user.id, nickname: user.nickname, avatar: normalizeAvatarForClient(user.avatar, req), phone: user.phone, gender: user.gender, memberCode: user.member_code, totalPoints: user.total_points, ladderUsers: ladderUsers.map((lu) => ({ id: lu.id, storeId: lu.store_id, storeName: lu.store?.name, realName: lu.real_name, level: lu.level, levelName: LADDER_LEVEL_NAMES[lu.level] || `Lv${lu.level}`, powerScore: lu.power_score, matchCount: lu.match_count, winCount: lu.win_count, })), }; if (storePoints !== null) { result.storePoints = storePoints; } res.json(success(result)); } catch (err) { console.error("获取用户信息失败:", err); res.status(500).json(error("获取用户信息失败")); } }; // 更新用户信息 updateInfo = async (req, res) => { try { const { nickname, avatar, phone, gender } = req.body; const user = req.user; const newNickname = nickname || user.nickname; await user.update({ nickname: newNickname, avatar: avatar || user.avatar, phone: phone || user.phone, gender: gender !== undefined ? gender : user.gender, }); if (nickname) { await LadderUser.update( { real_name: nickname }, { where: { user_id: user.id } }, ); } res.json(success(null, "更新成功")); } catch (err) { console.error("更新用户信息失败:", err); res.status(500).json(error("更新失败")); } }; // 获取会员码 getMemberCode = async (req, res) => { try { res.json( success({ memberCode: req.user.member_code, }), ); } catch (err) { console.error("获取会员码失败:", err); res.status(500).json(error("获取失败")); } }; // 通过会员码查询用户 getByMemberCode = async (req, res) => { try { const { code } = req.params; const { store_id } = req.query; const user = await User.findOne({ where: { member_code: code, status: 1 }, }); if (!user) { return res.status(404).json(error("用户不存在", 404)); } // 获取该门店的天梯信息 let ladderUser = null; if (store_id) { ladderUser = await LadderUser.findOne({ where: { user_id: user.id, store_id, status: 1 }, }); } res.json( success({ id: user.id, nickname: user.nickname, avatar: user.avatar, gender: user.gender, ladderUser: ladderUser ? { id: ladderUser.id, realName: ladderUser.real_name, level: ladderUser.level, powerScore: ladderUser.power_score, } : null, }), ); } catch (err) { console.error("查询用户失败:", err); res.status(500).json(error("查询失败")); } }; // 获取用户天梯信息 getLadderInfo = async (req, res) => { try { const { store_id } = req.query; const user = req.user; const where = { user_id: user.id, status: 1 }; if (store_id) { where.store_id = store_id; } const ladderUsers = await LadderUser.findAll({ where, include: [{ model: Store, as: "store", attributes: ["id", "name"] }], }); res.json( success( ladderUsers.map((lu) => ({ id: lu.id, storeId: lu.store_id, storeName: lu.store?.name, realName: lu.real_name, gender: lu.gender, level: lu.level, levelName: LADDER_LEVEL_NAMES[lu.level] || `Lv${lu.level}`, powerScore: lu.power_score, matchCount: lu.match_count, winCount: lu.win_count, monthlyMatchCount: lu.monthly_match_count, lastMatchTime: formatDateTime(lu.last_match_time), })), ), ); } catch (err) { console.error("获取天梯信息失败:", err); res.status(500).json(error("获取失败")); } }; // 获取当前门店 getCurrentStore = async (req, res) => { try { const { latitude, longitude } = req.query; const user = req.user; // 获取用户参与天梯的门店 const ladderUsers = await LadderUser.findAll({ where: { user_id: user.id, status: 1 }, include: [{ model: Store, as: "store" }], order: [["last_match_time", "DESC"]], }); if (ladderUsers.length > 0) { // 有参与天梯的门店,返回最近参与比赛的门店 const lu = ladderUsers[0]; return res.json( success({ storeId: lu.store_id, storeName: lu.store?.name, storeAddress: lu.store?.address, ladderUserId: lu.id, source: "last_match", }), ); } // 没有天梯门店,返回最近的门店 if (latitude && longitude) { const stores = await Store.findAll({ where: { status: 1 }, }); if (stores.length > 0) { let nearestStore = stores[0]; let minDistance = Infinity; for (const store of stores) { if (store.latitude && store.longitude) { const dist = calculateDistance( parseFloat(latitude), parseFloat(longitude), parseFloat(store.latitude), parseFloat(store.longitude), ); if (dist < minDistance) { minDistance = dist; nearestStore = store; } } } return res.json( success({ storeId: nearestStore.id, storeName: nearestStore.name, storeAddress: nearestStore.address, ladderUserId: null, source: "nearest", }), ); } } // 返回第一个门店 const firstStore = await Store.findOne({ where: { status: 1 } }); res.json( success( firstStore ? { storeId: firstStore.id, storeName: firstStore.name, storeAddress: firstStore.address, ladderUserId: null, source: "default", } : null, ), ); } catch (err) { console.error("获取当前门店失败:", err); res.status(500).json(error("获取失败")); } }; // 生成会员二维码图片 getQrcode = async (req, res) => { try { const user = req.user; if (!user.member_code) { return res.status(400).json(error("会员码不存在", 400)); } // 生成二维码配置 const qrOptions = { errorCorrectionLevel: "M", type: "image/png", margin: 2, width: 300, color: { dark: "#1A1A1A", light: "#FFFFFF", }, }; // 生成二维码为 base64 const qrcodeDataUrl = await QRCode.toDataURL(user.member_code, qrOptions); res.json( success({ memberCode: user.member_code, qrcode: qrcodeDataUrl, }), ); } catch (err) { console.error("生成二维码失败:", err); res.status(500).json(error("生成二维码失败")); } }; // 直接返回二维码图片 getQrcodeImage = async (req, res) => { try { const user = req.user; if (!user.member_code) { return res.status(400).send("会员码不存在"); } // 生成二维码配置 const qrOptions = { errorCorrectionLevel: "M", type: "png", margin: 2, width: 300, color: { dark: "#1A1A1A", light: "#FFFFFF", }, }; // 设置响应头 res.setHeader("Content-Type", "image/png"); res.setHeader("Cache-Control", "public, max-age=86400"); // 直接输出二维码图片 await QRCode.toFileStream(res, user.member_code, qrOptions); } catch (err) { console.error("生成二维码图片失败:", err); res.status(500).send("生成二维码失败"); } }; } module.exports = new UserController();