feat(pay): 新增小程序支付页面及相关隐私协议处理
- 新增支付页面组件,实现JSAPI支付流程和多状态页面展示 - 配置支付页面导航条样式和标题 - app.config.ts中添加支付页面路径 - app.tsx中新增微信隐私协议授权弹窗,拦截敏感API授权流程 - api/system/file中导出ensurePrivacyAuthorized用于隐私授权预检 - 登录页和注册页新增useDidShow钩子调用ensurePrivacyAuthorized预检隐私协议 - 登录和注册接口请求迁移至统一request封装,修正响应解构 - 支付流程中新增获取openid和自动登录逻辑,支持未注册用户跳转登录 - 支付成功后通知后端确认并自动跳转到已购产品页面 - 优化支付状态判断逻辑,正确识别支付和订单状态 - 添加错误处理和用户提示,提升支付体验安全性与稳定性
This commit is contained in:
@@ -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。
|
* 等待用户完成微信隐私协议授权。基础库 3.16.1+ 强制要求,未授权会抛 errno:112。
|
||||||
* 旧基础库或不支持隐私协议的版本直接放行。
|
* 旧基础库或不支持隐私协议的版本直接放行。
|
||||||
*/
|
*/
|
||||||
const ensurePrivacyAuthorized = (): Promise<void> => {
|
export const ensurePrivacyAuthorized = (): Promise<void> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const wxAny: any = Taro
|
const wxAny: any = Taro
|
||||||
if (typeof wxAny.requirePrivacyAuthorize !== 'function') {
|
if (typeof wxAny.requirePrivacyAuthorize !== 'function') {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
'passport/qr-login/index',
|
'passport/qr-login/index',
|
||||||
'passport/qr-confirm/index',
|
'passport/qr-confirm/index',
|
||||||
'passport/unified-qr/index',
|
'passport/unified-qr/index',
|
||||||
|
// 支付页面
|
||||||
|
'passport/pay/index',
|
||||||
// 首页子页面
|
// 首页子页面
|
||||||
'pages/index/search',
|
'pages/index/search',
|
||||||
'pages/index/notification',
|
'pages/index/notification',
|
||||||
|
|||||||
30
src/app.tsx
30
src/app.tsx
@@ -24,12 +24,38 @@ function App(props: AppProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 微信隐私协议(基础库 3.16.1+ 强制)
|
// 微信隐私协议(基础库 3.16.1+ 强制)
|
||||||
// 用户已在登录页底部勾选同意《用户协议》和《隐私政策》,
|
// 当调用 getPhoneNumber / chooseImage 等敏感 API 且用户未同意隐私协议时,
|
||||||
// 此处直接 resolve agree,不再弹自定义授权窗口。
|
// 微信会触发此回调。我们展示自定义弹窗,用户点击同意后再 resolve,
|
||||||
|
// 否则微信会拒绝后续敏感 API 调用。
|
||||||
const wxAny: any = Taro
|
const wxAny: any = Taro
|
||||||
if (typeof wxAny.onNeedPrivacyAuthorization === 'function') {
|
if (typeof wxAny.onNeedPrivacyAuthorization === 'function') {
|
||||||
wxAny.onNeedPrivacyAuthorization((resolve: any) => {
|
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' })
|
resolve({ event: 'agree', button: 'agree' })
|
||||||
|
} else {
|
||||||
|
resolve({ event: 'disagree', button: 'disagree' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
resolve({ event: 'disagree', button: 'disagree' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
// 获取设置失败时,默认 resolve 同意,避免流程阻塞
|
||||||
|
resolve({ event: 'agree', button: 'agree' })
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { View, Image, Text, Button } from '@tarojs/components'
|
||||||
import { TenantId } from '@/config/app'
|
import { TenantId } from '@/config/app'
|
||||||
import { getWxOpenId } from '@/api/layout'
|
import { getWxOpenId } from '@/api/layout'
|
||||||
import { getUserInfo } 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 { isUserDisabled } from '@/utils/auth'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { ensurePrivacyAuthorized } from '@/api/system/file'
|
||||||
import {
|
import {
|
||||||
checkAndHandleInviteRelation,
|
checkAndHandleInviteRelation,
|
||||||
hasPendingInvite,
|
hasPendingInvite,
|
||||||
@@ -72,22 +74,21 @@ async function ensureWxOpenIdSaved() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取用户信息 */
|
/** 获取用户信息(使用临时 token,避免本地 storage 尚未写入) */
|
||||||
async function fetchUserInfo(token: string) {
|
async function fetchUserInfo(token: string) {
|
||||||
try {
|
try {
|
||||||
const serverUrl = 'https://server.websoft.top'
|
const res: any = await request.get(
|
||||||
const res: any = await Taro.request({
|
`${SERVER_API_URL}/auth/user`,
|
||||||
url: `${serverUrl}/api/auth/user`,
|
{},
|
||||||
method: 'GET',
|
{
|
||||||
header: {
|
header: {
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'content-type': 'application/json',
|
|
||||||
TenantId: String(TenantId),
|
|
||||||
},
|
},
|
||||||
})
|
showError: false,
|
||||||
|
}
|
||||||
if (res.data?.code === 0 && res.data?.data) {
|
)
|
||||||
return res.data.data
|
if (res?.code === 0 && res?.data) {
|
||||||
|
return res.data
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -109,6 +110,14 @@ const Login = () => {
|
|||||||
setTimeout(() => setShowContent(true), 100)
|
setTimeout(() => setShowContent(true), 100)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/** 页面显示时预检隐私协议,避免 getPhoneNumber 因未授权隐私协议而失败 */
|
||||||
|
useDidShow(() => {
|
||||||
|
if (!isWeapp) return
|
||||||
|
ensurePrivacyAuthorized().catch((e) => {
|
||||||
|
console.warn('登录页隐私协议预检失败:', e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
/** 解析 redirect 参数 */
|
/** 解析 redirect 参数 */
|
||||||
const redirectUrl = (() => {
|
const redirectUrl = (() => {
|
||||||
const raw = (router?.params as Record<string, string> | undefined)?.redirect
|
const raw = (router?.params as Record<string, string> | undefined)?.redirect
|
||||||
@@ -215,23 +224,21 @@ const Login = () => {
|
|||||||
const inviteParams = parseInviteParams({ query: router?.params })
|
const inviteParams = parseInviteParams({ query: router?.params })
|
||||||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||||||
|
|
||||||
const serverUrl = 'https://server.websoft.top'
|
const res: any = await request.post(
|
||||||
const res: any = await Taro.request({
|
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||||
url: `${serverUrl}/api/wx-login/loginByMpWxPhone`,
|
{
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
code: phoneCode,
|
code: phoneCode,
|
||||||
notVerifyPhone: true,
|
notVerifyPhone: true,
|
||||||
refereeId,
|
refereeId,
|
||||||
sceneType: 'save_referee',
|
sceneType: 'save_referee',
|
||||||
tenantId: Number(TenantId),
|
tenantId: Number(TenantId),
|
||||||
},
|
},
|
||||||
header: { 'content-type': 'application/json', TenantId: String(TenantId) },
|
{ showError: false }
|
||||||
})
|
)
|
||||||
|
|
||||||
if (res.data?.code === 0 && res.data?.data?.access_token) {
|
if (res?.code === 0 && res?.data?.access_token) {
|
||||||
const token = res.data.data.access_token
|
const token = res.data.access_token
|
||||||
let user = res.data.data.user
|
let user = res.data.user
|
||||||
|
|
||||||
// 获取最新的用户信息
|
// 获取最新的用户信息
|
||||||
const freshUserInfo = await fetchUserInfo(token)
|
const freshUserInfo = await fetchUserInfo(token)
|
||||||
@@ -262,7 +269,7 @@ const Login = () => {
|
|||||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||||
setTimeout(() => navigateAfterLogin(), 800)
|
setTimeout(() => navigateAfterLogin(), 800)
|
||||||
} else {
|
} else {
|
||||||
Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' })
|
Taro.showToast({ title: res?.message || '登录失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('微信登录失败:', e)
|
console.error('微信登录失败:', e)
|
||||||
|
|||||||
5
src/passport/pay/index.config.ts
Normal file
5
src/passport/pay/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '确认支付',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
navigationBarBackgroundColor: '#ffffff'
|
||||||
|
}
|
||||||
400
src/passport/pay/index.tsx
Normal file
400
src/passport/pay/index.tsx
Normal file
@@ -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<SubscriptionDetail | null>(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 (
|
||||||
|
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<View className="text-center">
|
||||||
|
<Clock className="text-blue-500 mb-4" size="48" />
|
||||||
|
<Text className="block text-gray-500">加载订单信息...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 错误 ---
|
||||||
|
if (errorMsg) {
|
||||||
|
return (
|
||||||
|
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<View className="text-center p-8">
|
||||||
|
<Close className="text-red-500 mb-4" size="48" />
|
||||||
|
<Text className="block text-gray-800 text-lg mb-2">获取订单失败</Text>
|
||||||
|
<Text className="block text-gray-500 mb-6">{errorMsg}</Text>
|
||||||
|
<Button type="primary" onClick={handleBack}>返回</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 已支付 ---
|
||||||
|
if (paid) {
|
||||||
|
return (
|
||||||
|
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<View className="text-center p-8">
|
||||||
|
<View className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Check className="text-green-500" size="40" />
|
||||||
|
</View>
|
||||||
|
<Text className="block text-green-600 text-xl font-bold mb-2">支付成功</Text>
|
||||||
|
<Text className="block text-gray-500 mb-2">{detail?.productName || '应用订阅'}</Text>
|
||||||
|
{detail?.payPrice ? (
|
||||||
|
<Text className="block text-gray-400 mb-6">
|
||||||
|
已支付 ¥{Number(detail.payPrice).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" onClick={handleBack}>完成</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 支付确认 ---
|
||||||
|
return (
|
||||||
|
<View className="pay-page min-h-screen bg-gray-50">
|
||||||
|
<View className="p-4">
|
||||||
|
{/* 订单信息 */}
|
||||||
|
<View className="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||||
|
<Text className="block text-lg font-bold text-gray-800 mb-3">确认支付</Text>
|
||||||
|
|
||||||
|
<Cell title="商品名称" description={detail?.productName || '-'} />
|
||||||
|
<Cell title="价格类型" description={getPriceDesc()} />
|
||||||
|
|
||||||
|
<View className="flex items-center justify-between px-4 py-3">
|
||||||
|
<Text className="text-gray-600">支付金额</Text>
|
||||||
|
<Price
|
||||||
|
price={detail?.payPrice}
|
||||||
|
size="large"
|
||||||
|
thousands
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<View className="flex items-start px-4 py-2">
|
||||||
|
<Tips className="text-orange-500 mr-2 mt-1" size="16" />
|
||||||
|
<Text className="text-sm text-gray-500">
|
||||||
|
支付成功后即可在「已购产品」中使用该应用
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 支付按钮 */}
|
||||||
|
<View className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-100">
|
||||||
|
<View className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
loading={paying}
|
||||||
|
onClick={handlePay}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{paying ? '支付中...' : '立即支付'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部安全距离占位 */}
|
||||||
|
<View style={{ height: '100px' }} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PayPage
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { View, Text } from '@tarojs/components'
|
import { View, Text } from '@tarojs/components'
|
||||||
import { Button, Checkbox } from '@nutui/nutui-react-taro'
|
import { Button, Checkbox } from '@nutui/nutui-react-taro'
|
||||||
import { TenantId } from '@/config/app'
|
import { TenantId } from '@/config/app'
|
||||||
import { getUserInfo, getWxOpenId } from '@/api/layout'
|
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 { isUserDisabled } from '@/utils/auth'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { ensurePrivacyAuthorized } from '@/api/system/file'
|
||||||
import {
|
import {
|
||||||
getStoredInviteParams,
|
getStoredInviteParams,
|
||||||
parseInviteParams,
|
parseInviteParams,
|
||||||
@@ -25,17 +27,6 @@ interface GetPhoneNumberEvent {
|
|||||||
detail: GetPhoneNumberDetail
|
detail: GetPhoneNumberDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
|
||||||
data: {
|
|
||||||
code?: number
|
|
||||||
message?: string
|
|
||||||
data?: {
|
|
||||||
access_token: string
|
|
||||||
user: any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWeappLoginCode(): Promise<string | undefined> {
|
async function getWeappLoginCode(): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await new Promise<{ code?: string }>((resolve, reject) => {
|
const res = await new Promise<{ code?: string }>((resolve, reject) => {
|
||||||
@@ -110,9 +101,13 @@ const Register = () => {
|
|||||||
|
|
||||||
const router = Taro.getCurrentInstance().router
|
const router = Taro.getCurrentInstance().router
|
||||||
|
|
||||||
useEffect(() => {
|
/** 页面显示时预检隐私协议,避免 getPhoneNumber 因未授权隐私协议而失败 */
|
||||||
// register 页面不是 tabBar 页面,无需调用 hideTabBar
|
useDidShow(() => {
|
||||||
}, [])
|
if (!isWeapp) return
|
||||||
|
ensurePrivacyAuthorized().catch((e) => {
|
||||||
|
console.warn('注册页隐私协议预检失败:', e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const redirectUrl = useMemo(() => {
|
const redirectUrl = useMemo(() => {
|
||||||
const raw = (router?.params as any)?.redirect as string | undefined
|
const raw = (router?.params as any)?.redirect as string | undefined
|
||||||
@@ -195,10 +190,9 @@ const Register = () => {
|
|||||||
// 获取小程序登录 code(用于后续绑定 openid)
|
// 获取小程序登录 code(用于后续绑定 openid)
|
||||||
const wxLoginCode = await getWeappLoginCode()
|
const wxLoginCode = await getWeappLoginCode()
|
||||||
|
|
||||||
const res = (await Taro.request({
|
const res: any = await request.post(
|
||||||
url: 'https://shop-api.websoft.top/api/wx-login/loginByMpWxPhone',
|
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||||
method: 'POST',
|
{
|
||||||
data: {
|
|
||||||
code: phoneCode,
|
code: phoneCode,
|
||||||
encryptedData,
|
encryptedData,
|
||||||
iv,
|
iv,
|
||||||
@@ -207,19 +201,16 @@ const Register = () => {
|
|||||||
sceneType: 'save_referee',
|
sceneType: 'save_referee',
|
||||||
tenantId: TenantId,
|
tenantId: TenantId,
|
||||||
},
|
},
|
||||||
header: {
|
{ showError: false }
|
||||||
'content-type': 'application/json',
|
)
|
||||||
TenantId,
|
|
||||||
},
|
|
||||||
})) as unknown as LoginResponse
|
|
||||||
|
|
||||||
if ((res as any)?.data?.code === 1) {
|
if (res?.code === 1) {
|
||||||
Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' })
|
Taro.showToast({ title: res.message || '登录失败', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = res?.data?.data?.access_token
|
const token = res?.data?.access_token
|
||||||
const user = res?.data?.data?.user
|
const user = res?.data?.user
|
||||||
if (!token || !user?.userId) {
|
if (!token || !user?.userId) {
|
||||||
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
|
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user