511 lines
20 KiB
JavaScript
511 lines
20 KiB
JavaScript
const express = require('express');
|
||
const bodyParser = require('body-parser');
|
||
const cors = require('cors');
|
||
const axios = require('axios');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const multer = require('multer');
|
||
// const { S3Client } = require('@aws-sdk/client-s3'); // Removed S3
|
||
// const { Upload } = require('@aws-sdk/lib-storage'); // Removed S3
|
||
const config = require('./config/index');
|
||
const remoteConfig = require('./services/remoteConfig');
|
||
const logger = require('./utils/logger');
|
||
|
||
const app = express();
|
||
const port = config.port;
|
||
|
||
app.use(cors());
|
||
app.use(bodyParser.json());
|
||
|
||
// 请求日志中间件
|
||
app.use((req, res, next) => {
|
||
// 忽略静态资源请求日志,避免刷屏
|
||
if (!req.url.startsWith('/uploads') && !req.url.startsWith('/public')) {
|
||
logger.info('HTTP', `${req.method} ${req.url}`);
|
||
}
|
||
next();
|
||
});
|
||
|
||
// 配置静态文件服务,用于微信域名校验文件
|
||
// 请将下载的 MP_verify_xxx.txt 文件放入 server/public 目录
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
// 配置上传文件的静态服务
|
||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||
|
||
// ==================================================================
|
||
// 公众号配置区域 (用于获取永久头像)
|
||
// ==================================================================
|
||
// 已移至 config.js
|
||
|
||
// Step 1: 小程序跳转到这里,服务器重定向到微信授权页
|
||
app.get('/auth/oa/login', (req, res) => {
|
||
// 1. 尝试从远程配置获取动态域名
|
||
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
|
||
const key = config.officialAccount.redirectDomainKey || 'auth_redirect_domain';
|
||
|
||
let currentDomain = remoteConfig.getParaValue(key, agentid, gameid, channelid, marketid);
|
||
|
||
if (!currentDomain) {
|
||
return res.status(500).send('Configuration Error: No redirect domain available in remote config.');
|
||
}
|
||
|
||
// 3. 格式化域名 (确保有 https且无末尾斜杠)
|
||
currentDomain = currentDomain.trim().replace(/\/$/, '');
|
||
if (!currentDomain.startsWith('http')) {
|
||
currentDomain = `https://${currentDomain}`;
|
||
}
|
||
|
||
// 这里的 redirect_uri 必须是 encodeURIComponent 后的完整 URL
|
||
const redirectUri = encodeURIComponent(`${currentDomain}/auth/oa/callback`);
|
||
const scope = 'snsapi_userinfo'; // 获取头像必须用 userinfo 作用域
|
||
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${config.officialAccount.appId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=STATE#wechat_redirect`;
|
||
|
||
// console.log('Redirecting to WeChat Auth:', url); // 打印跳转链接,方便调试
|
||
logger.info('Auth', 'Redirecting to WeChat Auth:', url);
|
||
res.redirect(url);
|
||
});
|
||
|
||
// Step 2: 微信回调,获取 code -> access_token -> userinfo -> 返回 HTML 给 web-view
|
||
app.get('/auth/oa/callback', async (req, res) => {
|
||
const code = req.query.code;
|
||
// console.log('WeChat Callback Code:', code); // 打印回调 Code
|
||
logger.info('Auth', 'WeChat Callback Code:', code);
|
||
|
||
if (!code) return res.send('Auth failed, no code');
|
||
|
||
try {
|
||
// 1. 获取 access_token
|
||
const tokenUrl = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${config.officialAccount.appId}&secret=${config.officialAccount.appSecret}&code=${code}&grant_type=authorization_code`;
|
||
// console.log('Requesting Token URL:', tokenUrl); // 打印 Token 请求链接 (注意:生产环境不要打印 Secret)
|
||
logger.debug('Auth', 'Requesting Token URL (Secret Hidden)');
|
||
|
||
const tokenRes = await axios.get(tokenUrl);
|
||
// console.log('Token Response:', tokenRes.data); // 打印 Token 响应结果
|
||
logger.debug('Auth', 'Token Response:', tokenRes.data);
|
||
|
||
if (tokenRes.data.errcode) {
|
||
return res.send('Token Error: ' + JSON.stringify(tokenRes.data));
|
||
}
|
||
|
||
const { access_token, openid } = tokenRes.data;
|
||
|
||
// 2. 获取用户信息 (包含永久头像 headimgurl)
|
||
const infoUrl = `https://api.weixin.qq.com/sns/userinfo?access_token=${access_token}&openid=${openid}&lang=zh_CN`;
|
||
const infoRes = await axios.get(infoUrl);
|
||
// console.log('User Info Response:', infoRes.data); // 打印用户信息响应
|
||
logger.success('Auth', 'User Info Retrieved:', infoRes.data.nickname);
|
||
|
||
const userInfo = infoRes.data; // { headimgurl, nickname, unionid, ... }
|
||
|
||
// 3. 返回一个 HTML,利用 JSSDK 把数据传回小程序
|
||
// 注意:wx.miniProgram.postMessage 只有在页面后退、销毁、分享时才会触发小程序的 bindmessage
|
||
// 所以这里我们发送数据后,立即调用 navigateBack
|
||
const html = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>正在同步...</title>
|
||
<script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||
</head>
|
||
<body>
|
||
<h3 style="text-align:center; margin-top: 50px;">正在同步微信头像...</h3>
|
||
<script>
|
||
function sendAndBack() {
|
||
// 发送消息给小程序
|
||
wx.miniProgram.postMessage({
|
||
data: ${JSON.stringify(userInfo)}
|
||
});
|
||
// 跳回小程序上一页
|
||
wx.miniProgram.navigateBack();
|
||
}
|
||
|
||
if (window.WeixinJSBridge) {
|
||
sendAndBack();
|
||
} else {
|
||
document.addEventListener('WeixinJSBridgeReady', sendAndBack, false);
|
||
}
|
||
// 保底策略
|
||
setTimeout(sendAndBack, 1000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
res.send(html);
|
||
|
||
} catch (error) {
|
||
console.error(error);
|
||
res.send('Auth error: ' + error.message);
|
||
}
|
||
});
|
||
|
||
// 获取 AccessToken (简单实现,实际生产环境需要缓存 AccessToken)
|
||
async function getAccessToken() {
|
||
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.miniProgram.appId}&secret=${config.miniProgram.appSecret}`;
|
||
try {
|
||
const response = await axios.get(url);
|
||
if (response.data.errcode) {
|
||
throw new Error(`获取 AccessToken 失败: ${response.data.errmsg}`);
|
||
}
|
||
return response.data.access_token;
|
||
} catch (error) {
|
||
console.error('Get AccessToken Error:', error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 1. 登录接口:换取 OpenID 和 UnionID
|
||
app.post('/api/login', async (req, res) => {
|
||
const { code } = req.body;
|
||
if (!code) {
|
||
return res.status(400).json({ error: 'Code is required' });
|
||
}
|
||
|
||
// 如果用户没有配置 AppID,返回模拟数据
|
||
if (config.miniProgram.appId === 'YOUR_APP_ID') {
|
||
// console.log('未配置 AppID,返回模拟登录数据');
|
||
return res.json({
|
||
openid: 'mock_openid_' + Date.now(),
|
||
unionid: 'mock_unionid_' + Date.now(),
|
||
session_key: 'mock_session_key'
|
||
});
|
||
}
|
||
|
||
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${config.miniProgram.appId}&secret=${config.miniProgram.appSecret}&js_code=${code}&grant_type=authorization_code`;
|
||
|
||
try {
|
||
const response = await axios.get(url);
|
||
if (response.data.errcode) {
|
||
return res.status(500).json({ error: response.data.errmsg });
|
||
}
|
||
// 返回 openid, unionid (如果有), session_key
|
||
res.json(response.data);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 2. 获取手机号接口
|
||
app.post('/api/getPhoneNumber', async (req, res) => {
|
||
const { code } = req.body;
|
||
if (!code) {
|
||
return res.status(400).json({ error: 'Code is required' });
|
||
}
|
||
|
||
// 如果用户没有配置 AppID,返回模拟数据
|
||
if (config.miniProgram.appId === 'YOUR_APP_ID') {
|
||
// console.log('未配置 AppID,返回模拟手机号');
|
||
return res.json({
|
||
phoneNumber: '13800138000',
|
||
purePhoneNumber: '13800138000',
|
||
countryCode: '86',
|
||
watermark: { timestamp: Date.now(), appid: config.miniProgram.appId }
|
||
});
|
||
}
|
||
|
||
try {
|
||
const accessToken = await getAccessToken();
|
||
const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||
|
||
const response = await axios.post(url, { code });
|
||
|
||
if (response.data.errcode === 0) {
|
||
// 成功获取
|
||
res.json(response.data.phone_info);
|
||
} else {
|
||
res.status(500).json({ error: response.data.errmsg, errcode: response.data.errcode });
|
||
}
|
||
} catch (error) {
|
||
console.error('Get Phone Number Error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 缓存 game_server_http
|
||
let cachedGameServerHttp = null;
|
||
|
||
// 监听远程配置更新
|
||
remoteConfig.onUpdate(() => {
|
||
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
|
||
const newValue = remoteConfig.getParaValue('game_server_http', agentid, gameid, channelid, marketid);
|
||
|
||
if (newValue) {
|
||
cachedGameServerHttp = newValue;
|
||
// logger.info('Config', 'Updated cached game_server_http:', cachedGameServerHttp);
|
||
} else {
|
||
logger.warn('Config', 'game_server_http not found in new config');
|
||
}
|
||
});
|
||
|
||
// 3. 文件上传接口 (本地存储)
|
||
// 确保存储目录存在
|
||
const uploadDir = path.join(__dirname, 'uploads');
|
||
if (!fs.existsSync(uploadDir)) {
|
||
fs.mkdirSync(uploadDir, { recursive: true });
|
||
}
|
||
|
||
const storage = multer.diskStorage({
|
||
destination: function (req, file, cb) {
|
||
cb(null, uploadDir)
|
||
},
|
||
filename: function (req, file, cb) {
|
||
const fileExtension = path.extname(file.originalname) || '.jpg';
|
||
const fileName = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}${fileExtension}`;
|
||
cb(null, fileName)
|
||
}
|
||
});
|
||
|
||
const upload = multer({
|
||
storage: storage,
|
||
fileFilter: (req, file, cb) => {
|
||
if (config.enableAvatarUpload === false) {
|
||
// 如果配置关闭了上传,拒绝文件
|
||
return cb(null, false);
|
||
}
|
||
cb(null, true);
|
||
}
|
||
});
|
||
|
||
app.post('/api/upload', upload.single('file'), (req, res) => {
|
||
if (config.enableAvatarUpload === false) {
|
||
return res.status(403).json({ error: 'Avatar upload is disabled' });
|
||
}
|
||
|
||
if (!req.file) {
|
||
return res.status(400).json({ error: 'No file uploaded' });
|
||
}
|
||
|
||
try {
|
||
// 构造访问链接
|
||
const protocol = req.protocol;
|
||
const host = req.get('host');
|
||
const fileUrl = `${protocol}://${host}/uploads/${req.file.filename}`;
|
||
|
||
logger.success('Upload', 'File uploaded successfully:', fileUrl);
|
||
res.json({ url: fileUrl });
|
||
} catch (error) {
|
||
logger.error('Upload', 'Upload Error:', error);
|
||
res.status(500).json({ error: 'Upload failed: ' + error.message });
|
||
}
|
||
});
|
||
|
||
// 4. 提交用户信息接口 (真实保存到游戏服务器)
|
||
app.post('/api/saveUserInfo', async (req, res) => {
|
||
const userInfo = req.body;
|
||
logger.divider('Save User Info');
|
||
logger.info('API', 'Received User Info:', userInfo.nickName);
|
||
|
||
try {
|
||
// 1. 使用缓存的 game_server_http
|
||
if (!cachedGameServerHttp) {
|
||
// 如果缓存为空(例如启动时首次获取尚未完成),尝试手动获取一次
|
||
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
|
||
cachedGameServerHttp = remoteConfig.getParaValue('game_server_http', agentid, gameid, channelid, marketid);
|
||
}
|
||
|
||
if (!cachedGameServerHttp) {
|
||
logger.error('Config', 'game_server_http not found');
|
||
return res.status(500).json({ error: 'Configuration error: game_server_http not found' });
|
||
}
|
||
|
||
// 确保 URL 格式正确
|
||
const targetUrl = cachedGameServerHttp.startsWith('http') ? cachedGameServerHttp : `http://${cachedGameServerHttp}`;
|
||
|
||
// 2. 构造请求数据
|
||
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
|
||
const payload = {
|
||
"app": "youle",
|
||
"route": "agent",
|
||
"rpc": "bind_player_wechat",
|
||
"data": {
|
||
"agentid": agentid,
|
||
"channelid": channelid,
|
||
"marketid": marketid,
|
||
"gameid": gameid,
|
||
"openid": userInfo.openid,
|
||
"unionid": userInfo.unionid,
|
||
"nickname": userInfo.nickName,
|
||
"avatar": userInfo.avatarUrl,
|
||
"sex": userInfo.gender,
|
||
"province": userInfo.province,
|
||
"city": userInfo.city,
|
||
"telphone": userInfo.phoneNumber
|
||
}
|
||
};
|
||
|
||
// --- Debug: 生成可粘贴到浏览器的 GET 链接 ---
|
||
// try {
|
||
// const debugQuery = `data=${encodeURIComponent(JSON.stringify(payload))}`;
|
||
// const debugUrl = `${targetUrl}?${debugQuery}`;
|
||
// console.log('\n[Debug] Request URL for Browser:');
|
||
// console.log(debugUrl);
|
||
// console.log('');
|
||
// } catch (e) {
|
||
// console.error('[Debug] Failed to generate debug URL:', e);
|
||
// }
|
||
// -------------------------------------------
|
||
|
||
// 3. 发送请求
|
||
const bodyData = 'data=' + JSON.stringify(payload);
|
||
|
||
logger.info('GameServer', 'Sending bind_player_wechat request...');
|
||
logger.debug('GameServer', 'Payload:', payload);
|
||
|
||
const response = await axios.post(targetUrl, bodyData, {
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
}
|
||
});
|
||
logger.info('GameServer', 'Response received');
|
||
logger.debug('GameServer', 'Response Data:', response.data);
|
||
|
||
const gameResult = response.data?.data?.result;
|
||
const isSuccess = gameResult !== -1;
|
||
res.json({ success: isSuccess, message: response.data?.data?.msg, data: response.data });
|
||
|
||
} catch (error) {
|
||
logger.error('API', 'Save User Info Error:', error.message);
|
||
res.status(500).json({ success: false, message: '用户信息保存失败: ' + error.message, error: error.message });
|
||
}
|
||
});
|
||
|
||
// 5. 玩家登录接口
|
||
app.post('/api/playerLogin', async (req, res) => {
|
||
const userInfo = req.body;
|
||
logger.divider('Player Login');
|
||
logger.info('API', 'Received Login Request:', userInfo.nickName);
|
||
|
||
try {
|
||
if (!cachedGameServerHttp) {
|
||
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
|
||
cachedGameServerHttp = remoteConfig.getParaValue('game_server_http', agentid, gameid, channelid, marketid);
|
||
}
|
||
|
||
if (!cachedGameServerHttp) {
|
||
return res.status(500).json({ error: 'Configuration error: game_server_http not found' });
|
||
}
|
||
|
||
const targetUrl = cachedGameServerHttp.startsWith('http') ? cachedGameServerHttp : `http://${cachedGameServerHttp}`;
|
||
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
|
||
|
||
const payload = {
|
||
"app": "youle",
|
||
"route": "agent",
|
||
"rpc": "player_login",
|
||
"data": {
|
||
"agentid": agentid,
|
||
"channelid": channelid,
|
||
"marketid": marketid,
|
||
"gameid": gameid,
|
||
"openid": userInfo.openid,
|
||
"unionid": userInfo.unionid,
|
||
"nickname": userInfo.nickName,
|
||
"avatar": userInfo.avatarUrl,
|
||
"sex": userInfo.gender,
|
||
"province": userInfo.province,
|
||
"city": userInfo.city,
|
||
"version": userInfo.version,
|
||
"telphone": userInfo.phoneNumber,
|
||
"smmcode": userInfo.verificationCode,
|
||
"telphoneAuto": userInfo.telphoneAuto,
|
||
"playerid": userInfo.playerid,
|
||
}
|
||
};
|
||
|
||
// --- Debug: 生成可粘贴到浏览器的 GET 链接 ---
|
||
// try {
|
||
// const debugQuery = `data=${encodeURIComponent(JSON.stringify(payload))}`;
|
||
// const debugUrl = `${targetUrl}?${debugQuery}`;
|
||
// console.log('\n[Debug] Request URL for Browser:');
|
||
// console.log(debugUrl);
|
||
// console.log('');
|
||
// } catch (e) {
|
||
// console.error('[Debug] Failed to generate debug URL:', e);
|
||
// }
|
||
// -------------------------------------------
|
||
|
||
const bodyData = 'data=' + JSON.stringify(payload);
|
||
logger.info('GameServer', 'Sending player_login request...');
|
||
logger.debug('GameServer', 'Payload:', payload);
|
||
|
||
const response = await axios.post(targetUrl, bodyData, {
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||
});
|
||
logger.info('GameServer', 'Login Response received');
|
||
logger.debug('GameServer', 'Response Data:', response.data);
|
||
|
||
res.json(response.data);
|
||
|
||
} catch (error) {
|
||
logger.error('API', 'Player Login Error:', error.message);
|
||
res.status(500).json({ error: '登录失败: ' + error.message });
|
||
}
|
||
});
|
||
|
||
// 6. 获取验证码接口
|
||
app.post('/api/getPhoneCode', async (req, res) => {
|
||
const { phonenum } = req.body;
|
||
logger.divider('Get Phone Code');
|
||
logger.info('API', 'Received GetCode Request:', phonenum);
|
||
|
||
try {
|
||
if (!cachedGameServerHttp) {
|
||
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
|
||
cachedGameServerHttp = remoteConfig.getParaValue('game_server_http', agentid, gameid, channelid, marketid);
|
||
}
|
||
|
||
if (!cachedGameServerHttp) {
|
||
return res.status(500).json({ error: 'Configuration error: game_server_http not found' });
|
||
}
|
||
|
||
const targetUrl = cachedGameServerHttp.startsWith('http') ? cachedGameServerHttp : `http://${cachedGameServerHttp}`;
|
||
const { agentid } = config.remoteConfig;
|
||
|
||
const payload = {
|
||
"app": "youle",
|
||
"route": "agent",
|
||
"rpc": "send_phone_code_wechat",
|
||
"data": {
|
||
"agentid": agentid,
|
||
"phonenum": phonenum
|
||
}
|
||
};
|
||
|
||
// --- Debug: 生成可粘贴到浏览器的 GET 链接 ---
|
||
// try {
|
||
// const debugQuery = `data=${encodeURIComponent(JSON.stringify(payload))}`;
|
||
// const debugUrl = `${targetUrl}?${debugQuery}`;
|
||
// console.log('\n[Debug] Request URL for Browser:');
|
||
// console.log(debugUrl);
|
||
// console.log('');
|
||
// } catch (e) {
|
||
// console.error('[Debug] Failed to generate debug URL:', e);
|
||
// }
|
||
// -------------------------------------------
|
||
|
||
const bodyData = 'data=' + JSON.stringify(payload);
|
||
logger.info('GameServer', 'Sending get_phone_code_wechat request...');
|
||
|
||
const response = await axios.post(targetUrl, bodyData, {
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||
});
|
||
logger.info('GameServer', 'GetCode Response received');
|
||
logger.debug('GameServer', 'Response Data:', response.data);
|
||
|
||
res.json(response.data);
|
||
|
||
} catch (error) {
|
||
logger.error('API', 'Get Phone Code Error:', error.message);
|
||
res.status(500).json({ error: '获取随机数失败: ' + error.message });
|
||
}
|
||
});
|
||
|
||
// 启动远程配置更新
|
||
remoteConfig.start();
|
||
|
||
app.listen(port, () => {
|
||
logger.divider();
|
||
logger.success('System', `Server running at http://localhost:${port}`);
|
||
logger.info('System', 'Press Ctrl+C to stop');
|
||
});
|