feat(appSubscription): 实现Dashboard订阅到期显示及扫码支付功能

- dashboard订阅到期时间信息支持三态渲染:激活显示剩余天数和已激活标签、待支付显示金额及去支付按钮、无订阅显示立即订阅按钮
- 通过租户ID查询应用产品,实现按租户过滤的订阅展示逻辑
- 新增appProduct分页接口及类型定义,提供应用列表获取能力
- 实现「立即订阅」流程,支持免费应用直接激活,付费应用生成微信扫码支付二维码及轮询订单状态
- 实现待支付订阅复用支付接口生成支付二维码,避免重复创建订阅
- 小程序端扫码支付流程支持订单号查询及支付确认,后端使用Redis标记支付成功状态
- 修复后端generatePayQrcode接口缺少priceType字段导致的数据库报错,前端兼容现有逻辑无需修改
- 移除dashboard中siteInfo旧的expirationTime字段相关代码,改为读订阅expireTime
- 新增订阅及支付相关样式,优化界面体验
- 支付状态轮询实现超时提示及自动停止机制,提升用户支付体验
- 组件卸载时确保支付轮询停止,避免内存泄漏
- 独立加载订阅信息,不影响其他数据请求,避免页面加载失败影响订阅展示
This commit is contained in:
2026-07-15 14:11:14 +08:00
parent e0d27f67d5
commit 29a18ffbc6
4 changed files with 613 additions and 3 deletions

View File

