Files
youlegames/codes/agent/game-docker/wxserver_daoqi/index.js

556 lines
21 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 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) => {
let currentDomain;
if (config.officialAccount.redirectDomain) {
// 优先使用环境变量 WX_OA_REDIRECT_DOMAIN支持带路径前缀如 https://api.xxx.cn/wx
currentDomain = config.officialAccount.redirectDomain.trim().replace(/\/$/, '');
if (!currentDomain.startsWith('http')) {
currentDomain = `https://${currentDomain}`;
}
} else {
// 从远程配置获取动态域名
const { agentid, gameid, channelid, marketid } = config.remoteConfig;
const key = config.officialAccount.redirectDomainKey || 'auth_redirect_domain';
currentDomain = remoteConfig.getParaValue(key, agentid, gameid, channelid, marketid);
if (!currentDomain) {
return res.status(500).send('Configuration Error: No redirect domain available in remote config.');
}
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);
});
// code 使用缓存:防止微信 webview 双重请求导致 40163 "code been used"
// value 可能是Promise处理中或 string已完成的 HTMLTTL 60 秒后自动清理
const codeCache = new Map();
function setCacheWithTTL(code, value, ttlMs = 60000) {
codeCache.set(code, value);
setTimeout(() => codeCache.delete(code), ttlMs);
}
// Step 2: 微信回调,获取 code -> access_token -> userinfo -> 返回 HTML 给 web-view
app.get('/auth/oa/callback', async (req, res) => {
const code = req.query.code;
logger.info('Auth', 'WeChat Callback Code:', code);
if (!code) return res.send('Auth failed, no code');
// 命中缓存:等待相同 code 的进行中 Promise 或直接返回已缓存 HTML
// 防止微信 webview 双重请求race condition触发 40163
if (codeCache.has(code)) {
logger.info('Auth', 'Returning cached response for code:', code);
const cached = codeCache.get(code);
const html = (cached instanceof Promise) ? await cached : cached;
return res.send(html);
}
// 立即写入 Promise 占位,后续并发请求直接等待此 Promise
let resolveHtml;
const htmlPromise = new Promise(resolve => { resolveHtml = resolve; });
setCacheWithTTL(code, htmlPromise);
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`;
logger.debug('Auth', 'Requesting Token URL (Secret Hidden)');
const tokenRes = await axios.get(tokenUrl);
logger.debug('Auth', 'Token Response:', tokenRes.data);
if (tokenRes.data.errcode) {
const errHtml = 'Token Error: ' + JSON.stringify(tokenRes.data);
resolveHtml(errHtml);
setCacheWithTTL(code, errHtml);
return res.send(errHtml);
}
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);
logger.success('Auth', 'User Info Retrieved:', infoRes.data.nickname);
const userInfo = infoRes.data; // { headimgurl, nickname, unionid, ... }
// 3. 返回一个 HTML利用 JSSDK 把数据传回小程序
// 注意wx.miniProgram.postMessage 只有在页面后退、销毁时才会触发小程序的 bindmessage
// 重要JSON 中的 < > & 必须转义为 unicode 转义序列,避免昵称/URL 中含 </script> 导致
// HTML 解析器提前关闭 script 标签,使整段 JS 失效(表现为一直循环无法返回小程序)
const safeUserData = JSON.stringify(userInfo)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
const html = `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>正在同步...</title>
<script 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>
var userData = ${safeUserData};
var _called = false;
function doNavigate() {
if (_called) return;
_called = true;
try { wx.miniProgram.postMessage({ data: userData }); } catch(e) {}
try {
wx.miniProgram.navigateBack({ delta: 1 });
} catch(e) {
try { wx.miniProgram.switchTab({ url: '/pages/profile/profile' }); } catch(e2) {}
}
}
if (typeof WeixinJSBridge !== 'undefined') {
doNavigate();
} else {
document.addEventListener('WeixinJSBridgeReady', doNavigate, false);
}
setTimeout(doNavigate, 3000);
</script>
</body>
</html>
`;
// 将最终 HTML 字符串写入缓存(替换 Promise 占位)
setCacheWithTTL(code, html);
resolveHtml(html);
res.send(html);
} catch (error) {
console.error(error);
const errHtml = 'Auth error: ' + error.message;
resolveHtml(errHtml);
res.send(errHtml);
}
});
// 获取 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');
});