chore(config): 微信登录配置加固 + 配置自检脚本
配置: - manifest.json mp-weixin.appid = wx40ca4b02d4d1cb58(构建产物已自动同步) - .env 新增 [sms] 段:mock / mock_code,手机号登录验证码改为配置驱动 (Auth::login 不再硬编码 123456;mock=0 时返回 2010 提示接入短信) - 服务端 .env [wechat] appid/secret 已就位并与小程序一致 加固: - 新增 utils/wxlogin.js:微信登录单飞(防静默登录与手动登录抢同一个一次性 code) + 40029/40163 自动换新 code 重试一次 - Auth::wxlogin 失败时回传 wx_errcode,供客户端判断是否重试 - 登录失败自动展开手机号兜底通道 新增 tests/config_check.js:一键核对 appid 三处一致性 / 接口连通 / HTTPS 证书 / 短信开关 验证:config_check 8/8 + 163 项断言回归全通过
这个提交包含在:
@@ -221,7 +221,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import api from '@/utils/api.js'
|
||||
import { isLogin, toLogin, setToken, setUser } from '@/utils/request.js'
|
||||
import { isLogin, toLogin } from '@/utils/request.js'
|
||||
import { ensureSilentLogin } from '@/utils/wxlogin.js'
|
||||
|
||||
/* ---------- 系统适配:状态栏 / 导航栏高度(刘海屏 + 胶囊按钮) ---------- */
|
||||
const statusBarHeight = ref(20)
|
||||
@@ -255,33 +256,11 @@
|
||||
const cats = ref([])
|
||||
const badge = ref({ coupon: 0, unread: 0 })
|
||||
|
||||
let silentTried = false
|
||||
|
||||
/**
|
||||
* 静默微信登录:进主页时自动换取 openid 并建号,
|
||||
* 成功 → 无感登录(可直接下单);失败 → 保持游客态,不打断浏览
|
||||
* 进主页即尝试静默微信登录(单飞 + 失效自动重试),失败保持游客态
|
||||
*/
|
||||
function trySilentLogin() {
|
||||
if (isLogin() || silentTried) return Promise.resolve()
|
||||
silentTried = true
|
||||
return new Promise((resolve) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (r) => {
|
||||
if (!r.code) { resolve(); return }
|
||||
api.wxLogin({ code: r.code }).then((data) => {
|
||||
setToken(data.token)
|
||||
setUser(data.userinfo)
|
||||
resolve()
|
||||
}).catch(() => resolve())
|
||||
},
|
||||
fail: () => resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loadHome() {
|
||||
await trySilentLogin()
|
||||
await ensureSilentLogin()
|
||||
try {
|
||||
const res = await api.home()
|
||||
cats.value = res.cats || []
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import api from '@/utils/api.js';
|
||||
import { setToken, setUser } from '@/utils/request.js';
|
||||
import { wxAuthorizeLogin } from '@/utils/wxlogin.js';
|
||||
import { setRole } from '@/utils/role.js';
|
||||
|
||||
const role = ref('user');
|
||||
@@ -67,36 +68,21 @@
|
||||
|
||||
function goBack() { uni.navigateBack(); }
|
||||
|
||||
/** 微信授权登录:uni.login 取 code → 后端换 openid → 下发 token */
|
||||
function wxLogin() {
|
||||
/** 微信授权登录(单飞防抢占 code + 40029 自动换 code 重试) */
|
||||
async function wxLogin() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (res) => {
|
||||
if (!res.code) {
|
||||
loading.value = false;
|
||||
toast('微信授权失败,请重试');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api.wxLogin({ code: res.code });
|
||||
setToken(data.token);
|
||||
setUser(data.userinfo);
|
||||
setRole(role.value);
|
||||
toast(data.is_new ? '登录成功,欢迎加入' : '登录成功');
|
||||
setTimeout(() => routeAfterLogin(), 600);
|
||||
} catch (e) {
|
||||
toast(e.message || '登录失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
loading.value = false;
|
||||
toast('未检测到微信环境,请在小程序内打开');
|
||||
}
|
||||
});
|
||||
try {
|
||||
const data = await wxAuthorizeLogin({});
|
||||
setRole(role.value);
|
||||
toast(data.is_new ? '登录成功,欢迎加入' : '登录成功');
|
||||
setTimeout(() => routeAfterLogin(), 600);
|
||||
} catch (e) {
|
||||
toast(e.message || '登录失败');
|
||||
showPhone.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function phoneLogin() {
|
||||
|
||||
@@ -14,7 +14,9 @@ class Auth extends Base
|
||||
protected $noNeedLogin = ['login', 'wxlogin'];
|
||||
|
||||
/**
|
||||
* 手机号 + 验证码登录(验证码固定 123456,联调用;接入短信后走 Sms)
|
||||
* 手机号 + 验证码登录(兜底通道)
|
||||
* 验证码来源由 .env 的 [sms] 段控制:mock=1 时使用 mock_code(联调用),
|
||||
* 接入真实短信后置 mock=0 并在此处改为 Sms::check($mobile, $code, 'mobilelogin')
|
||||
* @ApiMethod (POST)
|
||||
*/
|
||||
public function login()
|
||||
@@ -27,8 +29,11 @@ class Auth extends Base
|
||||
if (!Validate::regex($mobile, "^1\d{10}$")) {
|
||||
$this->error('手机号格式不正确');
|
||||
}
|
||||
if ($code !== '123456') {
|
||||
$this->error('验证码不正确');
|
||||
|
||||
$mockOn = (int)\think\Env::get('sms.mock', 1);
|
||||
$mockCode = (string)\think\Env::get('sms.mock_code', '123456');
|
||||
if (!($mockOn === 1 && $mockCode !== '' && (string)$code === $mockCode)) {
|
||||
$this->error('短信通道尚未接入,请联系管理员开通', ['sms_ready' => 0], 2010);
|
||||
}
|
||||
|
||||
$user = \app\common\model\User::getByMobile($mobile);
|
||||
@@ -130,7 +135,7 @@ class Auth extends Base
|
||||
|
||||
$wx = \app\common\library\WxLogin::jscode2session($code);
|
||||
if (isset($wx['error'])) {
|
||||
$this->error($wx['error']);
|
||||
$this->error($wx['error'], ['wx_errcode' => isset($wx['errcode']) ? (int)$wx['errcode'] : 0]);
|
||||
}
|
||||
$ret = \app\common\library\WxLogin::provision(
|
||||
$wx['openid'],
|
||||
|
||||
110
tests/config_check.js
普通文件
110
tests/config_check.js
普通文件
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 上线配置自检
|
||||
* 核对:manifest ↔ 构建产物 ↔ 服务端 .env 的 appid 一致性、接口连通性、HTTPS、短信开关
|
||||
* 运行:node tests/config_check.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = '/Users/linda/Desktop/project/uni-preset-vue-vite-ts shanchan/yjyf';
|
||||
const API = 'https://yjyf.gzyakj.com.cn/api';
|
||||
const HOST = '8.137.181.63';
|
||||
|
||||
let pass = 0, fail = 0, warn = 0;
|
||||
const ok = (m, e) => { pass++; console.log(' ✅ ' + m + (e ? ' → ' + e : '')); };
|
||||
const bad = (m, e) => { fail++; console.log(' ❌ ' + m + ' → ' + e); };
|
||||
const wrn = (m, e) => { warn++; console.log(' ⚠️ ' + m + (e ? ' → ' + e : '')); };
|
||||
const sec = (t) => console.log('\n▌' + t);
|
||||
|
||||
function ssh(cmd) {
|
||||
return execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@' + HOST, cmd], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
console.log(' 上线配置自检');
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
|
||||
// 1. appid 三处一致性
|
||||
sec('1. 小程序 appid 一致性');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'manifest.json'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''));
|
||||
const manifestAppid = manifest['mp-weixin'] && manifest['mp-weixin'].appid;
|
||||
if (manifestAppid) ok('manifest.json mp-weixin.appid', manifestAppid);
|
||||
else bad('manifest.json mp-weixin.appid', '为空');
|
||||
|
||||
const distCfg = path.join(ROOT, 'unpackage/dist/dev/mp-weixin/project.config.json');
|
||||
if (fs.existsSync(distCfg)) {
|
||||
const distAppid = JSON.parse(fs.readFileSync(distCfg, 'utf8')).appid;
|
||||
if (distAppid === manifestAppid) ok('构建产物 project.config.json appid 与 manifest 一致', distAppid);
|
||||
else bad('构建产物 appid 不一致', 'dist=' + distAppid + ' manifest=' + manifestAppid + '(请在 HBuilderX 重新编译)');
|
||||
const urlCheck = JSON.parse(fs.readFileSync(distCfg, 'utf8')).setting.urlCheck;
|
||||
if (urlCheck === false) ok('开发者工具已关闭「合法域名校验」(仅开发期有效)');
|
||||
else wrn('开发者工具开启了合法域名校验', '需先在小程序后台配置 request 域名');
|
||||
} else {
|
||||
wrn('未找到构建产物', distCfg + '(请先在 HBuilderX 运行一次)');
|
||||
}
|
||||
|
||||
const env = ssh("grep -E '^(appid|secret|mock)' /www/wwwroot/yjyf.gzyakj.com.cn/.env");
|
||||
const envAppid = (env.match(/appid\s*=\s*(\S+)/) || [])[1];
|
||||
const hasSecret = /secret\s*=\s*\S+/.test(env);
|
||||
if (envAppid === manifestAppid) ok('服务端 .env [wechat] appid 与小程序一致', envAppid);
|
||||
else bad('.env appid 与小程序不一致', 'env=' + envAppid + ' manifest=' + manifestAppid);
|
||||
if (hasSecret) ok('服务端 .env 已配置 appsecret');
|
||||
else bad('服务端 .env 缺少 appsecret');
|
||||
|
||||
// 2. 接口连通性
|
||||
sec('2. 接口连通性');
|
||||
let apiOk = false;
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(API + '/home/index');
|
||||
const json = await res.json();
|
||||
apiOk = json.code === 1 && json.data.cats.length === 15;
|
||||
if (apiOk) ok('GET /api/home/index 正常', `${res.status} · ${Date.now() - t0}ms · ${json.data.cats.length} 个类目`);
|
||||
else bad('接口返回异常', JSON.stringify(json).slice(0, 120));
|
||||
} catch (e) {
|
||||
bad('接口不可达', e.message);
|
||||
}
|
||||
|
||||
const wx = await (await fetch(API + '/auth/wxlogin', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: 'code=config_probe'
|
||||
})).json();
|
||||
const probeCode = wx.data && wx.data.wx_errcode;
|
||||
if (wx.code !== 1 && probeCode === 40029) ok('微信服务连通(返回 40029=code 无效,说明 appid/secret 正确)');
|
||||
else if (wx.code !== 1 && /appid/.test(wx.msg || '')) bad('appid 不一致', wx.msg);
|
||||
else if (wx.code !== 1) wrn('微信登录探测结果', wx.msg);
|
||||
else ok('微信登录探测通过');
|
||||
|
||||
// 3. HTTPS / 证书
|
||||
sec('3. HTTPS 与证书');
|
||||
try {
|
||||
const out = execFileSync('curl', ['-sS', '-o', '/dev/null', '-w', '%{http_code} %{ssl_verify_result} %{time_total}', API + '/home/index'], { encoding: 'utf8', timeout: 15000 });
|
||||
const [code, ssl, t] = out.trim().split(' ');
|
||||
if (code === '200' && ssl === '0') ok('HTTPS 可达且证书校验通过', `${t}s`);
|
||||
else bad('HTTPS/证书异常', 'http=' + code + ' ssl_verify=' + ssl);
|
||||
} catch (e) {
|
||||
bad('HTTPS 检测失败', e.message);
|
||||
}
|
||||
|
||||
// 4. 短信通道
|
||||
sec('4. 手机号登录(短信)通道');
|
||||
const mock = (env.match(/mock\s*=\s*(\d+)/) || [])[1];
|
||||
const mockCode = (env.match(/mock_code\s*=\s*(\S+)/) || [])[1];
|
||||
if (mock === '1') wrn('当前为联调模式(mock=1)', '验证码固定为 ' + mockCode + ',上线前须接入真实短信并置 0');
|
||||
else ok('已关闭联调模式(mock=0)');
|
||||
|
||||
// 5. 需人工完成的项
|
||||
sec('5. 需在微信公众平台完成(无法用 appid/secret 代配)');
|
||||
wrn('request 合法域名', '需加 https://yjyf.gzyakj.com.cn');
|
||||
wrn('uploadFile 合法域名', '如启用图片上传需加同一域名');
|
||||
wrn('downloadFile 合法域名', '如启用图片回显需加同一域名');
|
||||
console.log(' 路径:mp.weixin.qq.com → 开发 → 开发管理 → 开发设置 → 服务器域名(需管理员扫码)');
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════════');
|
||||
console.log(` 结果:通过 ${pass} / 失败 ${fail} / 待人工 ${warn}`);
|
||||
if (fail) process.exitCode = 1;
|
||||
console.log('═══════════════════════════════════════════════════');
|
||||
})();
|
||||
81
utils/wxlogin.js
普通文件
81
utils/wxlogin.js
普通文件
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 微信登录统一入口
|
||||
* 解决两个线上问题:
|
||||
* 1) code 一次性(5 分钟、单次有效)——静默登录与手动登录若同时发起会互相抢 code
|
||||
* → 用「单飞(in-flight)」保证同一时刻只有一个换取流程
|
||||
* 2) 40029(invalid code)——可能来自 code 已被消费/过期
|
||||
* → 自动用全新 code 重试一次
|
||||
*/
|
||||
import api from './api.js';
|
||||
import { getToken, setToken, setUser } from './request.js';
|
||||
|
||||
let inflight = null;
|
||||
|
||||
/** 取微信一次性 code */
|
||||
export function getWxCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => (res && res.code ? resolve(res.code) : reject(new Error('微信未返回 code'))),
|
||||
fail: () => reject(new Error('未检测到微信环境,请在小程序内打开'))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isCodeInvalid(err) {
|
||||
const code = err && err.data ? Number(err.data.wx_errcode) : 0;
|
||||
if (code === 40029 || code === 40163) return true;
|
||||
return /invalid code|code been used|40029/i.test((err && err.message) || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 code 换 token;遇到 code 失效自动换新 code 重试一次
|
||||
*/
|
||||
async function exchange(payload, retry) {
|
||||
const code = await getWxCode();
|
||||
try {
|
||||
const data = await api.wxLogin(Object.assign({}, payload, { code: code }));
|
||||
setToken(data.token);
|
||||
setUser(data.userinfo);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (retry && isCodeInvalid(err)) {
|
||||
return exchange(payload, false);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默登录(进主页时调用):已登录直接返回,失败不抛错
|
||||
*/
|
||||
export function ensureSilentLogin() {
|
||||
if (getToken()) return Promise.resolve(null);
|
||||
if (inflight) return inflight;
|
||||
|
||||
inflight = exchange({}, true)
|
||||
.catch(() => null)
|
||||
.then((data) => {
|
||||
inflight = null;
|
||||
return data;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动授权登录(点击「微信一键登录」)
|
||||
*/
|
||||
export function wxAuthorizeLogin(payload) {
|
||||
if (inflight) {
|
||||
// 复用在途结果,避免抢同一个 code
|
||||
return inflight.then((data) => {
|
||||
if (data) return data;
|
||||
return exchange(payload || {}, true);
|
||||
});
|
||||
}
|
||||
inflight = exchange(payload || {}, true).then(
|
||||
(data) => { inflight = null; return data; },
|
||||
(err) => { inflight = null; throw err; }
|
||||
);
|
||||
return inflight;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户