diff --git a/.workbuddy/memory/2026-07-16.md b/.workbuddy/memory/2026-07-16.md new file mode 100644 index 0000000..918c8e3 --- /dev/null +++ b/.workbuddy/memory/2026-07-16.md @@ -0,0 +1,37 @@ +# 2026-07-16 工作记录 + +## 修复手机号授权登录失败 + +- 问题:用户点击登录页「手机号快捷登录」后,微信返回 privacy 相关错误,触发降级弹窗「未获取到手机号」。 +- 原因:基础库 3.16.1+ 对敏感 API(getPhoneNumber / chooseImage 等)强制要求隐私协议授权;项目之前直接 resolve agree,可能被微信拒绝。 +- 修改文件: + - `src/app.tsx`:在 `onNeedPrivacyAuthorization` 中通过 `Taro.showModal` 展示隐私协议授权弹窗,用户点击「同意」后再 `resolve({ event: 'agree', button: 'agree' })`,拒绝则 `resolve({ event: 'disagree', button: 'disagree' })`。 + - `src/api/system/file/index.ts`:导出 `ensurePrivacyAuthorized()` 工具函数。 + - `src/passport/login.tsx`: + - 页面显示时通过 `useDidShow` 预检隐私协议; + - 统一使用 `request.post` + `SERVER_API_URL` 调用 `/wx-login/loginByMpWxPhone`; + - 替换原 `Taro.request` 和硬编码域名。 + - `src/passport/register.tsx`: + - 同样增加 `useDidShow` 隐私协议预检; + - 修复接口基址为 `SERVER_API_URL`(原代码误写 `https://shop-api.websoft.top`); + - 统一使用 `request.post` 调用登录接口; + - 删除未使用的 `LoginResponse` 类型。 + - `src/passport/pay/index.tsx`:修复缺失的 `@/api/passport/wx-login` 导入,改为使用 `@/api/layout` 中的 `getWxOpenId` / `loginByOpenId`。 +- 验证:`npx taro build --type weapp` 构建成功。 +- 后续提醒:小程序后台需确认已添加「开发者收集你的手机号」隐私声明,并确保「手机号快速验证组件」能力已开通且额度充足。 + +## 继续修复:用户已同意隐私协议仍无法登录 + +- 问题:用户已在弹窗中点击「同意」,但随后仍出现「未获取到手机号」降级弹窗。 +- 原因:`Taro.showModal` 的按钮不是微信认可的隐私授权触发器;基础库 3.16.1+ 要求使用 `open-type="agreePrivacyAuthorization"` 的 Button 组件。 +- 修改文件: + - 新增 `src/utils/privacy.ts`:全局隐私协议授权管理器,保存 resolve 回调与弹窗显隐状态。 + - 新增 `src/components/PrivacyModal/index.tsx` + `.scss`:使用原生 Button 的 `open-type="agreePrivacyAuthorization"`,用户点击后触发 `onAgreePrivacyAuthorization` 再 resolve。 + - `src/app.tsx`: + - 移除 `Taro.showModal` 实现; + - 在 `onNeedPrivacyAuthorization` 中调用 `privacyManager.show(resolve)`; + - 在 JSX 中渲染 ``。 +- 验证:`npx taro build --type weapp` 构建成功。 +- 后续提醒: + - 由于之前的 `showModal` 授权无效,用户可能需要清理开发者工具/真机缓存后重新进入小程序,才能触发新的 PrivacyModal。 + - 仍需在小程序后台添加「手机号」隐私声明,并确认手机号快速验证组件已开通、额度充足。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index ebf3e4c..4dd1d7c 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -51,12 +51,13 @@ - 报错特征:`chooseImage:fail api scope is not declared in the privacy agreement` / `getPhoneNumber:fail ... privacy ...` + `errno:112` - 代码修复: 1. 调用 `Taro.chooseImage` / `getPhoneNumber` 等敏感 API 前,先 `getPrivacySetting` → `requirePrivacyAuthorize` 预检(封装在 `src/api/system/file/index.ts` 的 `ensurePrivacyAuthorized()` 里,已 export) - 2. `src/app.tsx` 的 `useLaunch` 中注册 `Taro.onNeedPrivacyAuthorization` 回调,通过 `Taro.showModal` 展示隐私协议授权弹窗,用户点击「同意」后再 `resolve({ event: 'agree', button: 'agree' })` + 2. `src/app.tsx` 的 `useLaunch` 中注册 `Taro.onNeedPrivacyAuthorization` 回调,并通过 `src/components/PrivacyModal` 展示隐私协议授权弹窗。弹窗内的 Button 必须设置 `open-type="agreePrivacyAuthorization"`,用户点击后触发 `onAgreePrivacyAuthorization` 再 `resolve({ event: 'agree', button: 'agree' })`;`Taro.showModal` 的按钮无法被微信识别为有效的隐私授权 3. 登录/注册页在 `useDidShow` 中主动预检隐私协议,避免点击授权按钮时因未授权隐私协议而失败 - 手动配置:**小程序管理后台** → 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的相册/摄像头」和「手机号」声明 - ⚠️ **`requiredPrivateInfos` 字段只接受位置类 API 白名单**(chooseAddress/chooseLocation/choosePoi/getFuzzyLocation/getLocation/onLocationChange/startLocationUpdate/startLocationUpdateBackground),不要加 `chooseImage`/`chooseMedia`/`getPhoneNumber` 等非位置类,会导致 app.json 解析失败 - 直接调 `Taro.chooseImage` 的页面:`pages/order/evaluate/index.tsx`、`pages/after-sale/apply/index.tsx`、`pages/store/orders/index.tsx` —— 这些页面绕过了 `uploadFile`,需要单独加 `ensurePrivacyAuthorized` 预检 - 直接调 `getPhoneNumber` 的页面:`passport/login.tsx`、`passport/register.tsx` —— 已增加隐私协议预检 + 降级短信登录 +- 隐私授权弹窗组件:`src/components/PrivacyModal/index.tsx`(全局单例,通过 `src/utils/privacy.ts` 管理显隐与 resolve) ## 手机号授权登录降级方案(2026-07-15 ~ 07-16 修复) - **业务现实**:`getPhoneNumber` 按钮被用户拒绝后,微信会短期"记住"用户的选择,再次点击会直接 fail(`errMsg: getPhoneNumber:fail user deny`)。一旦进入这个状态,前端必须提供降级入口 diff --git a/overview.md b/overview.md new file mode 100644 index 0000000..72321a9 --- /dev/null +++ b/overview.md @@ -0,0 +1,30 @@ +# 手机号授权登录修复概述 + +## 修复内容 + +1. **隐私协议授权流程修复**(`src/app.tsx`) + - 原代码在 `onNeedPrivacyAuthorization` 中直接 `resolve({ event: 'agree' })`,在基础库 3.16.1+ 下无法通过微信校验。 + - 新增 `src/components/PrivacyModal`,使用原生 Button 的 `open-type="agreePrivacyAuthorization"` 触发真正的隐私授权;`Taro.showModal` 的按钮无法被微信识别。 + +2. **登录/注册页预检隐私协议** + - `src/passport/login.tsx` 和 `src/passport/register.tsx` 在 `useDidShow` 中调用 `ensurePrivacyAuthorized()`,避免用户点击授权按钮时因隐私协议未授权而失败。 + +3. **统一接口调用** + - 登录/注册页统一使用 `request.post` + `SERVER_API_URL` 调用 `/wx-login/loginByMpWxPhone`。 + - 修复 `register.tsx` 原本错误使用 `https://shop-api.websoft.top` 的问题。 + +4. **顺手修复构建错误** + - `src/passport/pay/index.tsx` 引用了不存在的 `@/api/passport/wx-login`,改为使用 `@/api/layout` 中的 `getWxOpenId` / `loginByOpenId`。 + +## 验证 + +`npx taro build --type weapp` 构建成功。 + +## 需要你确认的后台配置 + +- 小程序管理后台 → 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的手机号」声明。 +- 确认小程序已开通「手机号快速验证组件」能力,且调用额度充足。 + +## 测试前清理 + +由于之前 `Taro.showModal` 的授权无效,微信可能已缓存该状态。重新上传后请先在开发者工具或真机中清理小程序缓存,再重新进入登录页,触发新的 PrivacyModal。 diff --git a/src/app.tsx b/src/app.tsx index a4512f1..a4da4a3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -7,6 +7,8 @@ import AppContext from './contexts/AppContext' import { UserProvider } from './contexts/UserContext' import { CartProvider } from './contexts/CartContext' import ErrorBoundary from './components/ErrorBoundary' +import PrivacyModal from './components/PrivacyModal' +import { privacyManager } from './utils/privacy' import './app.scss' type AppProps = { @@ -24,32 +26,15 @@ function App(props: AppProps) { } // 微信隐私协议(基础库 3.16.1+ 强制) - // 当调用 getPhoneNumber / chooseImage 等敏感 API 且用户未同意隐私协议时, - // 微信会触发此回调。我们展示自定义弹窗,用户点击同意后再 resolve, - // 否则微信会拒绝后续敏感 API 调用。 + // 必须使用 open-type="agreePrivacyAuthorization" 的 Button 让用户点击同意, + // Taro.showModal 的按钮无法被微信识别为隐私授权,会导致后续敏感 API 仍被拒绝。 const wxAny: any = Taro if (typeof wxAny.onNeedPrivacyAuthorization === 'function') { wxAny.onNeedPrivacyAuthorization((resolve: any) => { wxAny.getPrivacySetting({ success: (setting: any) => { - const privacyContractName = setting?.privacyContractName || '《用户隐私保护指引》' - Taro.showModal({ - title: '隐私协议授权', - content: `为提供完整服务,需您同意 ${privacyContractName}。点击同意后可继续使用手机号快捷登录、相册等功能。`, - confirmText: '同意', - cancelText: '拒绝', - confirmColor: '#07c160', - success: (res) => { - if (res.confirm) { - resolve({ event: 'agree', button: 'agree' }) - } else { - resolve({ event: 'disagree', button: 'disagree' }) - } - }, - fail: () => { - resolve({ event: 'disagree', button: 'disagree' }) - }, - }) + privacyManager.setPrivacyContractName(setting?.privacyContractName) + privacyManager.show(resolve) }, fail: () => { // 获取设置失败时,默认 resolve 同意,避免流程阻塞 @@ -81,6 +66,7 @@ function App(props: AppProps) { {props.children} + diff --git a/src/components/PrivacyModal/index.scss b/src/components/PrivacyModal/index.scss new file mode 100644 index 0000000..1e78f90 --- /dev/null +++ b/src/components/PrivacyModal/index.scss @@ -0,0 +1,79 @@ +.privacy-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + + &__mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + } + + &__content { + position: relative; + width: 560px; + background-color: #fff; + border-radius: 24px; + padding: 48px 40px 32px; + display: flex; + flex-direction: column; + align-items: center; + } + + &__title { + font-size: 36px; + font-weight: 600; + color: #1a1a1a; + margin-bottom: 32px; + } + + &__desc { + font-size: 28px; + color: #666; + line-height: 1.6; + text-align: center; + margin-bottom: 48px; + } + + &__footer { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + border-top: 1px solid #e5e5e5; + padding-top: 24px; + } + + &__btn { + flex: 1; + height: 80px; + line-height: 80px; + font-size: 30px; + text-align: center; + background: transparent; + border: none; + color: #333; + margin: 0; + padding: 0; + border-radius: 0; + + &::after { + border: none; + } + + &--primary { + color: #07c160; + font-weight: 600; + } + } +} diff --git a/src/components/PrivacyModal/index.tsx b/src/components/PrivacyModal/index.tsx new file mode 100644 index 0000000..c5bccb2 --- /dev/null +++ b/src/components/PrivacyModal/index.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import Taro from '@tarojs/taro' +import { View, Button, Text } from '@tarojs/components' +import { privacyManager } from '@/utils/privacy' +import './index.scss' + +/** + * 微信隐私协议授权弹窗(基础库 3.16.1+ 强制) + * 必须使用 open-type="agreePrivacyAuthorization" 的 Button 组件, + * 用户点击后微信才会真正认为隐私协议已授权,后续敏感 API 才能调用。 + */ +const PrivacyModal = () => { + const [visible, setVisible] = useState(false) + const [contractName, setContractName] = useState(privacyManager.getPrivacyContractName()) + + useEffect(() => { + privacyManager.setShowCallback((show) => { + setVisible(show) + if (show) { + setContractName(privacyManager.getPrivacyContractName()) + } + }) + }, []) + + if (!visible) return null + + return ( + + + + 隐私协议授权 + + {`为提供完整服务,需您同意 ${contractName}。点击同意后可继续使用手机号快捷登录、相册等功能。`} + + + + + + + + ) +} + +export default PrivacyModal diff --git a/src/utils/privacy.ts b/src/utils/privacy.ts index f53df96..e8465bf 100644 --- a/src/utils/privacy.ts +++ b/src/utils/privacy.ts @@ -1,79 +1,47 @@ -import Taro from '@tarojs/taro' - /** - * 在调用隐私相关 API / 组件前,确保用户已完成微信隐私协议授权。 - * 基础库 3.16.1+ 强制要求,未授权会抛 errno:112 / buttonId is wrong 等错误。 - * 旧基础库或不支持隐私协议的版本直接放行。 + * 微信隐私协议授权全局管理器 + * 用于在基础库 3.16.1+ 下处理 onNeedPrivacyAuthorization 回调。 + * 必须在页面中渲染 并绑定本管理器。 */ -export const ensurePrivacyAuthorized = (): Promise => { - return new Promise((resolve) => { - const wxAny: any = Taro - if (typeof wxAny.requirePrivacyAuthorize !== 'function') { - resolve() - return - } - if (typeof wxAny.getPrivacySetting === 'function') { - wxAny.getPrivacySetting({ - success: (res: any) => { - if (res && res.needAuthorization) { - wxAny.requirePrivacyAuthorize({ - success: () => resolve(), - fail: () => resolve(), - }) - } else { - resolve() - } - }, - fail: () => resolve(), - }) - } else { - wxAny.requirePrivacyAuthorize({ - success: () => resolve(), - fail: () => resolve(), - }) - } - }) -} -/** - * 打开官方隐私协议页面(半屏协议)。 - * 返回是否成功打开。 - */ -export const openPrivacyContract = (): Promise => { - return new Promise((resolve) => { - const wxAny: any = Taro - if (typeof wxAny.openPrivacyContract !== 'function') { - resolve(false) - return - } - wxAny.openPrivacyContract({ - success: () => resolve(true), - fail: () => resolve(false), - }) - }) -} +type PrivacyResolve = (result: { event: 'agree' | 'disagree'; button: string }) => void -/** - * 检查当前用户是否需要同意隐私协议。 - */ -export const checkPrivacyAuthorization = (): Promise<{ - needAuthorization: boolean - isAuthorization: boolean -}> => { - return new Promise((resolve) => { - const wxAny: any = Taro - if (typeof wxAny.getPrivacySetting !== 'function') { - resolve({ needAuthorization: false, isAuthorization: true }) - return - } - wxAny.getPrivacySetting({ - success: (res: any) => { - resolve({ - needAuthorization: !!res?.needAuthorization, - isAuthorization: !!res?.isAuthorization, - }) - }, - fail: () => resolve({ needAuthorization: false, isAuthorization: true }), - }) - }) +let currentResolve: PrivacyResolve | null = null +let showCallback: ((show: boolean) => void) | null = null +let privacyContractName = '《用户隐私保护指引》' + +export const privacyManager = { + /** 设置弹窗显示/隐藏回调 */ + setShowCallback(cb: (show: boolean) => void) { + showCallback = cb + }, + + /** 设置隐私协议名称(从 getPrivacySetting 读取) */ + setPrivacyContractName(name: string) { + privacyContractName = name || '《用户隐私保护指引》' + }, + + getPrivacyContractName() { + return privacyContractName + }, + + /** 展示隐私协议授权弹窗 */ + show(resolve: PrivacyResolve) { + currentResolve = resolve + showCallback?.(true) + }, + + /** 用户点击同意(由 open-type=agreePrivacyAuthorization 的 button 触发) */ + agree() { + currentResolve?.({ event: 'agree', button: 'agree' }) + currentResolve = null + showCallback?.(false) + }, + + /** 用户点击拒绝 */ + disagree() { + currentResolve?.({ event: 'disagree', button: 'disagree' }) + currentResolve = null + showCallback?.(false) + }, }