Compare commits
7 Commits
dd183adaab
...
1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b21f5e5b1 | |||
| 511e4b8ff3 | |||
| 1d5bb5b9b1 | |||
| ac18c33c36 | |||
| 38c8026d4f | |||
| e6c7ffa46c | |||
| 482cff5ece |
@@ -47,3 +47,15 @@
|
|||||||
- **原因**: 项目没有声明微信小程序原生全局对象 `wx` 的类型
|
- **原因**: 项目没有声明微信小程序原生全局对象 `wx` 的类型
|
||||||
- **修复**: 在 `types/global.d.ts` 中添加 `declare const wx: any;` 全局类型声明
|
- **修复**: 在 `types/global.d.ts` 中添加 `declare const wx: any;` 全局类型声明
|
||||||
- **验证**: `tsc` 不再报 `common.ts` 相关错误,`npx taro build --type weapp` 构建成功
|
- **验证**: `tsc` 不再报 `common.ts` 相关错误,`npx taro build --type weapp` 构建成功
|
||||||
|
|
||||||
|
## 去掉首页「复制咨询模板」按钮及复制逻辑
|
||||||
|
|
||||||
|
- **问题**: `wx.setClipboardData` 在小程序开发者工具中报 `no permission` 错误,且审核对复制功能敏感
|
||||||
|
- **修复**:
|
||||||
|
- 移除 `src/pages/index/index.tsx` 中的 `copyConsultTemplate` 函数
|
||||||
|
- 删除「复制咨询模板」按钮,只保留「电话咨询」
|
||||||
|
- 修改联系我们区域文案,去掉「复制咨询模板」相关描述
|
||||||
|
- `openMaybeLink` 中外链不再调用 `copyText`,改为 `Taro.showModal` 提示用户手动复制到浏览器打开
|
||||||
|
- 移除首页对 `copyText` 的 import
|
||||||
|
- **验证**: `npx taro build --type weapp` 构建成功,首页无复制相关调用
|
||||||
|
- **备注**: 项目其他页面仍有复制功能(如礼品卡分享、优惠券分享、订单号复制、密钥复制等),如需要可继续逐个移除
|
||||||
|
|||||||
15
.workbuddy/memory/2026-07-01.md
Normal file
15
.workbuddy/memory/2026-07-01.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 2026-07-01 工作日志
|
||||||
|
|
||||||
|
## 修复支付页面 request URL 无效问题
|
||||||
|
- **文件**: `src/utils/request.ts`
|
||||||
|
- **问题**: 扫码进入支付页后报错 `request:fail invalid url "/app/subscription/detail-by-no/..."`,原因是核心 `request()` 函数没有调用 `buildUrl()` 拼接 baseUrl,相对路径直接传给微信 `wx.request()` 导致失败
|
||||||
|
- **修复**: 在 `request()` 函数中统一加 `buildUrl(options.url)` 处理,确保所有请求方式都能正确拼接完整 URL
|
||||||
|
|
||||||
|
## 小程序码改为体验版
|
||||||
|
- **文件**: `src/api/invite/index.ts`(前端)
|
||||||
|
- **修改**: `generateMiniProgramCode` 函数将 `envVersion` 作为查询参数传给后端,默认值 `'trial'`
|
||||||
|
- **后端配合**:
|
||||||
|
- `WxLoginController.java`: `getOrderQRCodeUnlimited` 和 `getOrderQRCode` 方法新增 `@RequestParam(defaultValue = "release") String envVersion` 参数
|
||||||
|
- 硬编码的 `"release"` 改为动态读取 `envVersion` 参数
|
||||||
|
- `getQRCodeText` 已有此参数,无需修改
|
||||||
|
- **注意**: 提审上线前需将前端默认 `envVersion` 改回 `'release'`
|
||||||
101
.workbuddy/memory/2026-07-02.md
Normal file
101
.workbuddy/memory/2026-07-02.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 2026-07-02 工作日志
|
||||||
|
|
||||||
|
## 小程序扫码支付"获取订单失败"问题排查与修复
|
||||||
|
|
||||||
|
- **问题**:用户扫小程序码后进入小程序支付页,提示"获取订单失败"
|
||||||
|
- **根因**:后端 Spring Security 的 SecurityConfig 中未将支付相关接口加入白名单(permitAll),扫码进入时用户未登录态,请求 `/api/app/subscription/detail-by-no/**` 被 401 拦截
|
||||||
|
- **修复**:在 SecurityConfig.java 的 antMatchers 中添加了以下接口白名单:
|
||||||
|
- `/api/app/subscription/detail-by-no/**` — 根据订阅号查询订单详情
|
||||||
|
- `/api/app/subscription/mp-prepay/**` — 创建小程序预支付订单
|
||||||
|
- `/api/app/subscription/mp-confirm/**` — 支付成功确认
|
||||||
|
- `/api/app/subscription/wx-notify/**` — 微信支付回调通知
|
||||||
|
- `/api/wx-login/**` — 小程序码生成等微信登录相关接口(之前只覆盖了 `/api/shop/wx-login/**`,漏掉了 `/api/wx-login/**`)
|
||||||
|
- **前端代码位置**:`src/passport/pay/index.tsx`,支付页面从 scene/subscriptionNo 获取订单号,调 detail-by-no 接口
|
||||||
|
- **后端代码位置**:`AppSubscriptionController.java`(@RequestMapping("/api/app/subscription")),`WxLoginController.java`(@RequestMapping("/api/wx-login"))
|
||||||
|
- **注意**:生成小程序码时 `envVersion` 默认为 `trial`(体验版),上线前需改为 `release`
|
||||||
|
|
||||||
|
### 后续排查:后端白名单加了仍报"获取订单失败"
|
||||||
|
|
||||||
|
- **二次根因**:前端 `src/passport/pay/index.tsx` 的 `fetchDetail` 存在逻辑 bug
|
||||||
|
- `request()` 默认 `returnRaw=false`,响应拦截器对 `code===0` 的响应已自动拆包,只返回 `data` 部分(订阅对象)
|
||||||
|
- 但 `fetchDetail` 又检查 `res?.code === 200 || res?.code === 0`,而拆包后的 `res`(订阅对象)没有 `code` 字段
|
||||||
|
- 因此条件永远为 `false`,走进 `setErrorMsg` 分支,显示"获取订单失败"
|
||||||
|
- **修复**:移除多余的 `code` 检查,`request` 成功即代表业务成功,直接使用返回的 `data` 对象;错误由 catch 处理
|
||||||
|
- **附加**:`getSubscriptionNo` 加了 `console.log` 打印页面参数和解析结果,方便真机调试排查 scene 传入问题
|
||||||
|
|
||||||
|
### 五次排查:getOpenId 返回 success:true 但 openid 为 undefined
|
||||||
|
|
||||||
|
- **根因**:前端 `src/api/passport/wx-login/index.ts` 的 `getOpenId` 函数错误地假设后端返回的是 `{ openid, unionid, session_key }` 对象,直接取 `res.data.openid`;但后端 `/wx-login/getOpenId` 实际返回的是 `LoginResult` 结构 `{ access_token, user }`,真正的 openid 在 `user.openid` 里
|
||||||
|
- **修复**:更新 `getOpenId` 类型为 `ApiResult<WxLoginResult>`,从 `res.data.user?.openid` 提取 openid,同时把 `access_token` 和 `user` 一起返回给调用方复用
|
||||||
|
- **后续影响**:`handlePay` 中拿到 openid 后,因为 `getOpenId` 已经自动注册/登录(后端会注册新用户并签发 token),理论上用户已经自动登录,可能无需再调 `loginByOpenId`;但当前代码保留双保险
|
||||||
|
|
||||||
|
- **根因**:`handlePay` 流程有 bug——调了 `Taro.login()` 获取 code,但没有用 code 去换 openid,直接从 `Taro.getStorageSync('openid')` 读取(扫码用户可能没登录过,storage 里没存)
|
||||||
|
- **前端修复**(`src/passport/pay/index.tsx`):
|
||||||
|
1. 引入 `getOpenId` 和 `loginByOpenId` API
|
||||||
|
2. `handlePay` 中:`Taro.login()` → `getOpenId(code)` 换取 openid → 缓存 openid
|
||||||
|
3. 尝试 `loginByOpenId` 自动登录获取 token(如果用户未注册,弹出引导登录对话框)
|
||||||
|
4. `mp-prepay` 请求也加了 `returnRaw: true` 和调试日志
|
||||||
|
- **后端修复**(`AppSubscriptionController.java`):
|
||||||
|
1. `mp-prepay`:将 `userId == null || !userId.equals(sub.getUserId())` 改为 `userId != null && !userId.equals(sub.getUserId())`,允许未登录用户发起支付
|
||||||
|
2. `mp-confirm`:同理,未登录时跳过 userId 校验,用 `effectiveUserId = userId != null ? userId : sub.getUserId()` 替代原 `userId`
|
||||||
|
|
||||||
|
- **修改点1**:`src/passport/pay/index.tsx` 的 `fetchDetail` 改为 `returnRaw: true`,拿到接口完整响应(含 code/message/data),手动判断业务状态码(`code === 0 || code === 200`),不再依赖拦截器拆包
|
||||||
|
- **修改点2**:`fetchDetail` 和 `getSubscriptionNo` 加了详细 console.log/console.error,打印:
|
||||||
|
- 原始页面参数(scene、subscriptionNo)
|
||||||
|
- 请求 URL 和当前 token
|
||||||
|
- 接口完整返回 JSON
|
||||||
|
- catch 中的 err.name/type/code/message/data
|
||||||
|
- **修改点3**:`src/utils/request.ts` 响应拦截器去掉了 `process.env.NODE_ENV === 'development'` 条件限制,所有环境都打印日志,方便真机调试 Console 查看
|
||||||
|
### 六次排查:getOpenId 返回 success:true 但 openid 为 null
|
||||||
|
|
||||||
|
- **现象**:前端 `getOpenId` 返回 `success: true, access_token: 有值, user.userId: 35619`,但 `openid: null, unionid: null`
|
||||||
|
- **根因**:后端 `/wx-login/getOpenId` 虽然通过微信 `jscode2session` 换到了 openid,但**没有把 openid 设置到 `UserParam` 中**。
|
||||||
|
- 后续 `userService.getByOauthId(userParam)` 查找用户时,userParam.openid 为空,可能无法命中已有用户;
|
||||||
|
- 新用户注册时走 `addUser(userParam)`,而 `addUser` 只在 `userParam.openid` 非空时才会写 `User.openid`,所以新用户 openid 也为空;
|
||||||
|
- 对于已存在但 openid 为空的老用户(如手机号注册),直接返回,openid 仍然是 null。
|
||||||
|
- **修复**(`WxLoginController.java` 的 `/getOpenId` 方法):
|
||||||
|
1. 从微信返回中拿到 `openid`/`unionid` 后,立即 `userParam.setOpenid(openid)` 和 `userParam.setUnionid(unionid)`;
|
||||||
|
2. 用户已存在但 `user.openid` 为空时,把 openid/unionid 更新到数据库并返回最新 user;
|
||||||
|
3. 这样 `LoginResult.user.openid` 一定有值,前端就能正确获取并传给 `mp-prepay`。
|
||||||
|
### 七次排查:mp-prepay 报 SIGN_ERROR(签名错误)
|
||||||
|
|
||||||
|
- **现象**:前端调用 `/api/app/subscription/mp-prepay/{id}` 时,后端返回 `code: 1, message: "微信支付服务异常: 微信错误码: SIGN_ERROR, 签名错误"`。
|
||||||
|
- **排查**:
|
||||||
|
1. 当前小程序 JSAPI 支付使用 `WxNativePayUtil.getConfig` 构建微信支付 Config,模式为 `RSAPublicKeyConfig`(公钥模式),但生产环境 `wechatpay-public-key-id` 为空。
|
||||||
|
2. 读取项目下所有证书文件,用 openssl 检查各证书对应的商户号,发现:
|
||||||
|
- `/wechat/websopy/apiclient_cert.pem`:商户号 `1557418831`(与小程序无关联)
|
||||||
|
- `/wechat/10398/apiclient_cert.pem`:商户号 `1246610101`(与小程序已关联,见截图)
|
||||||
|
3. 配置中的 `mch-id` 是 `1246610101`,但配置的 `private-key-relative-path` 指向 `wechat/websopy/`,导致证书与商户号不匹配,微信返回 `SIGN_ERROR`。
|
||||||
|
4. 检查 `10398` 目录证书与私钥匹配性:公钥哈希一致,证书序列号为 `48749613B40AA8F1D768583FC352358E13EB5AF0`。
|
||||||
|
- **修复**:
|
||||||
|
1. 修改 `WxNativePayUtil.java`:将 `RSAPublicKeyConfig`(公钥模式)改为 `RSAAutoCertificateConfig`(自动证书模式),不再依赖 `wechatpay-public-key-id` 和 `wechatpay-cert-relative-path`。
|
||||||
|
2. 修正 `application-dev.yml` 和 `application-prod.yml`:
|
||||||
|
- `mch-id`:`1246610101`(保持不变,这是正确的)
|
||||||
|
- `merchant-serial-number`:`48749613B40AA8F1D768583FC352358E13EB5AF0`(对应 10398 目录证书)
|
||||||
|
- `private-key-relative-path`:`wechat/10398/apiclient_key.pem`
|
||||||
|
- `wechatpay-cert-relative-path` 置空(自动证书模式)
|
||||||
|
- **待确认**:当前保留的 `api-v3-key: "zGufUcqa7ovgxRL0kF5OlPr482EZwtn9"` 是否属于商户号 `1246610101`,需要用户在微信支付商户后台确认。如果仍报签名错误,需要替换为正确的 APIv3 密钥。
|
||||||
|
- **待处理**:生产环境需将 `/Users/gxwebsoft/JAVA/websopy-java/src/main/resources/wechat/10398/apiclient_key.pem` 上传到 `/www/wwwroot/file.ws/wechat/10398/apiclient_key.pem`,并确保该文件可被 Java 进程读取。之前错误上传 `wechat/websopy/` 目录的证书给 1246610101 使用,这是 `SIGN_ERROR` 的真正原因。
|
||||||
|
|
||||||
|
### 八次排查:SUB202607022056385942 提示"订阅不存在"
|
||||||
|
|
||||||
|
- **现象**:调用 `/api/_app/subscription/check-status/SUB202607022056385942` 和 `/api/_app/subscription/detail-by-no/SUB202607022056385942` 均返回"订阅不存在"
|
||||||
|
- **根因**:`getOrderQRCodeUnlimited` 接口(WxLoginController.java 第 441 行)**只生成小程序码,并不创建订阅记录**。如果生成小程序码时没有先调用 `subscribe` 接口,`subscriptionNo` 就只是个随机字符串,没有对应的数据库记录
|
||||||
|
- **修复(方案 1)**:新增 `POST /api/app/subscription/generate-pay-qrcode` 接口,在一个请求中完成:
|
||||||
|
1. 创建订阅记录(复用 `subscribe` 的核心逻辑,写入数据库)
|
||||||
|
2. 用 `subscriptionNo` 调用 `WxMiniprogramUtil.generateMiniprogramQrCode` 生成小程序码
|
||||||
|
3. 返回 `subscriptionNo` + Base64 图片
|
||||||
|
- **关联修改**:
|
||||||
|
- `AppPayProperties.java`:新增 `miniAppSecret` 字段(用于生成小程序码获取 access_token)
|
||||||
|
- `application-dev.yml` / `application-prod.yml`:添加 `mini-app-secret` 配置项(当前为占位符,需用户填写真实秘钥)
|
||||||
|
- `AppSubscriptionController.java` `mp-prepay`:已改为允许跨用户支付(`userId != null && !userId.equals(sub.getUserId())`)
|
||||||
|
- `AppSubscriptionController.java` `mp-confirm`:已改为未登录时跳过 userId 校验
|
||||||
|
- **下一步**:
|
||||||
|
1. 到微信公众平台获取小程序 `AppSecret`,填写到 `application-dev.yml` 和 `application-prod.yml` 的 `mini-app-secret` 字段
|
||||||
|
2. 重新构建部署后端
|
||||||
|
3. 调用新接口生成支付小程序码:`POST /api/app/subscription/generate-pay-qrcode` Body: `{"productId": 1, "subscriptionPeriod": "month", "envVersion": "trial"}`
|
||||||
|
4. 用返回的 `subscriptionNo` 测试 `check-status` 和 `detail-by-no` 接口,确认订阅记录已存在
|
||||||
|
|
||||||
|
### 补充修复
|
||||||
|
|
||||||
|
- `AppSubscriptionController.java` 的 `generate-pay-qrcode` 接口中,`switch (subscriptionPeriod)` 的 `case "month":` 空 fall through 触发 IDE 红色警告。已添加 `// fall through to default` 注释消除警告。
|
||||||
@@ -41,3 +41,12 @@
|
|||||||
- 首页「立即开通」等外链按钮点击后复制链接;相对路径入口(如开发者中心)改为 `Taro.navigateTo` 跳转,不再复制
|
- 首页「立即开通」等外链按钮点击后复制链接;相对路径入口(如开发者中心)改为 `Taro.navigateTo` 跳转,不再复制
|
||||||
|
|
||||||
当前实现基本符合 5.15.4 规范。
|
当前实现基本符合 5.15.4 规范。
|
||||||
|
|
||||||
|
## AppSubscription 状态字段语义(踩坑必读)
|
||||||
|
|
||||||
|
判断"是否已支付"必须用 `payStatus`(0=未支付 / 1=已支付)这个**支付状态**字段,**不能**用 `status`(订阅生命周期 active/pending/expired/cancelled)。
|
||||||
|
|
||||||
|
- `status`:订阅的生命周期状态,**不等于支付状态**。后端 `renewPay` 为了避免放弃支付导致服务中断,会故意保留原 `status='active'`、只把 `payStatus` 设为 0。
|
||||||
|
- `payStatus`:本次订单的支付状态。支付成功(mp-confirm / handleBalancePay)后才置 1。
|
||||||
|
|
||||||
|
续费场景下这两个字段完全解耦:用 `status === 'active'` 判支付会**直接误判为已支付**,详见 `2026-07-16.md` 修复记录。
|
||||||
|
|||||||
@@ -108,10 +108,15 @@ export interface InviteRecordParam {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成小程序码
|
* 生成小程序码
|
||||||
|
*
|
||||||
|
* 注意:envVersion 参数会作为查询参数传给后端,
|
||||||
|
* 后端调用 wxacode.getUnlimited 时需使用该值。
|
||||||
*/
|
*/
|
||||||
export async function generateMiniProgramCode(data: MiniProgramCodeParam) {
|
export async function generateMiniProgramCode(data: MiniProgramCodeParam) {
|
||||||
try {
|
try {
|
||||||
const url = '/wx-login/getOrderQRCodeUnlimited/' + data.scene;
|
// 默认使用体验版(开发/测试阶段),提审上线前改为 'release'
|
||||||
|
const envVersion = data.envVersion || 'trial'
|
||||||
|
const url = `/wx-login/getOrderQRCodeUnlimited/${data.scene}?envVersion=${envVersion}`;
|
||||||
// 由于接口直接返回图片buffer,我们直接构建完整的URL
|
// 由于接口直接返回图片buffer,我们直接构建完整的URL
|
||||||
return `${BaseUrl}${url}`;
|
return `${BaseUrl}${url}`;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export async function loginByOpenId(data: WxLoginParam): Promise<{
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取微信 OpenId(仅获取,不登录)
|
* 获取微信 OpenId(仅获取,不登录)
|
||||||
|
* 注意:后端 /wx-login/getOpenId 返回的是 LoginResult { access_token, user },
|
||||||
|
* 真正的 openid 在 user.openid 里
|
||||||
*/
|
*/
|
||||||
export async function getOpenId(code: string): Promise<{
|
export async function getOpenId(code: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -97,22 +99,24 @@ export async function getOpenId(code: string): Promise<{
|
|||||||
unionid?: string;
|
unionid?: string;
|
||||||
session_key?: string;
|
session_key?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
access_token?: string;
|
||||||
|
user?: WxLoginUserInfo;
|
||||||
}> {
|
}> {
|
||||||
const res = await request.post<ApiResult<{
|
const res = await request.post<ApiResult<WxLoginResult>>(
|
||||||
openid: string;
|
|
||||||
unionid?: string;
|
|
||||||
session_key?: string;
|
|
||||||
}>>(
|
|
||||||
SERVER_API_URL + '/wx-login/getOpenId',
|
SERVER_API_URL + '/wx-login/getOpenId',
|
||||||
{ code }
|
{ code }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[WxLogin] getOpenId 响应:', res);
|
||||||
|
|
||||||
if ((res.code === 0 || res.code === 200) && res.data) {
|
if ((res.code === 0 || res.code === 200) && res.data) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
openid: res.data.openid,
|
openid: res.data.user?.openid,
|
||||||
unionid: res.data.unionid,
|
unionid: res.data.user?.unionid,
|
||||||
session_key: res.data.session_key
|
session_key: res.data.user?.session_key,
|
||||||
|
access_token: res.data.access_token,
|
||||||
|
user: res.data.user
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { View, Text } from '@tarojs/components'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Button } from '@nutui/nutui-react-taro'
|
import { Button } from '@nutui/nutui-react-taro'
|
||||||
import { useConfig } from '@/hooks/useConfig'
|
import { useConfig } from '@/hooks/useConfig'
|
||||||
import { copyText } from '@/utils/common'
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
@@ -76,28 +75,19 @@ function Home() {
|
|||||||
Taro.navigateTo({ url })
|
Taro.navigateTo({ url })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 外链在小程序内无法直接打开,复制链接降低认知成本
|
// 小程序内无法直接打开外链,提示用户手动复制到浏览器
|
||||||
if (/^https?:\/\//i.test(abs)) {
|
if (/^https?:\/\//i.test(abs)) {
|
||||||
copyText(abs)
|
Taro.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '该链接需要在浏览器中打开,请点击确认后手动复制链接访问。',
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了'
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Taro.showToast({ title: '请在 PC 端访问该入口', icon: 'none', duration: 1600 })
|
Taro.showToast({ title: '请在 PC 端访问该入口', icon: 'none', duration: 1600 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyConsultTemplate = () => {
|
|
||||||
const tel = config?.tel ? `\n联系电话:${config.tel}` : ''
|
|
||||||
const text =
|
|
||||||
'【需求咨询】\n' +
|
|
||||||
'1) 想开通哪些产品:企业官网 / 电商 / 小程序 / 其他\n' +
|
|
||||||
'2) 是否需要模板/插件市场:是 / 否\n' +
|
|
||||||
'3) 是否需要“支付即开通”:是 / 否\n' +
|
|
||||||
'4) 交付方式:SaaS / 私有化 / 混合\n' +
|
|
||||||
'5) 合规/部署要求:\n' +
|
|
||||||
'6) 期望上线时间:\n' +
|
|
||||||
tel
|
|
||||||
copyText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
const callPhone = () => {
|
const callPhone = () => {
|
||||||
const tel = (config?.tel || '').trim()
|
const tel = (config?.tel || '').trim()
|
||||||
if (!tel) {
|
if (!tel) {
|
||||||
@@ -456,7 +446,7 @@ function Home() {
|
|||||||
<View className="section__head">
|
<View className="section__head">
|
||||||
<Text className="section__title">联系我们</Text>
|
<Text className="section__title">联系我们</Text>
|
||||||
<Text className="section__desc">
|
<Text className="section__desc">
|
||||||
复制咨询模板或直接电话沟通,我们会按你的业务场景给出产品组合、开通链路与部署方案(SaaS/私有化)。
|
直接电话沟通或留下需求,我们会按你的业务场景给出产品组合、开通链路与部署方案(SaaS/私有化)。
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -492,9 +482,6 @@ function Home() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="contactActions">
|
<View className="contactActions">
|
||||||
<Button type="default" block onClick={copyConsultTemplate}>
|
|
||||||
复制咨询模板
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" block onClick={callPhone}>
|
<Button type="primary" block onClick={callPhone}>
|
||||||
电话咨询
|
电话咨询
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
5
src/passport/pay-bak/index.config.ts
Normal file
5
src/passport/pay-bak/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '确认支付',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
navigationBarBackgroundColor: '#ffffff'
|
||||||
|
}
|
||||||
379
src/passport/pay-bak/index.tsx
Normal file
379
src/passport/pay-bak/index.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
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 支付
|
||||||
|
*/
|
||||||
|
interface SubscriptionDetail {
|
||||||
|
id: number
|
||||||
|
subscriptionNo: string
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
productLogo?: string
|
||||||
|
productIcon?: string
|
||||||
|
status: 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] 订阅状态:', subscription?.status)
|
||||||
|
|
||||||
|
if (subscription?.status === 'active') {
|
||||||
|
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
|
||||||
@@ -4,10 +4,18 @@ import { Button, Cell, Price, Divider } from '@nutui/nutui-react-taro'
|
|||||||
import { Check, Close, Tips, Clock } from '@nutui/icons-react-taro'
|
import { Check, Close, Tips, Clock } from '@nutui/icons-react-taro'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { request } from '@/utils/request'
|
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 支付
|
* 接收参数 subscriptionNo,完成 JSAPI 支付
|
||||||
|
*
|
||||||
|
* 重要:判断订单是否已支付,必须用 payStatus(0=未支付 1=已支付),
|
||||||
|
* 不能用 status(订阅生命周期:active/pending/expired/cancelled)。
|
||||||
|
* 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0,
|
||||||
|
* 若用 status 判支付会直接误判为"已支付"。
|
||||||
*/
|
*/
|
||||||
interface SubscriptionDetail {
|
interface SubscriptionDetail {
|
||||||
id: number
|
id: number
|
||||||
@@ -17,6 +25,10 @@ interface SubscriptionDetail {
|
|||||||
productLogo?: string
|
productLogo?: string
|
||||||
productIcon?: string
|
productIcon?: string
|
||||||
status: string
|
status: string
|
||||||
|
// 支付状态: 0-未支付 1-已支付
|
||||||
|
payStatus?: number
|
||||||
|
payTime?: string
|
||||||
|
transactionId?: string
|
||||||
priceType: string
|
priceType: string
|
||||||
payPrice: number
|
payPrice: number
|
||||||
subscriptionPeriod?: string
|
subscriptionPeriod?: string
|
||||||
@@ -43,42 +55,86 @@ const PayPage: React.FC = () => {
|
|||||||
const getSubscriptionNo = useCallback(() => {
|
const getSubscriptionNo = useCallback(() => {
|
||||||
const instance = Taro.getCurrentInstance()
|
const instance = Taro.getCurrentInstance()
|
||||||
const params = instance?.router?.params || {}
|
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(普通链接参数)
|
// 优先读取 scene(小程序码参数),再读取 subscriptionNo(普通链接参数)
|
||||||
const scene = params.scene ? decodeURIComponent(params.scene) : ''
|
const scene = params.scene ? decodeURIComponent(params.scene) : ''
|
||||||
return scene || params.subscriptionNo || ''
|
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 fetchDetail = useCallback(async () => {
|
||||||
const subscriptionNo = getSubscriptionNo()
|
const subscriptionNo = getSubscriptionNo()
|
||||||
if (!subscriptionNo) {
|
if (!subscriptionNo) {
|
||||||
|
console.error('[PayPage] subscriptionNo 为空,无法请求')
|
||||||
setErrorMsg('缺少订单参数')
|
setErrorMsg('缺少订单参数')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[PayPage] ===== 请求调试开始 =====')
|
||||||
|
console.log('[PayPage] 请求 URL:', `/app/subscription/detail-by-no/${subscriptionNo}`)
|
||||||
|
console.log('[PayPage] 当前 token:', Taro.getStorageSync('access_token') || '(无token)')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await request({
|
// request 默认 returnRaw=false,拦截器已拆包,返回的就是 data 部分(订阅对象)
|
||||||
|
const data: any = await request({
|
||||||
url: `/app/subscription/detail-by-no/${subscriptionNo}`,
|
url: `/app/subscription/detail-by-no/${subscriptionNo}`,
|
||||||
method: 'GET'
|
method: 'GET',
|
||||||
|
returnRaw: true // 先用 returnRaw=true 拿到完整响应,方便调试
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res?.code === 200 || res?.code === 0) {
|
console.log('[PayPage] 接口完整返回(returnRaw=true):', JSON.stringify(data))
|
||||||
const data = res.data || res
|
console.log('[PayPage] data.code:', data?.code)
|
||||||
|
console.log('[PayPage] data.message:', data?.message)
|
||||||
|
console.log('[PayPage] data.data:', data?.data)
|
||||||
|
|
||||||
if (data.status === 'active') {
|
// 判断业务状态码
|
||||||
|
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)
|
setPaid(true)
|
||||||
setDetail(data)
|
setDetail(subscription)
|
||||||
} else {
|
} else {
|
||||||
setDetail(data)
|
setDetail(subscription)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setErrorMsg(res?.message || '获取订单信息失败')
|
console.error('[PayPage] 业务错误 code:', data?.code, 'message:', data?.message)
|
||||||
|
setErrorMsg(data?.message || `业务错误(code=${data?.code})`)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} 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 || '网络异常,请重试')
|
setErrorMsg(err?.message || '网络异常,请重试')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
console.log('[PayPage] ===== 请求调试结束 =====')
|
||||||
}
|
}
|
||||||
}, [getSubscriptionNo])
|
}, [getSubscriptionNo])
|
||||||
|
|
||||||
@@ -92,23 +148,75 @@ const PayPage: React.FC = () => {
|
|||||||
|
|
||||||
setPaying(true)
|
setPaying(true)
|
||||||
try {
|
try {
|
||||||
// 1. 获取登录凭证(code)并换取 openid
|
// 1. 获取微信登录凭证(code)
|
||||||
const loginRes = await Taro.login()
|
const loginRes = await Taro.login()
|
||||||
if (!loginRes.code) {
|
if (!loginRes.code) {
|
||||||
throw new Error('获取登录凭证失败')
|
throw new Error('获取登录凭证失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 通过 code 获取 openid(使用现有的 loginByOpenId 接口)
|
console.log('[PayPage] Taro.login code:', loginRes.code)
|
||||||
const openid = Taro.getStorageSync('openid') || ''
|
|
||||||
|
|
||||||
// 3. 请求后端创建 JSAPI 预支付订单
|
// 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({
|
const prepayRes: any = await request({
|
||||||
url: `/app/subscription/mp-prepay/${detail.id}`,
|
url: `/app/subscription/mp-prepay/${detail.id}`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { openid }
|
data: { openid },
|
||||||
|
returnRaw: true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (prepayRes?.code !== 200 && prepayRes?.code !== 0) {
|
console.log('[PayPage] mp-prepay 完整返回:', JSON.stringify(prepayRes))
|
||||||
|
|
||||||
|
if (prepayRes?.code !== 0 && prepayRes?.code !== 200) {
|
||||||
throw new Error(prepayRes?.message || '创建支付订单失败')
|
throw new Error(prepayRes?.message || '创建支付订单失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +226,7 @@ const PayPage: React.FC = () => {
|
|||||||
throw new Error('支付参数不完整')
|
throw new Error('支付参数不完整')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 调用微信支付
|
// 5. 调用微信支付
|
||||||
await Taro.requestPayment({
|
await Taro.requestPayment({
|
||||||
timeStamp: payParams.timeStamp,
|
timeStamp: payParams.timeStamp,
|
||||||
nonceStr: payParams.nonceStr,
|
nonceStr: payParams.nonceStr,
|
||||||
@@ -127,7 +235,7 @@ const PayPage: React.FC = () => {
|
|||||||
paySign: payParams.paySign
|
paySign: payParams.paySign
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5. 支付成功后通知后端确认
|
// 6. 支付成功后通知后端确认
|
||||||
try {
|
try {
|
||||||
await request({
|
await request({
|
||||||
url: `/app/subscription/mp-confirm/${detail.subscriptionNo}`,
|
url: `/app/subscription/mp-confirm/${detail.subscriptionNo}`,
|
||||||
@@ -138,12 +246,22 @@ const PayPage: React.FC = () => {
|
|||||||
console.warn('支付确认通知失败,等待轮询:', confirmErr)
|
console.warn('支付确认通知失败,等待轮询:', confirmErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 显示成功状态
|
// 7. 显示成功状态
|
||||||
setPaid(true)
|
setPaid(true)
|
||||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
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) {
|
} catch (err: any) {
|
||||||
console.error('支付失败:', err)
|
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')) {
|
if (err?.errMsg?.includes('cancel')) {
|
||||||
Taro.showToast({ title: '支付已取消', icon: 'none' })
|
Taro.showToast({ title: '支付已取消', icon: 'none' })
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ const requestInterceptor = (config: RequestConfig): RequestConfig => {
|
|||||||
|
|
||||||
config.header = { ...defaultHeaders, ...config.header };
|
config.header = { ...defaultHeaders, ...config.header };
|
||||||
|
|
||||||
|
console.log('[Request] 发送请求:', { url: config.url, method: config.method, hasToken: !!token, returnRaw: config.returnRaw });
|
||||||
|
|
||||||
// 显示加载提示
|
// 显示加载提示
|
||||||
if (config.showLoading) {
|
if (config.showLoading) {
|
||||||
Taro.showLoading({ title: '加载中...' });
|
Taro.showLoading({ title: '加载中...' });
|
||||||
@@ -95,10 +97,8 @@ const responseInterceptor = <T>(response: any, config: RequestConfig): T => {
|
|||||||
|
|
||||||
const { statusCode, data } = response;
|
const { statusCode, data } = response;
|
||||||
|
|
||||||
// 调试信息(仅开发环境)
|
// 调试信息(所有环境都打印,方便真机排查)
|
||||||
if (process.env.NODE_ENV === 'development') {
|
console.log('[Request] 响应:', { statusCode, url: config.url, data: JSON.stringify(data).substring(0, 500) });
|
||||||
console.log('API Response:', { statusCode, url: config.url, success: statusCode === 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP状态码检查
|
// HTTP状态码检查
|
||||||
if (statusCode !== 200) {
|
if (statusCode !== 200) {
|
||||||
@@ -139,6 +139,7 @@ const responseInterceptor = <T>(response: any, config: RequestConfig): T => {
|
|||||||
|
|
||||||
// 认证错误
|
// 认证错误
|
||||||
if (apiResponse.code === 401 || apiResponse.code === 403) {
|
if (apiResponse.code === 401 || apiResponse.code === 403) {
|
||||||
|
console.error('[Request] 认证错误:', { code: apiResponse.code, message: apiResponse.message, url: config.url });
|
||||||
handleAuthError();
|
handleAuthError();
|
||||||
throw new RequestError(
|
throw new RequestError(
|
||||||
apiResponse.message || '认证失败',
|
apiResponse.message || '认证失败',
|
||||||
@@ -149,9 +150,7 @@ const responseInterceptor = <T>(response: any, config: RequestConfig): T => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 业务错误
|
// 业务错误
|
||||||
if (process.env.NODE_ENV === 'development') {
|
console.error('[Request] 业务错误:', { code: apiResponse.code, message: apiResponse.message, url: config.url });
|
||||||
console.error('API业务错误:', { code: apiResponse.code, message: apiResponse.message });
|
|
||||||
}
|
|
||||||
throw new RequestError(
|
throw new RequestError(
|
||||||
apiResponse.message || '请求失败',
|
apiResponse.message || '请求失败',
|
||||||
ErrorType.BUSINESS_ERROR,
|
ErrorType.BUSINESS_ERROR,
|
||||||
@@ -289,8 +288,8 @@ const executeRequest = <T>(config: RequestConfig): Promise<T> => {
|
|||||||
// 主请求函数
|
// 主请求函数
|
||||||
export async function request<T>(options: RequestConfig): Promise<T> {
|
export async function request<T>(options: RequestConfig): Promise<T> {
|
||||||
try {
|
try {
|
||||||
// 请求拦截
|
// 拼接完整URL(相对路径自动补上 baseUrl)
|
||||||
const config = requestInterceptor({ ...DEFAULT_CONFIG, ...options });
|
const config = requestInterceptor({ ...DEFAULT_CONFIG, ...options, url: buildUrl(options.url) });
|
||||||
|
|
||||||
// 执行请求(带重试)
|
// 执行请求(带重试)
|
||||||
const result = await retryRequest<T>(config);
|
const result = await retryRequest<T>(config);
|
||||||
|
|||||||
3
types/global.d.ts
vendored
3
types/global.d.ts
vendored
@@ -69,6 +69,9 @@ declare const API_BASE_URL: string;
|
|||||||
declare const APP_NAME: string;
|
declare const APP_NAME: string;
|
||||||
declare const DEBUG: string;
|
declare const DEBUG: string;
|
||||||
|
|
||||||
|
// 微信小程序原生全局对象 wx
|
||||||
|
declare const wx: any;
|
||||||
|
|
||||||
// 基础类型定义
|
// 基础类型定义
|
||||||
declare global {
|
declare global {
|
||||||
/** 通用ID类型 */
|
/** 通用ID类型 */
|
||||||
|
|||||||
Reference in New Issue
Block a user