From 015cd50c08b5f4be0cf33d9ebcb1c162bfb73290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Wed, 15 Jul 2026 22:19:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(subscription):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E7=BB=AD=E8=B4=B9=E6=8E=A5=E5=8F=A3=E4=B8=BA=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=94=AF=E4=BB=98=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正续费请求路径为后端实际的 /renew-pay/{id},避免 404 错误 - 续费接口新增 method 和 envVersion 参数支持余额与微信支付 - 续费请求返回支付结果,微信支付返回小程序码,余额支付直接成功 - 续费不再分步调起支付,前端确认续费逻辑简化为一步调用后端接口 - 后端 renewPay 优化,避免放弃支付导致服务中断及时长丢失 - 后端续费激活逻辑调整,续费保留原订阅有效期累计时长 - 排查并解决微信小程序支付配置缺失导致续费失败的问题 - 代码及注释同步更新,完善续费流程整体一致性与稳定性 --- .workbuddy/memory/2026-07-15.md | 44 ++++++++++++++++++++++++++++ src/api/app/appSubscription/index.ts | 24 +++++++++------ src/store/modules/appSubscription.ts | 13 ++++++-- src/views/shop/dashboard/index.vue | 31 ++++++++++---------- 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/.workbuddy/memory/2026-07-15.md b/.workbuddy/memory/2026-07-15.md index ca65ff0..d888ae7 100644 --- a/.workbuddy/memory/2026-07-15.md +++ b/.workbuddy/memory/2026-07-15.md @@ -204,3 +204,47 @@ - 逻辑:校验原订阅归属 → 创建 pending 续费记录(startTime=原expireTime或now,expireTime=新到期)→ 返回 `{ subscriptionId, subscriptionNo, status, message, payPrice?, orderNo? }`(同 SubscribeResult) - 前端拿到后调 `pay/{subscriptionId}` 生成微信小程序码,与订阅流程一致 - 续费按钮触发条件不变:`showRenewButton`(daysToExpire<=7,即将到期或已过期)。 + +## 续费最终对齐:后端已有 /renew-pay(路径不匹配导致 404,改前端即可) + +- 关键发现:后端 `AppSubscriptionController` 其实**已经实现了续费**,方法是 `renewPay`(line 849-910),路径是 `/api/app/subscription/renew-pay/{id}`(带连字符),不是前端调的 `/renew/{id}`,所以之前 404。 +- 后端 renew-pay 逻辑(已实现,无需改动): + - 校验订阅存在 + priceType=subscription + 产品存在 + - 计算续费价格(年付按10个月,对齐 subscribe) + - 计算新到期:`baseTime = max(原expireTime, now)`,`newExpireTime = baseTime + period`(未过期从原到期延后,已过期从现在延后,不丢时长) + - 原订阅状态改 pending、覆盖 payPrice/period/expireTime + - method=balance → handleBalancePay(直接扣款激活);method=wechat → handleWechatPay(返回 miniappQrcode) +- 前端三处改动(对齐后端,从方案1改为方案2"renew一步返码"): + 1. `api/.../index.ts` `renewSubscription`:路径 `/renew/{id}` → `/renew-pay/{id}`;参数加 `method/envVersion`;返回类型 `SubscribeResult` → `PayResult | WechatNativePayResult`。 + 2. `store/.../appSubscription.ts` `renew`:参数加 `method/envVersion`;返回类型 `PayResult | WechatNativePayResult`。 + 3. `dashboard/index.vue` `confirmRenew`:`renew(sub.id, period, 'wechat', envVersion)` 直接拿支付结果 → `'paid' in result` 余额成功分支;`'miniappQrcode' in result` 弹码+轮询。不再二次调 pay。 +- 教训:前端 404 时应先 grep 后端 Controller 确认接口是否真实存在及准确路径(带连字符的 `/renew-pay` 容易被误写为 `/renew`)。后端项目位于 `/Users/gxwebsoft/JAVA/websopy-java/`,Controller 全路径 `com.gxwebsoft.app.controller.AppSubscriptionController`。 + +## 续费报错"微信小程序配置不存在或解析失败"根因排查 + +- 报错:`POST /renew-pay/90` 返回"生成小程序码失败: 微信小程序配置不存在或解析失败 — 请检查 app_setting 表中 key='platform_miniprogram' 的记录"。用户说"之前订阅还可以"。 +- 根因机制(`WxMiniprogramUtil.getAccessToken`): + - access_token 有 Redis 缓存,key=`WX_ACCESS_TOKEN:5`(DEFAULT_TENANT_ID=5),TTL=7000秒(约2小时)。 + - **缓存命中时直接返回 token,不读 app_setting 配置**;缓存未命中才调 `getMiniprogramSetting()` 读 `app_setting` 表 key='platform_miniprogram'。 + - 所以"之前订阅可以"= access_token 缓存命中掩盖了配置读取问题;现在续费失败=缓存过期,走到读配置那步发现读不到。 + - 这**不是续费特有 bug**,现在去测订阅(subscribe+pay)也会同样失败。 +- `AppSettingServiceImpl.getByKey` 只按 setting_key 查、不按租户过滤(注释"平台配置不按租户隔离"),排除租户隔离问题。 +- 排查方向(按可能性): + 1. app_setting 表 platform_miniprogram 记录被误删/setting_value 被清空。 + 2. setting_value JSON 格式损坏(多/少逗号引号),`JSON.parseObject` 抛异常 → catch return null。 + 3. 表里有多条 setting_key='platform_miniprogram' 记录,MyBatis-Plus getOne 抛 TooManyResultsException → catch return null。 +- 后端 handleWechatPay 开头会调 `diagnoseConfig()` 打印详细日志(app_setting 是否存在、setting_value 原始内容、解析出的 appId/appSecret),看后端日志最快定位。 +- 修复:配置丢了去小程序配置页重新保存(batchSave 会写入);多条记录则清理只留一条。 +- 关键文件:`/Users/gxwebsoft/JAVA/websopy-java/src/main/java/com/gxwebsoft/common/core/utils/WxMiniprogramUtil.java`(getAccessToken line 182-276,getMiniprogramSetting line 291-316)。 + +## 后端 renew-pay 隐患修复(放弃支付不中断服务 + 续费不丢时长) + +- 隐患:原 renewPay 把原订阅 status 改 pending + 提前改 expireTime,用户放弃支付会服务中断、到期时间错乱;且 handleBalancePay/mpConfirm 激活时 expireTime=now+period 覆盖了续费应得的"原到期+period",未过期续费用户损失剩余时长。 +- 后端改动 `AppSubscriptionController.java`(4 处): + 1. `renewPay`:删 `setStatus("pending")` 和 `setExpireTime(newExpireTime)`;只设 payStatus=0 + payPrice/originalPrice/period。原订阅 status 保持 active(或 expired),expireTime 不变 → 放弃支付零影响。newExpireTime 仍计算,仅用于日志"预计到期(支付后)"。 + 2. `handleBalancePay` 激活:`sub.setStartTime(now)` → `if (startTime==null) setStartTime(now)`(续费保留首次生效时间);expireTime 由 `now+period` → `max(原expireTime, now)+period`。 + 3. `mpConfirm` 拦截:`if (status=="active")` → `if (status=="active" && payStatus==1)`,放行续费的 active+payStatus=0。 + 4. `mpConfirm` 激活:同 handleBalancePay,startTime 仅 null 时设、expireTime 用 max(原expireTime, now)+period。 +- max 逻辑对新订阅和续费都正确:新订阅 expireTime 为 null → expireBase=now → now+period;续费未过期 → 原+period(不丢时长);续费已过期 → now+period。 +- 放弃支付现状:订阅保持 active+payStatus=0,服务不中断;用户可再次点"立即续费"重新 renew-pay(会覆盖 payPrice/period 并重新生成码)。无清理 pending 续费订单的需求(不再产生 pending 记录)。 +- 前端无需改动(renew-pay 调用不变)。 diff --git a/src/api/app/appSubscription/index.ts b/src/api/app/appSubscription/index.ts index 6284b29..6231a2d 100644 --- a/src/api/app/appSubscription/index.ts +++ b/src/api/app/appSubscription/index.ts @@ -221,19 +221,25 @@ export async function mpConfirm( } /** - * 续费:基于已有订阅创建续费订单(pending),返回新 subscriptionId - * POST /app/subscription/renew/{id}?period=month|year - * 返回结构与 subscribe 一致(含 subscriptionId),前端再调 pay 生成支付码 + * 续费:基于已有订阅创建续费订单并生成支付码(一步完成,对齐后端 /renew-pay) + * POST /app/subscription/renew-pay/{id}?period=month&method=wechat&envVersion=trial + * - method=balance:余额支付,返回 PayResult(paid/balance) + * - method=wechat:微信支付,返回小程序码 WechatNativePayResult(miniappQrcode) + * 后端会基于原 expireTime 或 now(取较大者)延长到期时间 */ export async function renewSubscription( id: number, - period: 'month' | 'year' = 'month' -): Promise { - const res = await request.post>(`${BASE}/renew/${id}`, undefined, { - params: { period } - }); + period: 'month' | 'year' = 'month', + method: 'balance' | 'wechat' = 'wechat', + envVersion?: string +): Promise { + const res = await request.post>( + `${BASE}/renew-pay/${id}`, + undefined, + { params: { period, method, envVersion } } + ); if (res.data.code === 0) { - return res.data.data as SubscribeResult; + return res.data.data as PayResult | WechatNativePayResult; } return Promise.reject(new Error(res.data.message)); } diff --git a/src/store/modules/appSubscription.ts b/src/store/modules/appSubscription.ts index f618892..9748c36 100644 --- a/src/store/modules/appSubscription.ts +++ b/src/store/modules/appSubscription.ts @@ -286,13 +286,20 @@ export const useAppSubscriptionStore = defineStore('appSubscription', { // ============================================================ /** - * 续费:基于已有订阅创建续费订单,返回新 subscriptionId(再调 pay 发起支付) + * 续费:基于已有订阅创建续费订单并生成支付码(一步完成,对齐后端 /renew-pay) * @param id 订阅ID * @param period 周期:month/year + * @param method 支付方式:balance/wechat + * @param envVersion 小程序版本(微信支付时使用) */ - async renew(id: number, period: 'month' | 'year' = 'month'): Promise { + async renew( + id: number, + period: 'month' | 'year' = 'month', + method: 'balance' | 'wechat' = 'wechat', + envVersion?: string + ): Promise { try { - const result = await renewSubscription(id, period); + const result = await renewSubscription(id, period, method, envVersion); this.invalidateCache(); return result; } catch (error) { diff --git a/src/views/shop/dashboard/index.vue b/src/views/shop/dashboard/index.vue index b7bb488..bfd73fe 100644 --- a/src/views/shop/dashboard/index.vue +++ b/src/views/shop/dashboard/index.vue @@ -544,32 +544,31 @@ const onRenew = () => { subscribeModalVisible.value = true; }; -// 确认续费(方案1:renew 创建续费订单 → pay 生成小程序码 → 扫码,与 confirmSubscribe 对称) +// 确认续费(后端 renew-pay 一步完成:创建续费订单 + 生成支付码,无需再调 pay) const confirmRenew = async () => { const sub = currentSubscription.value; if (!sub?.id) return; renewing.value = true; try { - // 1. 续费:基于原订阅创建续费订单,返回新 subscriptionId - const renewResult = await subscriptionStore.renew(sub.id, selectedPeriod.value); - // 免费应用:renew 直接激活,status=active - if (renewResult.status === 'active') { + // renew-pay 直接返回支付结果(微信支付返回小程序码,余额支付返回 paid) + const result = await subscriptionStore.renew( + sub.id, + selectedPeriod.value, + 'wechat', + envVersion + ); + // 余额支付:直接成功 + if ('paid' in result && result.paid) { message.success('续费成功'); subscribeModalVisible.value = false; refreshSubscription(); return; } - // 2. 付费应用:用 pay 生成支付小程序码 - const payResult = await subscriptionStore.pay( - renewResult.subscriptionId, - 'wechat', - envVersion - ); - if ('miniappQrcode' in payResult) { - paySubscriptionNo.value = - payResult.subscriptionNo || renewResult.subscriptionNo; - payQrcode.value = payResult.miniappQrcode; - payPrice.value = Number(payResult.payPrice) || 0; + // 微信支付:弹小程序码扫码 + if ('miniappQrcode' in result) { + paySubscriptionNo.value = result.subscriptionNo; + payQrcode.value = result.miniappQrcode; + payPrice.value = Number(result.payPrice) || 0; payProductName.value = currentProduct.value?.productName || '应用续费'; subscribeModalVisible.value = false; payModalVisible.value = true;