yingsa/server/src/controllers/userController.js
Ethanfly ccea3a99e5 feat: Enhance scrolling behavior with pause before refresh
- Introduced a pause mechanism at the end of scrolling for both Ladder and Ranking boards, allowing the last item to be fully visible before triggering a data refresh.
- Updated scrolling logic to prevent immediate resets, improving user experience during data loading.
- Added a consistent pause duration across components to standardize behavior during scrolling interactions.
2026-02-10 10:50:41 +08:00

745 lines
22 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 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();