@@ -60,3 +60,21 @@
- `ShopOrderService` 接口 + `ShopOrderServiceImpl` 实现方法签名加 `paymentVoucher` 参数lambdaUpdate 加 `.set(paymentVoucher 非空, ShopOrder::getPaymentVoucher, paymentVoucher)`
- DDL 脚本 `sql/shop_order_payment_voucher.sql``ALTER TABLE shop_order ADD COLUMN payment_voucher VARCHAR(500) NULL ... AFTER pay_time`
- 注意:方法签名从 2 参变 3 参,全项目仅 Controller 一处调用,无断裂。
## Dashboard 到期时间显示订阅信息 + 立即订阅/扫码支付
- 改造 `src/views/shop/dashboard/index.vue` 基本信息「到期时间」行三态渲染active 显示 expireTime + 已激活 tag + ≤7天到期提醒pending 显示待支付 + 去支付;无订阅显示立即订阅)。
- **productId 关联条件**`app_product.tenantId = userStore.info.tenantId`(非 siteInfo.appId。通过 `pageProducts({tenantId,current:1,size:1})` 取该租户应用。
- 新增 `src/api/app/appProduct/{model.ts,index.ts}`AppProduct 类型 + `pageProducts` 接口GET /api/app/product/page分页参数 current/size注意与项目 page/limit 不同)。
- 「立即订阅」流程:选 month/year → 免费应用调 `subscribe()` 直接激活;付费应用调 `generatePayQrcode()` 生成小程序码 → Modal 展示二维码 → 每 2.5s 轮询 `checkStatus(subscriptionNo)`paid 后关闭+刷新(最多 5 分钟。envVersion 按 `import.meta.env.DEV` 自动判断 trial/release。
- pending 订阅「去支付」复用 `subscriptionStore.pay(id,'wechat')`(用 `'miniappQrcode' in result` 类型守卫窄化)生成小程序码,不重复创建订阅。
- 小程序端支付页 `websopy-taro/src/passport/pay/index.tsx`:扫码 scene=subscriptionNo → detail-by-no → mp-prepay → requestPayment → mp-confirm后端写 Redis `wxpay:paid:{no}=1`Web 端 checkStatus 据此感知。
- 移除 dashboard 不再使用的 `siteInfo` 解构(原到期时间读 `siteInfo.expirationTime`,已改为订阅 expireTime修复 noUnusedLocals 报错。
## 修复后端 generatePayQrcode 报错 "Field 'price_type' doesn't have a default value"
- 现象dashboard 点「立即订阅」付费应用调 `generatePayQrcode` 时,后端 insert app_subscription 报 `price_type` 无默认值。
- 根因:后端 `AppSubscriptionController.generatePayQrcode`websopy-java创建订阅时漏了 `setPriceType()`,而 DB `price_type` 为 NOT NULL 无默认值。对比 `subscribe` 方法有 `setPriceType`
- 参考 `websopy-pc/app/pages/console/pay/[subscriptionNo].vue`:它用 `subscribe` 创建订阅 + `pay(id,'wechat')` 生成二维码(两步),避开了 `generatePayQrcode`,所以没遇到此 bug。
- 修复(后端 `AppSubscriptionController.java` generatePayQrcode 方法):补全 `setPriceType(product.getPriceType())` + `setOriginalPrice`/`setPayStatus(0)`/`setTenantId`,对齐 subscribe。**前端无需改动**,重启后端即可。
- 注意:`subscribe` 价格计算用 `price/100`(当分转元),`generatePayQrcode` 直接用 `price`当元两者不一致schema 标注 price 为「元」,故 `generatePayQrcode` 价格逻辑更合理,保留不动。

View File

@@ -0,0 +1,43 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { AppProduct, AppProductQueryParam } from './model';
/**
* websopy 特殊接口域名
* 后端 Controller: com.gxwebsoft.app.controller.AppProductController
* @RequestMapping("/api/app/product"),后端无 context-path
* 完整 URL = https://websopy-api.websoft.top/api/app/product/xxx
*/
const WEBSOPY_API_BASE = 'https://websopy-api.websoft.top/api';
const BASE = `${WEBSOPY_API_BASE}/app/product`;
/**
* 分页查询应用列表
* GET /app/product/page
* 支持按 tenantId 过滤(管理后台按租户查询应用)
* 注意:分页参数为 current/size非 page/limit
*/
export async function pageProducts(
params: AppProductQueryParam
): Promise<PageResult<AppProduct>> {
const res = await request.get<ApiResult<PageResult<AppProduct>>>(
`${BASE}/page`,
{ params }
);
if (res.data.code === 0) {
return res.data.data || { list: [], count: 0 };
}
return Promise.reject(new Error(res.data.message));
}
/**
* 获取应用详情
* GET /app/product/detail/{id}
*/
export async function getProductDetail(id: number): Promise<AppProduct> {
const res = await request.get<ApiResult<AppProduct>>(`${BASE}/detail/${id}`);
if (res.data.code === 0) {
return res.data.data as AppProduct;
}
return Promise.reject(new Error(res.data.message));
}

View File

@@ -0,0 +1,143 @@
/**
* 应用产品实体(对应表 app_product
* 字段对照后端 com.gxwebsoft.app.entity.AppProduct
*/
export interface AppProduct {
// 应用ID主键
productId?: number;
// 应用名称
productName?: string;
// 应用标识(唯一)
productCode?: string;
// 应用密钥
productSecret?: string;
// 应用类型: 10网站 20微信小程序 30抖音小程序 40百度小程序 50支付宝小程序 60Android 70iOS 80macOS 90Windows 100插件
appType?: number;
// 应用类型名称(关联查询)
appTypeName?: string;
// 分类ID
categoryId?: number;
// 行业类型(父级)
industryParent?: string;
// 行业类型(子级)
industryChild?: string;
// 应用Logo
logo?: string;
// 应用图标
icon?: string;
// 二维码
qrcode?: string;
// 应用截图(JSON数组)
screenshots?: string;
// 应用简介
description?: string;
// 详细说明
content?: string;
// 关键词
keywords?: string;
// 域名
domain?: string;
// 域名前缀
prefix?: string;
// 包名/AppID
packageName?: string;
// 后台地址
adminUrl?: string;
// API地址
apiUrl?: string;
// 下载地址
downloadUrl?: string;
// 版本号
version?: string;
// 版本: standard标准版 professional专业版 perpetual永久授权
edition?: string;
// 最低版本要求
minVersion?: string;
// 定价: free免费 one_time一次性 subscription订阅
priceType?: string;
// 价格(元)
price?: number;
// 划线价格
linePrice?: number;
// 续费价格
renewPrice?: number;
// 交付方式: 1源码 2托管 3授权
deliveryMethod?: number;
// 计费方式: 1按年 2按月 3一次性
chargingMethod?: number;
// 订阅周期: month/year
subscriptionPeriod?: string;
// 发布状态: developing pending_review published rejected deprecated
publishStatus?: string;
// 发布时间
publishTime?: string;
// 审核时间
reviewTime?: string;
// 审核人ID
reviewerId?: number;
// 拒绝原因
rejectReason?: string;
// 浏览次数
clicks?: number;
// 安装次数
installs?: number;
// 下载次数
downloads?: number;
// 评分(1-5)
rating?: number;
// 点赞数
likes?: number;
// 开发者
developer?: string;
// 开发者电话
developerPhone?: string;
// 开发者邮箱
developerEmail?: string;
// 是否推荐: 0否 1是
recommend?: number;
// 是否官方: 0否 1是
official?: number;
// 是否上架市场: 0否 1是
market?: number;
// 是否显示首页: 0否 1是
showIndex?: number;
// 是否可搜索: 0否 1是
searchEnabled?: number;
// 模板ID
templateId?: number;
// 租户ID
tenantId?: number;
// 创建时间
createTime?: string;
// 更新时间
updateTime?: string;
}
/**
* 应用产品分页查询参数
* 注意:后端 AppProductController.page 的分页参数为 current/size非 page/limit
*/
export interface AppProductQueryParam {
// 页码
current?: number;
// 每页条数
size?: number;
// 应用名称
productName?: string;
// 应用标识
productCode?: string;
// 应用类型
appType?: number;
// 分类ID
categoryId?: number;
// 发布状态
publishStatus?: string;
// 状态
status?: number;
// 用户ID
userId?: number;
// 租户ID按租户查询应用
tenantId?: number;
// 关键词搜索(同时搜索应用名称和应用标识)
keywords?: string;
}

View File

@@ -168,7 +168,27 @@
{{ tenantStore.company?.createTime || '-' }}
</a-descriptions-item>
<a-descriptions-item label="到期时间">
{{ siteInfo?.expirationTime || '-' }}
<template v-if="subscriptionLoading">
<a-skeleton-input :active="true" size="small" style="width: 140px" />
</template>
<template v-else-if="currentSubscription && currentSubscription.status === 'active' && currentSubscription.expireTime">
<span>{{ formatDateTime(currentSubscription.expireTime) }}</span>
<a-tag color="green" style="margin-left: 8px">已激活</a-tag>
<span v-if="daysToExpire !== null && daysToExpire >= 0 && daysToExpire <= 7" class="expire-warn">
{{ daysToExpire }}天后到期
</span>
<span v-else-if="daysToExpire !== null && daysToExpire < 0" class="expire-warn">
已过期
</span>
</template>
<template v-else-if="currentSubscription && currentSubscription.status === 'pending'">
<span class="text-orange">待支付 ¥{{ Number(currentSubscription.payPrice || 0).toFixed(2) }}</span>
<a-button type="link" size="small" @click="repayPending" style="padding: 0 0 0 8px">去支付</a-button>
</template>
<template v-else>
<span class="text-muted">未订阅</span>
<a-button type="link" size="small" @click="onSubscribe" style="padding: 0 0 0 8px">立即订阅</a-button>
</template>
</a-descriptions-item>
<a-descriptions-item label="系统运行">
{{ runDays }}
@@ -223,6 +243,80 @@
</div>
</a-col>
</a-row>
<!-- 订阅 Modal选择订阅周期 -->
<a-modal
v-model:open="subscribeModalVisible"
title="订阅应用"
:confirm-loading="subscribing"
ok-text="确认订阅"
cancel-text="取消"
:mask-closable="false"
@ok="confirmSubscribe"
>
<div class="subscribe-modal-body">
<div class="subscribe-product-name">
{{ currentProduct?.productName || '应用订阅' }}
</div>
<a-alert
v-if="isFreeProduct"
type="success"
message="该应用为免费应用,订阅后立即激活"
show-icon
style="margin-bottom: 12px"
/>
<div v-else class="period-options">
<div
class="period-option"
:class="{ active: selectedPeriod === 'month' }"
@click="selectedPeriod = 'month'"
>
<div class="period-option-label">月付</div>
<div class="period-option-price">¥{{ monthPrice.toFixed(2) }}/</div>
</div>
<div
class="period-option"
:class="{ active: selectedPeriod === 'year' }"
@click="selectedPeriod = 'year'"
>
<div class="period-option-label">年付</div>
<div class="period-option-price">¥{{ yearPrice.toFixed(2) }}/</div>
<div class="period-option-tag">更划算</div>
</div>
</div>
</div>
</a-modal>
<!-- 支付 Modal微信扫码支付 -->
<a-modal
v-model:open="payModalVisible"
title="微信扫码支付"
:footer="null"
:mask-closable="false"
@cancel="onPayModalClose"
>
<div class="pay-modal-body">
<div class="pay-product-name">{{ payProductName }}</div>
<div class="pay-amount">
<span class="pay-amount-label">支付金额</span>
<span class="pay-amount-value">¥{{ payPrice.toFixed(2) }}</span>
</div>
<div class="pay-qrcode-wrap">
<img
v-if="payQrcode"
:src="payQrcode"
alt="支付二维码"
class="pay-qrcode-img"
/>
<a-spin v-else />
</div>
<div class="pay-tip">
<QrcodeOutlined />
请使用微信扫描上方二维码完成支付
</div>
<div class="pay-order-no">订单号{{ paySubscriptionNo }}</div>
</div>
</a-modal>
</div>
</template>
@@ -240,28 +334,35 @@ import {
TeamOutlined,
SettingOutlined,
ClearOutlined,
InfoCircleOutlined
InfoCircleOutlined,
QrcodeOutlined
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue/es';
import { useSiteStore } from '@/store/modules/site';
import { useStatisticsStore } from '@/store/modules/statistics';
import { useUserStore } from '@/store/modules/user';
import { useTenantStore } from '@/store/modules/tenant';
import { useAppSubscriptionStore } from '@/store/modules/appSubscription';
import { pageShopOrder } from '@/api/shop/shopOrder';
import { getShopSettingCategoryValues } from '@/api/shop/shopSetting';
import { getCompressedImageUrl } from '@/utils/image';
import { storeToRefs } from 'pinia';
import { removeSiteInfoCache } from '@/api/cms/cmsWebsite';
import { pageProducts } from '@/api/app/appProduct';
import type { AppProduct } from '@/api/app/appProduct/model';
import type { AppSubscription } from '@/api/app/appSubscription/model';
import dayjs from 'dayjs';
// 使用状态管理
const siteStore = useSiteStore();
const statisticsStore = useStatisticsStore();
const userStore = useUserStore();
const tenantStore = useTenantStore();
const subscriptionStore = useAppSubscriptionStore();
const router = useRouter();
// 从 store 中获取响应式数据
const { siteInfo, loading: siteLoading } = storeToRefs(siteStore);
const { loading: siteLoading } = storeToRefs(siteStore);
const { loading: statisticsLoading } = storeToRefs(statisticsStore);
// 系统信息
@@ -273,6 +374,227 @@ const systemInfo = reactive({
server: 'Linux CentOS 7.9',
});
// ============================================================
// 应用订阅相关数据来源websopy app_subscription 表)
// productId 通过 当前登录用户租户ID 查询 app_product 获得
// ============================================================
// 当前应用产品按租户ID查询
const currentProduct = ref<AppProduct | null>(null);
// 当前应用的有效订阅
const currentSubscription = ref<AppSubscription | null>(null);
// 订阅信息加载中
const subscriptionLoading = ref(false);
// 订阅 Modal选择周期
const subscribeModalVisible = ref(false);
const subscribing = ref(false);
const selectedPeriod = ref<'month' | 'year'>('month');
// 支付 Modal小程序码扫码支付
const payModalVisible = ref(false);
const payQrcode = ref('');
const payPrice = ref(0);
const paySubscriptionNo = ref('');
const payProductName = ref('');
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
const pollCount = ref(0);
const MAX_POLL_COUNT = 120; // 2.5s * 120 = 5分钟
// 微信小程序环境版本:开发用 trial生产用 release
const envVersion = import.meta.env.DEV ? 'trial' : 'release';
// 是否免费应用
const isFreeProduct = computed(() => {
const p = currentProduct.value;
if (!p) return false;
return p.priceType === 'free' || !p.price || Number(p.price) === 0;
});
// 月付参考价格(元)
const monthPrice = computed(() => Number(currentProduct.value?.price) || 0);
// 年付参考价格按12个月
const yearPrice = computed(() => monthPrice.value * 12);
// 距到期天数负数表示已过期null 表示无到期时间
const daysToExpire = computed(() => {
const expire = currentSubscription.value?.expireTime;
if (!expire) return null;
return dayjs(expire).startOf('day').diff(dayjs().startOf('day'), 'day');
});
// 格式化时间
const formatDateTime = (dt?: string) => {
if (!dt) return '-';
return dayjs(dt).format('YYYY-MM-DD HH:mm:ss');
};
// 加载当前应用的订阅信息
const loadSubscriptionInfo = async () => {
const tenantId = userStore.info?.tenantId;
if (!tenantId) {
console.warn('无法获取租户ID跳过订阅信息加载');
return;
}
subscriptionLoading.value = true;
try {
// 1. 按租户ID查询应用产品取第一条
const productRes = await pageProducts({
tenantId,
current: 1,
size: 1
});
const product = productRes.list?.[0];
if (!product || !product.productId) {
console.warn('当前租户未找到应用产品');
return;
}
currentProduct.value = product;
// 2. 拉取我的订阅列表,过滤出该产品的订阅
const subRes = await subscriptionStore.fetchMySubscriptions(
{ page: 1, limit: 50 },
false
);
const subs = (subRes.list || []).filter(
(s) => s.productId === product.productId
);
// 优先取 active 中 expireTime 最新;否则取 pending 最新
const active = subs
.filter((s) => s.status === 'active')
.sort(
(a, b) =>
dayjs(b.expireTime).valueOf() - dayjs(a.expireTime).valueOf()
)[0];
const pending = subs
.filter((s) => s.status === 'pending')
.sort(
(a, b) => dayjs(b.createTime).valueOf() - dayjs(a.createTime).valueOf()
)[0];
currentSubscription.value = active || pending || null;
} catch (e) {
console.warn('获取订阅信息失败:', e);
} finally {
subscriptionLoading.value = false;
}
};
// 刷新订阅信息(支付成功/订阅成功后调用)
const refreshSubscription = () => {
loadSubscriptionInfo().catch(() => {});
};
// 打开订阅 Modal
const onSubscribe = () => {
if (!currentProduct.value?.productId) {
message.warning('应用信息加载中,请稍后再试');
return;
}
selectedPeriod.value = 'month';
subscribeModalVisible.value = true;
};
// 确认订阅
const confirmSubscribe = async () => {
const product = currentProduct.value;
if (!product?.productId) return;
subscribing.value = true;
try {
if (isFreeProduct.value) {
// 免费应用:直接创建并激活
await subscriptionStore.subscribe({
productId: product.productId,
subscriptionPeriod: selectedPeriod.value
});
message.success('订阅成功');
subscribeModalVisible.value = false;
refreshSubscription();
return;
}
// 付费应用:生成支付小程序码
const result = await subscriptionStore.generatePayQrcode({
productId: product.productId,
subscriptionPeriod: selectedPeriod.value,
envVersion
});
paySubscriptionNo.value = result.subscriptionNo;
payQrcode.value = result.qrcodeBase64;
payPrice.value = Number(result.payPrice) || 0;
payProductName.value =
result.productName || product.productName || '应用订阅';
subscribeModalVisible.value = false;
payModalVisible.value = true;
// 开始轮询支付状态
startPolling(result.subscriptionNo);
} catch (e: any) {
console.error('创建订阅失败:', e);
message.error(e?.message || '创建订阅失败,请重试');
} finally {
subscribing.value = false;
}
};
// 重新支付pending 订阅,复用已有订阅记录生成小程序码)
const repayPending = async () => {
const sub = currentSubscription.value;
if (!sub?.id || !sub.subscriptionNo) return;
try {
const result = await subscriptionStore.pay(sub.id, 'wechat', envVersion);
if ('miniappQrcode' in result) {
paySubscriptionNo.value = sub.subscriptionNo;
payQrcode.value = result.miniappQrcode;
payPrice.value = Number(result.payPrice) || 0;
payProductName.value =
sub.productName || currentProduct.value?.productName || '应用订阅';
payModalVisible.value = true;
startPolling(sub.subscriptionNo);
}
} catch (e: any) {
message.error(e?.message || '生成支付码失败');
}
};
// 开始轮询支付状态(每 2.5s 查一次,最多 5 分钟)
const startPolling = (subscriptionNo: string) => {
stopPolling();
pollCount.value = 0;
pollingTimer.value = setInterval(async () => {
pollCount.value++;
if (pollCount.value > MAX_POLL_COUNT) {
stopPolling();
message.warning('支付状态查询超时,如已支付请刷新页面');
return;
}
try {
const status = await subscriptionStore.checkStatus(subscriptionNo);
if (status.paid) {
stopPolling();
payModalVisible.value = false;
message.success('支付成功');
refreshSubscription();
}
} catch (e) {
console.warn('查询支付状态失败:', e);
}
}, 2500);
};
// 停止轮询
const stopPolling = () => {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
pollCount.value = 0;
};
// 关闭支付 Modal
const onPayModalClose = () => {
stopPolling();
payModalVisible.value = false;
payQrcode.value = '';
paySubscriptionNo.value = '';
};
// 计算属性
const now = ref(Date.now());
let runDaysTimer: ReturnType<typeof setInterval>;
@@ -384,6 +706,11 @@ const loadData = async () => {
console.warn('获取租户信息失败:', e);
});
// 独立加载订阅信息(到期时间),不受其他请求失败影响
loadSubscriptionInfo().catch((e) => {
console.warn('加载订阅信息失败:', e);
});
// 独立请求商城Logo不受其他请求失败影响
getShopSettingCategoryValues('basic')
.then((values) => {
@@ -461,6 +788,8 @@ onUnmounted(() => {
// 组件卸载时停止自动刷新
statisticsStore.stopAutoRefresh();
clearInterval(runDaysTimer);
// 停止支付状态轮询
stopPolling();
});
</script>
@@ -624,4 +953,81 @@ onUnmounted(() => {
grid-template-columns: repeat(2, 1fr);
}
}
/* 到期时间状态 */
.text-muted { color: rgba(0,0,0,0.45); }
.text-orange { color: #fa8c16; }
.expire-warn { color: #ff4d4f; font-size: 12px; margin-left: 4px; }
/* 订阅 Modal */
.subscribe-modal-body { padding: 8px 0; }
.subscribe-product-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: rgba(0,0,0,0.85);
}
.period-options {
display: flex;
gap: 16px;
}
.period-option {
flex: 1;
border: 2px solid #f0f0f0;
border-radius: 10px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.period-option:hover { border-color: #69b1ff; }
.period-option.active {
border-color: #00704A;
background: #f0fdf4;
}
.period-option-label { font-size: 14px; color: rgba(0,0,0,0.65); margin-bottom: 8px; }
.period-option-price { font-size: 18px; font-weight: 700; color: #00704A; }
.period-option-tag {
position: absolute;
top: -8px;
right: 8px;
background: #ff4d4f;
color: #fff;
font-size: 11px;
padding: 1px 8px;
border-radius: 8px;
}
/* 支付 Modal */
.pay-modal-body { text-align: center; padding: 8px 0; }
.pay-product-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: rgba(0,0,0,0.85);
}
.pay-amount { margin-bottom: 16px; }
.pay-amount-label { font-size: 13px; color: rgba(0,0,0,0.45); margin-right: 8px; }
.pay-amount-value { font-size: 24px; font-weight: 800; color: #ff4d4f; }
.pay-qrcode-wrap {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.pay-qrcode-img {
width: 220px;
height: 220px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.pay-tip {
font-size: 13px;
color: rgba(0,0,0,0.55);
margin-bottom: 8px;
}
.pay-order-no {
font-size: 12px;
color: rgba(0,0,0,0.35);
}
</style>