From fba6c5ac22b3264074e8e8b500c45f7996037976 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Thu, 22 Oct 2026 15:45:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E4=BD=93=E9=AA=8C=E8=B4=A6=E5=8F=B7=E7=9B=B4?= =?UTF-8?q?=E7=99=BB=EF=BC=8C=E4=BB=85=E4=BF=9D=E7=95=99=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E6=8E=88=E6=9D=83=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录页删除体验账号卡片、切换按钮与全部 demo 登录/失败回退逻辑,微信授权失败只提示重试 - completeLogin 只接受服务端签发的 token,禁止无 token 的假登录 - 删除 /api/auth/demo-accounts、/api/auth/demo-login、loadDemoAccounts 与客户端 API - 停止种子 demo_accounts 配置并清理历史配置行;117 套件/763 用例全绿;线上接口已 404 --- pwuniapp/common/api.js | 19 - pwuniapp/pages/auth/login.vue | 355 ++++-------------- server/src/db/mysql.ts | 19 +- server/src/index.ts | 82 ---- server/tests/content-security.spec.ts | 6 +- server/tests/demo-login-removed.spec.ts | 44 +++ .../tests/login-identity-consistency.spec.ts | 6 +- 7 files changed, 124 insertions(+), 407 deletions(-) create mode 100644 server/tests/demo-login-removed.spec.ts diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 588a020..18ef8bf 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -1572,22 +1572,3 @@ export async function loginWithWechatPhone({ phoneCode, wxCode } = {}) { }); } -/** - * 体验账号列表(后台配置,DB 驱动;小程序端不再写死) - */ -export async function getDemoAccounts() { - try { - const res = await http.get('/auth/demo-accounts'); - return Array.isArray(res) ? res : []; - } catch (err) { - console.warn('[API] 获取体验账号异常:', err.message); - return []; - } -} - -/** - * 体验登录:服务端校验账号在配置清单内后签发真实会话 token - */ -export async function demoLogin(userId) { - return http.post('/auth/demo-login', { userId: Number(userId) }); -} diff --git a/pwuniapp/pages/auth/login.vue b/pwuniapp/pages/auth/login.vue index 01440b7..dce3001 100644 --- a/pwuniapp/pages/auth/login.vue +++ b/pwuniapp/pages/auth/login.vue @@ -22,33 +22,6 @@ 微信官方安全风控 · 个人信息严格加密存储 - - - - 多角色体验账号直登 - 评审与体验专用 · 一键切换角色 - - - - - - - - {{ acc.nickname }} - {{ acc.roleDesc }} - - {{ acc.desc }} - - - - - - @@ -69,7 +42,7 @@ import { ref, onMounted } from 'vue'; import { onLoad, onShow } from '@dcloudio/uni-app'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; -import { getPlatformConfigs, loginWithWechatPhone, getDemoAccounts, demoLogin } from '@/common/api.js'; +import { getPlatformConfigs, loginWithWechatPhone } from '@/common/api.js'; const APP_LOGO = 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=200'; const DEFAULT_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; @@ -107,19 +80,6 @@ const loadProtocolConfig = async () => { } }; -const demoAccounts = ref([]); -const loadingDemoAccounts = ref(false); - -const loadDemoAccounts = async () => { - if (loadingDemoAccounts.value) return; - loadingDemoAccounts.value = true; - try { - demoAccounts.value = await getDemoAccounts(); - } finally { - loadingDemoAccounts.value = false; - } -}; - onLoad((options) => { if (options && options.redirect) { try { @@ -134,7 +94,6 @@ onMounted(() => { currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); loadProtocolConfig(); - loadDemoAccounts(); }); onShow(() => { @@ -157,15 +116,14 @@ const completeLogin = (userId, customData = {}, sessionToken = '') => { setTimeout(() => { uni.hideLoading(); + // 仅接受服务端签发的 token,避免出现"旧 token 属主 + 新 userId"的身份错配 + if (!sessionToken) { + uni.showToast({ title: '登录凭证缺失,请重试', icon: 'none' }); + return; + } uni.removeStorageSync('user_has_logged_out'); uni.setStorageSync('userId', userId); - // userId 与 token 必须保持一致:无 token 的体验登录要清掉旧 token, - // 否则会出现"旧 token 属主 + 新 userId"→ 服务端判定以他人身份操作(403) - if (sessionToken) { - uni.setStorageSync('userToken', sessionToken); - } else { - uni.removeStorageSync('userToken'); - } + uni.setStorageSync('userToken', sessionToken); uni.setStorageSync('userInfo', { id: userId, nickname: customData.nickname || '微信用户', @@ -220,82 +178,75 @@ const handleGetPhoneNumber = async (e) => { } // #ifdef MP-WEIXIN - if (e && e.detail && (e.detail.code || e.detail.encryptedData)) { - if (e.detail.code) { - uni.showLoading({ title: '正在获取手机号...' }); - try { - let wxCode = ''; - try { - const loginRes = await new Promise((resolve, reject) => { - uni.login({ - provider: 'weixin', - success: (res) => resolve(res), - fail: (err) => reject(err) - }); - }); - wxCode = loginRes.code || ''; - } catch (_) {} - - const res = await loginWithWechatPhone({ - phoneCode: e.detail.code, - wxCode - }); - - uni.hideLoading(); - if (res && (res.user || res.userId)) { - const userObj = res.user || { id: res.userId, phone: res.phone }; - const sessionToken = res.userToken || res.token || ''; - completeLogin(userObj.id, userObj, sessionToken); - return; - } - } catch (err) { - uni.hideLoading(); - console.warn('[WeChat Login] 微信手机号解析未成功:', err); - if (err.message && err.message.includes('封禁')) { - uni.showModal({ - title: '账号已封禁', - content: err.message, - showCancel: false, - confirmText: '我知道了' - }); - return; - } - uni.showModal({ - title: '登录提示', - content: `${err.message || '微信手机号解析未成功'},是否使用评审体验账号快捷登录?`, - confirmText: '快捷登录', - cancelText: '取消', - confirmColor: '#10B981', - success: (mRes) => { - if (mRes.confirm) { - loginFirstDemoAccount(); - } - } - }); - return; - } - } - - loginFirstDemoAccount(); - } else { + if (!e || !e.detail || !(e.detail.code || e.detail.encryptedData)) { console.warn('[WeChat Login] 未授权手机号或环境受限:', e?.detail?.errMsg); uni.showModal({ title: '登录提示', - content: '未获得微信手机号授权(需非个人主体认证小程序支持)。是否使用体验账号直接登录?', - confirmText: '快捷登录', - cancelText: '取消', - confirmColor: '#10B981', - success: (res) => { - if (res.confirm) { - loginFirstDemoAccount(); - } - } + content: '需要授权微信手机号才能登录。请点击“一键授权快捷登录”,并允许获取手机号后重试。', + showCancel: false, + confirmText: '我知道了' + }); + return; + } + + if (!e.detail.code) { + uni.showModal({ + title: '登录提示', + content: '当前微信版本暂不支持手机号快捷登录,请升级微信后重试。', + showCancel: false, + confirmText: '我知道了' + }); + return; + } + + uni.showLoading({ title: '正在获取手机号...' }); + try { + let wxCode = ''; + try { + const loginRes = await new Promise((resolve, reject) => { + uni.login({ + provider: 'weixin', + success: (res) => resolve(res), + fail: (err) => reject(err) + }); + }); + wxCode = loginRes.code || ''; + } catch (_) {} + + const res = await loginWithWechatPhone({ + phoneCode: e.detail.code, + wxCode + }); + + uni.hideLoading(); + if (res && (res.user || res.userId)) { + const userObj = res.user || { id: res.userId, phone: res.phone }; + const sessionToken = res.userToken || res.token || ''; + completeLogin(userObj.id, userObj, sessionToken); + } + } catch (err) { + uni.hideLoading(); + console.warn('[WeChat Login] 微信手机号解析未成功:', err); + if (err.message && err.message.includes('封禁')) { + uni.showModal({ + title: '账号已封禁', + content: err.message, + showCancel: false, + confirmText: '我知道了' + }); + return; + } + uni.showModal({ + title: '登录失败', + content: `${err.message || '微信手机号解析未成功'},请稍后重试。`, + showCancel: false, + confirmText: '我知道了' }); } // #endif // #ifndef MP-WEIXIN - loginFirstDemoAccount(); + uni.showToast({ title: '请在微信小程序内使用手机号快捷登录', icon: 'none' }); // #endif }; @@ -305,36 +256,10 @@ const handleFallbackLogin = () => { uni.showToast({ title: '请先阅读并勾选服务协议', icon: 'none' }); return; } - loginFirstDemoAccount(); + uni.showToast({ title: '请在微信小程序内使用手机号快捷登录', icon: 'none' }); // #endif }; -const loginWithDemo = async (acc) => { - if (!agreedProtocol.value) { - uni.showToast({ title: '请先阅读并勾选服务协议', icon: 'none' }); - return; - } - uni.showLoading({ title: '正在登录...' }); - try { - const res = await demoLogin(acc.userId); - uni.hideLoading(); - completeLogin(res.userId, res.user || {}, res.userToken || ''); - } catch (e) { - uni.hideLoading(); - uni.showToast({ title: (e && e.message) || '体验登录失败', icon: 'none' }); - } -}; - -const loginFirstDemoAccount = async () => { - if (!demoAccounts.value.length) await loadDemoAccounts(); - const first = demoAccounts.value[0]; - if (!first) { - uni.showToast({ title: '体验账号未配置,请联系管理员', icon: 'none' }); - return; - } - await loginWithDemo(first); -}; - const openUserAgreement = () => { uni.navigateTo({ url: '/pages/common/protocol?type=service', @@ -457,115 +382,6 @@ const openPrivacyAgreement = () => { } } -/* 体验账号卡片 */ -.demo-accounts-card { - background: #151D30; - border-radius: 28rpx; - border: 1px solid rgba(255, 255, 255, 0.06); - padding: 28rpx 28rpx 16rpx; - margin-bottom: 40rpx; - - .demo-card-head { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20rpx; - - .demo-title { - font-size: 26rpx; - font-weight: 700; - color: #F8FAFC; - } - .demo-sub { - font-size: 20rpx; - color: #64748B; - } - } - - .demo-list { - display: flex; - flex-direction: column; - gap: 16rpx; - - .demo-item { - display: flex; - align-items: center; - gap: 18rpx; - padding: 16rpx 20rpx; - border-radius: 20rpx; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - - .demo-avatar { - width: 72rpx; - height: 72rpx; - border-radius: 36rpx; - flex-shrink: 0; - } - - .demo-info { - flex: 1; - overflow: hidden; - - .demo-name-row { - display: flex; - align-items: center; - gap: 12rpx; - margin-bottom: 4rpx; - - .demo-name { - font-size: 26rpx; - font-weight: 700; - color: #F8FAFC; - } - - .demo-badge { - font-size: 18rpx; - padding: 2rpx 10rpx; - border-radius: 6rpx; - font-weight: 600; - - &.host { - background: rgba(245, 158, 11, 0.2); - color: #F59E0B; - } - &.buyer { - background: rgba(99, 102, 241, 0.2); - color: #818CF8; - } - &.expert { - background: rgba(16, 185, 129, 0.2); - color: #10B981; - } - } - } - - .demo-feature { - font-size: 20rpx; - color: #94A3B8; - display: block; - } - } - - .btn-switch-account { - font-size: 22rpx; - color: #6366F1; - font-weight: 600; - padding: 8rpx 18rpx; - border-radius: 12rpx; - background: rgba(99, 102, 241, 0.15); - border: none; - margin: 0; - line-height: 1.4; - - &::after { - border: none; - } - } - } - } -} - /* 协议勾选 (一行紧凑完整展示) */ .protocol-section { display: flex; @@ -636,39 +452,6 @@ const openPrivacyAgreement = () => { } } - .demo-accounts-card { - background: #FFFFFF; - border-color: #E7E5E4; - box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06); - - .demo-card-head { - .demo-title { - color: #1C1917; - } - .demo-sub { - color: #A8A29E; - } - } - - .demo-list .demo-item { - background: #FDFBF7; - border-color: #E7E5E4; - - .demo-info .demo-name-row .demo-name { - color: #1C1917; - } - - .demo-info .demo-feature { - color: #78716C; - } - - .btn-switch-account { - background: #FFF7ED; - color: #EA580C; - } - } - } - .protocol-section { .checkbox { border-color: #D6D3D1; diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index b4de38d..f3b5a41 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -1352,22 +1352,9 @@ async function seedInitialDataIfEmpty(conn: any): Promise { `, [mapLocationConfigVal]); } - // 体验账号(演示/评审):账号清单与展示文案全部落库,小程序端不再写死 - const demoAccountsVal = JSON.stringify({ - enabled: true, - accounts: [ - { userId: 1001, roleTag: 'host', roleDesc: '房主/局长', desc: '余额充足 · 发起项目制/AA组局' }, - { userId: 2001, roleTag: 'buyer', roleDesc: '普通买家', desc: '搭子报名 · 预约陪伴 · 核销码签到' }, - { userId: 2006, roleTag: 'expert', roleDesc: '金牌陪伴师', desc: '工作台接单 · 雷达抢单 · 现场核销' } - ], - remark: '体验账号:仅此处配置的账号可走「体验登录」获取真实会话 token;清空 accounts 即关闭体验登录' - }); - if (!existingKeys.has('demo_accounts')) { - await conn.query(` - INSERT INTO platform_configs (config_key, name, value_json, description, status) - VALUES ('demo_accounts', '体验账号配置', ?, '演示/评审用体验账号清单(含展示文案),关闭时清空 accounts', 1) - `, [demoAccountsVal]); - } + // 体验账号直登已下线:清理历史配置,避免残留测试账号清单 + await conn.query("DELETE FROM platform_configs WHERE config_key = 'demo_accounts'"); + if (existingKeys.has('platform_protocol')) { const [protoRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'platform_protocol'"); if (protoRows && protoRows.length > 0) { diff --git a/server/src/index.ts b/server/src/index.ts index 30872cc..a16263a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3090,88 +3090,6 @@ app.post('/api/auth/wechat-phone', async (req: Request, res: Response) => { } }); -// ===== 体验账号(演示/评审):清单与文案全部来自 DB 配置,小程序端不再写死 ===== -async function loadDemoAccounts(): Promise> { - try { - const record = await repo.getPlatformConfigByKey('demo_accounts'); - const value = (record && record.valueJson) || {}; - if (value.enabled === false) return []; - const accounts = Array.isArray(value.accounts) ? value.accounts : []; - return accounts - .map((a: any) => ({ - userId: Number(a.userId) || 0, - roleTag: String(a.roleTag || ''), - roleDesc: String(a.roleDesc || ''), - desc: String(a.desc || ''), - })) - .filter((a: any) => a.userId > 0) - .slice(0, 10); - } catch (e) { - logServerError('loadDemoAccounts', e); - return []; - } -} - -app.get('/api/auth/demo-accounts', async (_req: Request, res: Response) => { - try { - const accounts = await loadDemoAccounts(); - const list = []; - for (const acc of accounts) { - const u: any = await repo.getUserById(acc.userId); - if (!u) continue; - if (u.status === '已封禁' || u.status === '封禁') continue; - list.push({ - userId: u.id, - nickname: u.nickname || `用户${u.id}`, - avatar: u.avatar || '', - roleTag: acc.roleTag, - roleDesc: acc.roleDesc, - desc: acc.desc, - }); - } - res.json({ code: 20000, data: list }); - } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); - } -}); - -// 体验登录:只对 DB 配置内的账号签发真实 token,避免"无 token 的假登录"造成的 401/身份错配 -app.post('/api/auth/demo-login', rateLimit({ name: 'demo_login', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => { - try { - const userId = Number(req.body?.userId) || 0; - const accounts = await loadDemoAccounts(); - if (!accounts.some((a) => a.userId === userId)) { - return res.status(403).json({ code: 40300, message: '该账号未开启体验登录' }); - } - const user: any = await repo.getUserById(userId); - if (!user) { - return res.status(404).json({ code: 40400, message: '体验账号不存在或已被清理' }); - } - if (user.status === '已封禁' || user.status === '封禁') { - return res.status(403).json({ code: 40300, message: '该体验账号已被封禁' }); - } - const token = signUserToken(user.id); - res.json({ - code: 20000, - data: { - userId: user.id, - user: { - id: user.id, - nickname: user.nickname, - avatar: user.avatar, - gender: user.gender, - phone: user.phone, - }, - userToken: token, - token, - }, - message: '体验登录成功', - }); - } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message || '体验登录异常' }); - } -}); - // 微信小程序 JSAPI 统一下单预支付接口 (支持真实商户号配置与沙盒收银台无缝切换) app.post('/api/pay/wechat/prepay', rateLimit({ name: 'prepay', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), async (req: Request, res: Response) => { try { diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index 9ecebcc..a591db8 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -517,8 +517,10 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 expect(content).toContain('open-type="getPhoneNumber"'); expect(content).toContain('@getphonenumber="handleGetPhoneNumber"'); expect(content).toContain('一键授权快捷登录'); - expect(content).toContain('getDemoAccounts'); - expect(content).toContain('demoLogin'); + // 测试/演示账号直登已下线,只允许微信手机号授权登录 + expect(content).not.toContain('getDemoAccounts'); + expect(content).not.toContain('demoLogin'); + expect(content).not.toContain('多角色体验账号直登'); expect(content).toContain('用户服务协议'); expect(content).toContain('隐私保护指引'); // 验证已彻底移除登录大门前强制填写的头像昵称卡片,保障成熟商业极速登录体验 diff --git a/server/tests/demo-login-removed.spec.ts b/server/tests/demo-login-removed.spec.ts new file mode 100644 index 0000000..a6afa3f --- /dev/null +++ b/server/tests/demo-login-removed.spec.ts @@ -0,0 +1,44 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 体验账号直登下线闸门: + * 测试/演示账号不是真实微信授权用户,不允许再作为登录入口或失败回退。 + */ +describe('体验账号直登已下线', () => { + const root = path.resolve(__dirname, '../../'); + const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8'); + const login = read('pwuniapp/pages/auth/login.vue'); + const api = read('pwuniapp/common/api.js'); + const indexCode = read('server/src/index.ts'); + const mysql = read('server/src/db/mysql.ts'); + + it('登录页不得保留测试账号列表、切换按钮或回退登录', () => { + expect(login).not.toContain('demo-accounts-card'); + expect(login).not.toContain('多角色体验账号直登'); + expect(login).not.toContain('loginWithDemo'); + expect(login).not.toContain('loginFirstDemoAccount'); + expect(login).not.toContain('getDemoAccounts'); + expect(login).not.toContain('demoLogin'); + }); + + it('客户端 API 与后端路由必须移除', () => { + expect(api).not.toContain('/auth/demo-accounts'); + expect(api).not.toContain('/auth/demo-login'); + expect(indexCode).not.toContain('/api/auth/demo-accounts'); + expect(indexCode).not.toContain('/api/auth/demo-login'); + expect(indexCode).not.toContain('loadDemoAccounts'); + }); + + it('数据库不再种子体验账号配置,并清理历史配置', () => { + expect(mysql).toContain("DELETE FROM platform_configs WHERE config_key = 'demo_accounts'"); + expect(mysql).not.toContain('体验账号配置'); + }); + + it('微信授权失败时只提示重试,不再引导体验账号登录', () => { + expect(login).not.toContain('体验账号'); + expect(login).toContain('请在微信小程序内使用手机号快捷登录'); + expect(login).toContain('需要授权微信手机号才能登录'); + }); +}); diff --git a/server/tests/login-identity-consistency.spec.ts b/server/tests/login-identity-consistency.spec.ts index 5eefe34..1ab647e 100644 --- a/server/tests/login-identity-consistency.spec.ts +++ b/server/tests/login-identity-consistency.spec.ts @@ -12,10 +12,12 @@ describe('第九十批登录态一致性闸门', () => { const api = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8'); const login = fs.readFileSync(path.join(root, 'pwuniapp/pages/auth/login.vue'), 'utf-8'); - it('completeLogin 原子写入 userId 与 token(无 token 时清除旧 token)', () => { + it('completeLogin 只接受服务端签发的 token,缺失时拒绝登录', () => { expect(login).toContain('const completeLogin = (userId, customData = {}, sessionToken = \'\')'); - expect(login).toContain('uni.removeStorageSync(\'userToken\')'); + expect(login).toContain('if (!sessionToken)'); expect(login).toContain('uni.setStorageSync(\'userToken\', sessionToken)'); + // 不再存在"无 token 只写 userId"的体验登录路径 + expect(login).not.toContain('uni.removeStorageSync(\'userToken\')'); }); it('微信登录把 token 交给 completeLogin 统一落库', () => {