diff --git a/YJYF/YJYF.vue b/YJYF/YJYF.vue index 823dd3b..30d61d8 100644 --- a/YJYF/YJYF.vue +++ b/YJYF/YJYF.vue @@ -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 || [] diff --git a/pages/login/login.vue b/pages/login/login.vue index b5ce727..8bb4572 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -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() { diff --git a/server/application/api/controller/Auth.php b/server/application/api/controller/Auth.php index 576aa98..0c93fff 100644 --- a/server/application/api/controller/Auth.php +++ b/server/application/api/controller/Auth.php @@ -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'], diff --git a/tests/config_check.js b/tests/config_check.js new file mode 100644 index 0000000..9ea0f5e --- /dev/null +++ b/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('═══════════════════════════════════════════════════'); +})(); diff --git a/utils/wxlogin.js b/utils/wxlogin.js new file mode 100644 index 0000000..1c030d4 --- /dev/null +++ b/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; +}