小程序微信后台和代理后台使用同一个域名

This commit is contained in:
2026-04-14 00:12:00 +08:00
parent a7c2448207
commit fbd10ad1f9
112 changed files with 510 additions and 405 deletions

View File

@@ -18,8 +18,12 @@ module.exports = {
officialAccount: {
appId: process.env.WX_OA_APPID || 'wx7a1c6f324182bc83',
appSecret: process.env.WX_OA_APPSECRET || 'a90ba94e3a2dca8d09656dcc364e1df0',
// 远程配置 Key用于动态获取网页授权域名
// 直接指定 OAuth 回调的基础域名(含路径前缀),优先于远程配置
// 例https://api.daoqijuyou.cn/wx → 回调为 .../wx/auth/oa/callback
redirectDomain: process.env.WX_OA_REDIRECT_DOMAIN || null,
// 远程配置 KeyredirectDomain 为空时才使用
// 在远程配置 json 中配置此 key 对应的值为您的测试/正式域名
redirectDomainKey: process.env.WX_OA_REDIRECT_DOMAIN_KEY || 'minipro_api_url'
},

View File

@@ -39,20 +39,28 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// 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}`;
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
@@ -65,26 +73,48 @@ app.get('/auth/oa/login', (req, res) => {
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;
// console.log('WeChat Callback Code:', code); // 打印回调 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`;
// 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 errHtml = 'Token Error: ' + JSON.stringify(tokenRes.data);
resolveHtml(errHtml);
setCacheWithTTL(code, errHtml);
return res.send(errHtml);
}
const { access_token, openid } = tokenRes.data;
@@ -92,50 +122,65 @@ app.get('/auth/oa/callback', async (req, res) => {
// 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
// 注意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 type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<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>
function sendAndBack() {
// 发送消息给小程序
wx.miniProgram.postMessage({
data: ${JSON.stringify(userInfo)}
});
// 跳回小程序上一页
wx.miniProgram.navigateBack();
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 (window.WeixinJSBridge) {
sendAndBack();
if (typeof WeixinJSBridge !== 'undefined') {
doNavigate();
} else {
document.addEventListener('WeixinJSBridgeReady', sendAndBack, false);
document.addEventListener('WeixinJSBridgeReady', doNavigate, false);
}
// 保底策略
setTimeout(sendAndBack, 1000);
setTimeout(doNavigate, 3000);
</script>
</body>
</html>
`;
// 将最终 HTML 字符串写入缓存(替换 Promise 占位)
setCacheWithTTL(code, html);
resolveHtml(html);
res.send(html);
} catch (error) {
console.error(error);
res.send('Auth error: ' + error.message);
const errHtml = 'Auth error: ' + error.message;
resolveHtml(errHtml);
res.send(errHtml);
}
});