diff --git a/src/api/system/file/index.ts b/src/api/system/file/index.ts index 02b8e14..26748f9 100644 --- a/src/api/system/file/index.ts +++ b/src/api/system/file/index.ts @@ -54,11 +54,11 @@ const computeSignature = (accessKeySecret: string, canonicalString: string): str } /** - * 在调用涉及用户隐私的 API(chooseImage / chooseMedia / getLocation 等)前, + * 在调用涉及用户隐私的 API(chooseImage / chooseMedia / getLocation / getPhoneNumber 等)前, * 等待用户完成微信隐私协议授权。基础库 3.16.1+ 强制要求,未授权会抛 errno:112。 * 旧基础库或不支持隐私协议的版本直接放行。 */ -const ensurePrivacyAuthorized = (): Promise => { +export const ensurePrivacyAuthorized = (): Promise => { return new Promise((resolve) => { const wxAny: any = Taro if (typeof wxAny.requirePrivacyAuthorize !== 'function') { diff --git a/src/app.config.ts b/src/app.config.ts index 78a6cd0..b946cd6 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -15,6 +15,8 @@ export default { 'passport/qr-login/index', 'passport/qr-confirm/index', 'passport/unified-qr/index', + // 支付页面 + 'passport/pay/index', // 首页子页面 'pages/index/search', 'pages/index/notification', diff --git a/src/app.tsx b/src/app.tsx index 995278d..a4512f1 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -24,12 +24,38 @@ function App(props: AppProps) { } // 微信隐私协议(基础库 3.16.1+ 强制) - // 用户已在登录页底部勾选同意《用户协议》和《隐私政策》, - // 此处直接 resolve agree,不再弹自定义授权窗口。 + // 当调用 getPhoneNumber / chooseImage 等敏感 API 且用户未同意隐私协议时, + // 微信会触发此回调。我们展示自定义弹窗,用户点击同意后再 resolve, + // 否则微信会拒绝后续敏感 API 调用。 const wxAny: any = Taro if (typeof wxAny.onNeedPrivacyAuthorization === 'function') { wxAny.onNeedPrivacyAuthorization((resolve: any) => { - resolve({ event: 'agree', button: 'agree' }) + 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' }) + }, + }) + }, + fail: () => { + // 获取设置失败时,默认 resolve 同意,避免流程阻塞 + resolve({ event: 'agree', button: 'agree' }) + }, + }) }) } }) diff --git a/src/passport/login.tsx b/src/passport/login.tsx index 8cbc755..2456984 100644 --- a/src/passport/login.tsx +++ b/src/passport/login.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react' -import Taro from '@tarojs/taro' +import Taro, { useDidShow } from '@tarojs/taro' import { View, Image, Text, Button } from '@tarojs/components' import { TenantId } from '@/config/app' import { getWxOpenId } from '@/api/layout' import { getUserInfo } from '@/api/layout' -import { saveStorageByLoginUser } from '@/utils/server' +import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server' import { isUserDisabled } from '@/utils/auth' +import request from '@/utils/request' +import { ensurePrivacyAuthorized } from '@/api/system/file' import { checkAndHandleInviteRelation, hasPendingInvite, @@ -72,22 +74,21 @@ async function ensureWxOpenIdSaved() { } } -/** 获取用户信息 */ +/** 获取用户信息(使用临时 token,避免本地 storage 尚未写入) */ async function fetchUserInfo(token: string) { try { - const serverUrl = 'https://server.websoft.top' - const res: any = await Taro.request({ - url: `${serverUrl}/api/auth/user`, - method: 'GET', - header: { - 'Authorization': `Bearer ${token}`, - 'content-type': 'application/json', - TenantId: String(TenantId), - }, - }) - - if (res.data?.code === 0 && res.data?.data) { - return res.data.data + const res: any = await request.get( + `${SERVER_API_URL}/auth/user`, + {}, + { + header: { + Authorization: `Bearer ${token}`, + }, + showError: false, + } + ) + if (res?.code === 0 && res?.data) { + return res.data } return null } catch (e) { @@ -109,6 +110,14 @@ const Login = () => { setTimeout(() => setShowContent(true), 100) }, []) + /** 页面显示时预检隐私协议,避免 getPhoneNumber 因未授权隐私协议而失败 */ + useDidShow(() => { + if (!isWeapp) return + ensurePrivacyAuthorized().catch((e) => { + console.warn('登录页隐私协议预检失败:', e) + }) + }) + /** 解析 redirect 参数 */ const redirectUrl = (() => { const raw = (router?.params as Record | undefined)?.redirect @@ -215,23 +224,21 @@ const Login = () => { const inviteParams = parseInviteParams({ query: router?.params }) const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0 - const serverUrl = 'https://server.websoft.top' - const res: any = await Taro.request({ - url: `${serverUrl}/api/wx-login/loginByMpWxPhone`, - method: 'POST', - data: { + const res: any = await request.post( + `${SERVER_API_URL}/wx-login/loginByMpWxPhone`, + { code: phoneCode, notVerifyPhone: true, refereeId, sceneType: 'save_referee', tenantId: Number(TenantId), }, - header: { 'content-type': 'application/json', TenantId: String(TenantId) }, - }) + { showError: false } + ) - if (res.data?.code === 0 && res.data?.data?.access_token) { - const token = res.data.data.access_token - let user = res.data.data.user + if (res?.code === 0 && res?.data?.access_token) { + const token = res.data.access_token + let user = res.data.user // 获取最新的用户信息 const freshUserInfo = await fetchUserInfo(token) @@ -262,7 +269,7 @@ const Login = () => { Taro.showToast({ title: '登录成功', icon: 'success' }) setTimeout(() => navigateAfterLogin(), 800) } else { - Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' }) + Taro.showToast({ title: res?.message || '登录失败', icon: 'none' }) } } catch (e: any) { console.error('微信登录失败:', e) diff --git a/src/passport/pay/index.config.ts b/src/passport/pay/index.config.ts new file mode 100644 index 0000000..656a5fe --- /dev/null +++ b/src/passport/pay/index.config.ts @@ -0,0 +1,5 @@ +export default { + navigationBarTitleText: '确认支付', + navigationBarTextStyle: 'black', + navigationBarBackgroundColor: '#ffffff' +} diff --git a/src/passport/pay/index.tsx b/src/passport/pay/index.tsx new file mode 100644 index 0000000..4aff7f2 --- /dev/null +++ b/src/passport/pay/index.tsx @@ -0,0 +1,400 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { View, Text } from '@tarojs/components' +import { Button, Cell, Price, Divider } from '@nutui/nutui-react-taro' +import { Check, Close, Tips, Clock } from '@nutui/icons-react-taro' +import Taro from '@tarojs/taro' +import { request } from '@/utils/request' +import { getOpenId, loginByOpenId } from '@/api/passport/wx-login' +import { saveStorageByLoginUser } from '@/utils/server' +import { TenantId } from '@/config/app' + +/** + * 小程序支付页面 + * 接收参数 subscriptionNo,完成 JSAPI 支付 + * + * 重要:判断订单是否已支付,必须用 payStatus(0=未支付 1=已支付), + * 不能用 status(订阅生命周期:active/pending/expired/cancelled)。 + * 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0, + * 若用 status 判支付会直接误判为"已支付"。 + */ +interface SubscriptionDetail { + id: number + subscriptionNo: string + productId: number + productName: string + productLogo?: string + productIcon?: string + status: string + // 支付状态: 0-未支付 1-已支付 + payStatus?: number + payTime?: string + transactionId?: string + priceType: string + payPrice: number + subscriptionPeriod?: string +} + +interface JsapiPayParams { + timeStamp: string + nonceStr: string + package: string + signType: string + paySign: string + outTradeNo: string +} + +const PayPage: React.FC = () => { + const [loading, setLoading] = useState(true) + const [paying, setPaying] = useState(false) + const [detail, setDetail] = useState(null) + const [errorMsg, setErrorMsg] = useState('') + const [paid, setPaid] = useState(false) + + // 从页面参数中获取 subscriptionNo + // 小程序码 scene 会作为 query.scene 传入,需解码 + const getSubscriptionNo = useCallback(() => { + const instance = Taro.getCurrentInstance() + const params = instance?.router?.params || {} + console.log('[PayPage] ===== 参数调试开始 =====') + console.log('[PayPage] 原始 params:', JSON.stringify(params)) + console.log('[PayPage] params.scene:', params.scene) + console.log('[PayPage] params.subscriptionNo:', params.subscriptionNo) + // 优先读取 scene(小程序码参数),再读取 subscriptionNo(普通链接参数) + const scene = params.scene ? decodeURIComponent(params.scene) : '' + const subscriptionNo = scene || params.subscriptionNo || '' + console.log('[PayPage] decodeURIComponent(scene):', scene) + console.log('[PayPage] 最终 subscriptionNo:', subscriptionNo) + console.log('[PayPage] ===== 参数调试结束 =====') + return subscriptionNo + }, []) + + // 获取订阅详情 + const fetchDetail = useCallback(async () => { + const subscriptionNo = getSubscriptionNo() + if (!subscriptionNo) { + console.error('[PayPage] subscriptionNo 为空,无法请求') + setErrorMsg('缺少订单参数') + setLoading(false) + return + } + + console.log('[PayPage] ===== 请求调试开始 =====') + console.log('[PayPage] 请求 URL:', `/app/subscription/detail-by-no/${subscriptionNo}`) + console.log('[PayPage] 当前 token:', Taro.getStorageSync('access_token') || '(无token)') + + try { + // request 默认 returnRaw=false,拦截器已拆包,返回的就是 data 部分(订阅对象) + const data: any = await request({ + url: `/app/subscription/detail-by-no/${subscriptionNo}`, + method: 'GET', + returnRaw: true // 先用 returnRaw=true 拿到完整响应,方便调试 + }) + + console.log('[PayPage] 接口完整返回(returnRaw=true):', JSON.stringify(data)) + console.log('[PayPage] data.code:', data?.code) + console.log('[PayPage] data.message:', data?.message) + console.log('[PayPage] data.data:', data?.data) + + // 判断业务状态码 + if (data?.code === 0 || data?.code === 200) { + const subscription = data.data || data + console.log('[PayPage] 订阅对象:', JSON.stringify(subscription)) + console.log('[PayPage] 订阅状态 status:', subscription?.status) + console.log('[PayPage] 支付状态 payStatus:', subscription?.payStatus) + console.log('[PayPage] 支付时间 payTime:', subscription?.payTime) + + // 关键修复:判断是否已支付,必须用 payStatus 而非 status + // 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0 + const isPaid = subscription?.payStatus === 1 + const isCancelled = subscription?.status === 'cancelled' + const isExpired = subscription?.status === 'expired' + + if (isCancelled) { + setErrorMsg('订单已取消,无法继续支付') + } else if (isExpired) { + setErrorMsg('订单已过期,请重新下单') + } else if (isPaid) { + setPaid(true) + setDetail(subscription) + } else { + setDetail(subscription) + } + } else { + console.error('[PayPage] 业务错误 code:', data?.code, 'message:', data?.message) + setErrorMsg(data?.message || `业务错误(code=${data?.code})`) + } + } catch (err: any) { + console.error('[PayPage] ===== 请求异常 =====') + console.error('[PayPage] err.name:', err?.name) + console.error('[PayPage] err.type:', err?.type) + console.error('[PayPage] err.code:', err?.code) + console.error('[PayPage] err.message:', err?.message) + console.error('[PayPage] err.data:', err?.data) + console.error('[PayPage] err 完整:', JSON.stringify(err)) + setErrorMsg(err?.message || '网络异常,请重试') + } finally { + setLoading(false) + console.log('[PayPage] ===== 请求调试结束 =====') + } + }, [getSubscriptionNo]) + + useEffect(() => { + fetchDetail() + }, [fetchDetail]) + + // 执行支付 + const handlePay = async () => { + if (!detail) return + + setPaying(true) + try { + // 1. 获取微信登录凭证(code) + const loginRes = await Taro.login() + if (!loginRes.code) { + throw new Error('获取登录凭证失败') + } + + console.log('[PayPage] Taro.login code:', loginRes.code) + + // 2. 用 code 换取 openid(关键步骤,之前遗漏了) + const openIdRes = await getOpenId(loginRes.code) + console.log('[PayPage] getOpenId 结果:', openIdRes) + + if (!openIdRes.success || !openIdRes.openid) { + throw new Error(openIdRes.message || '获取 openid 失败,请重试') + } + + const openid = openIdRes.openid + // 缓存 openid,后续支付确认等接口可用 + Taro.setStorageSync('openid', openid) + console.log('[PayPage] 获取到 openid:', openid) + + // 3. 尝试自动登录(获取 token,mp-prepay 接口需要登录态) + // 如果用户已在小程序注册过,loginByOpenId 会返回 token + // 如果用户未注册,不影响 openid 已获取,但 mp-prepay 可能需要特殊处理 + try { + const loginResult = await loginByOpenId({ + code: loginRes.code, + tenantId: TenantId + }) + console.log('[PayPage] loginByOpenId 结果:', loginResult) + + if (loginResult.success && loginResult.data) { + // 登录成功,保存 token + saveStorageByLoginUser(loginResult.data.access_token || '', loginResult.data.user) + console.log('[PayPage] 自动登录成功,已保存 token') + } else { + console.warn('[PayPage] 自动登录失败:', loginResult.message) + // 未注册用户:openid 已经有了,但 mp-prepay 需要 userId + // 提示用户先注册/登录 + if (loginResult.message?.includes('未注册') || loginResult.message?.includes('不存在')) { + Taro.showModal({ + title: '需要先登录', + content: '支付前需要先注册账号,是否前往登录?', + confirmText: '去登录', + cancelText: '取消' + }).then(modalRes => { + if (modalRes.confirm) { + Taro.navigateTo({ url: '/passport/login' }) + } + }) + return // 不继续支付流程 + } + } + } catch (loginErr) { + console.warn('[PayPage] 自动登录异常:', loginErr) + // 登录异常但不阻断支付,openid 已拿到,尝试继续 + } + + // 4. 请求后端创建 JSAPI 预支付订单 + const prepayRes: any = await request({ + url: `/app/subscription/mp-prepay/${detail.id}`, + method: 'POST', + data: { openid }, + returnRaw: true + }) + + console.log('[PayPage] mp-prepay 完整返回:', JSON.stringify(prepayRes)) + + if (prepayRes?.code !== 0 && prepayRes?.code !== 200) { + throw new Error(prepayRes?.message || '创建支付订单失败') + } + + const payParams: JsapiPayParams = prepayRes.data || prepayRes + + if (!payParams.timeStamp || !payParams.package || !payParams.paySign) { + throw new Error('支付参数不完整') + } + + // 5. 调用微信支付 + await Taro.requestPayment({ + timeStamp: payParams.timeStamp, + nonceStr: payParams.nonceStr, + package: payParams.package, + signType: (payParams.signType || 'RSA') as any, + paySign: payParams.paySign + }) + + // 6. 支付成功后通知后端确认 + try { + await request({ + url: `/app/subscription/mp-confirm/${detail.subscriptionNo}`, + method: 'POST', + data: {} + }) + } catch (confirmErr) { + console.warn('支付确认通知失败,等待轮询:', confirmErr) + } + + // 7. 显示成功状态 + setPaid(true) + Taro.showToast({ title: '支付成功', icon: 'success' }) + + // 8. 延迟自动跳转到已购产品页面 + setTimeout(() => { + Taro.navigateTo({ url: '/user/apps/index' }).catch(() => { + // 如果 navigateTo 失败(比如在 tab 页),回退到用户页 + Taro.switchTab({ url: '/pages/user/user' }) + }) + }, 1500) + + } catch (err: any) { + console.error('[PayPage] 支付失败:', err) + console.error('[PayPage] err.name:', err?.name, 'err.type:', err?.type) + console.error('[PayPage] err.code:', err?.code, 'err.message:', err?.message) + + if (err?.errMsg?.includes('cancel')) { + Taro.showToast({ title: '支付已取消', icon: 'none' }) + } else { + const msg = err?.message || err?.errMsg || '支付失败,请重试' + Taro.showToast({ title: msg, icon: 'error', duration: 2000 }) + } + } finally { + setPaying(false) + } + } + + // 返回 + const handleBack = () => { + Taro.navigateBack({ delta: 1 }).catch(() => { + Taro.switchTab({ url: '/pages/user/user' }) + }) + } + + // 格式化价格描述 + const getPriceDesc = () => { + if (!detail) return '' + if (detail.priceType === 'one_time') return '永久买断' + if (detail.priceType === 'subscription') { + return detail.subscriptionPeriod === 'year' ? '按年订阅' : '按月订阅' + } + return '' + } + + // --- 加载中 --- + if (loading) { + return ( + + + + 加载订单信息... + + + ) + } + + // --- 错误 --- + if (errorMsg) { + return ( + + + + 获取订单失败 + {errorMsg} + + + + ) + } + + // --- 已支付 --- + if (paid) { + return ( + + + + + + 支付成功 + {detail?.productName || '应用订阅'} + {detail?.payPrice ? ( + + 已支付 ¥{Number(detail.payPrice).toFixed(2)} + + ) : null} + + + + ) + } + + // --- 支付确认 --- + return ( + + + {/* 订单信息 */} + + 确认支付 + + + + + + 支付金额 + + + + + + + + + 支付成功后即可在「已购产品」中使用该应用 + + + + + {/* 支付按钮 */} + + + + + + + + + {/* 底部安全距离占位 */} + + + ) +} + +export default PayPage diff --git a/src/passport/register.tsx b/src/passport/register.tsx index 9ecbae9..e42eaaa 100644 --- a/src/passport/register.tsx +++ b/src/passport/register.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from 'react' -import Taro from '@tarojs/taro' +import Taro, { useDidShow } from '@tarojs/taro' import { View, Text } from '@tarojs/components' import { Button, Checkbox } from '@nutui/nutui-react-taro' import { TenantId } from '@/config/app' import { getUserInfo, getWxOpenId } from '@/api/layout' -import { saveStorageByLoginUser } from '@/utils/server' +import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server' import { isUserDisabled } from '@/utils/auth' +import request from '@/utils/request' +import { ensurePrivacyAuthorized } from '@/api/system/file' import { getStoredInviteParams, parseInviteParams, @@ -25,17 +27,6 @@ interface GetPhoneNumberEvent { detail: GetPhoneNumberDetail } -interface LoginResponse { - data: { - code?: number - message?: string - data?: { - access_token: string - user: any - } - } -} - async function getWeappLoginCode(): Promise { try { const res = await new Promise<{ code?: string }>((resolve, reject) => { @@ -110,9 +101,13 @@ const Register = () => { const router = Taro.getCurrentInstance().router - useEffect(() => { - // register 页面不是 tabBar 页面,无需调用 hideTabBar - }, []) + /** 页面显示时预检隐私协议,避免 getPhoneNumber 因未授权隐私协议而失败 */ + useDidShow(() => { + if (!isWeapp) return + ensurePrivacyAuthorized().catch((e) => { + console.warn('注册页隐私协议预检失败:', e) + }) + }) const redirectUrl = useMemo(() => { const raw = (router?.params as any)?.redirect as string | undefined @@ -195,10 +190,9 @@ const Register = () => { // 获取小程序登录 code(用于后续绑定 openid) const wxLoginCode = await getWeappLoginCode() - const res = (await Taro.request({ - url: 'https://shop-api.websoft.top/api/wx-login/loginByMpWxPhone', - method: 'POST', - data: { + const res: any = await request.post( + `${SERVER_API_URL}/wx-login/loginByMpWxPhone`, + { code: phoneCode, encryptedData, iv, @@ -207,19 +201,16 @@ const Register = () => { sceneType: 'save_referee', tenantId: TenantId, }, - header: { - 'content-type': 'application/json', - TenantId, - }, - })) as unknown as LoginResponse + { showError: false } + ) - if ((res as any)?.data?.code === 1) { - Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' }) + if (res?.code === 1) { + Taro.showToast({ title: res.message || '登录失败', icon: 'none' }) return } - const token = res?.data?.data?.access_token - const user = res?.data?.data?.user + const token = res?.data?.access_token + const user = res?.data?.user if (!token || !user?.userId) { Taro.showToast({ title: '登录失败,请重试', icon: 'none' }) return