diff --git a/.workbuddy/memory/2026-07-15.md b/.workbuddy/memory/2026-07-15.md index c474c29..758dbed 100644 --- a/.workbuddy/memory/2026-07-15.md +++ b/.workbuddy/memory/2026-07-15.md @@ -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` 价格逻辑更合理,保留不动。 diff --git a/src/api/app/appProduct/index.ts b/src/api/app/appProduct/index.ts new file mode 100644 index 0000000..b5c87d0 --- /dev/null +++ b/src/api/app/appProduct/index.ts @@ -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> { + const res = await request.get>>( + `${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 { + const res = await request.get>(`${BASE}/detail/${id}`); + if (res.data.code === 0) { + return res.data.data as AppProduct; + } + return Promise.reject(new Error(res.data.message)); +} diff --git a/src/api/app/appProduct/model.ts b/src/api/app/appProduct/model.ts new file mode 100644 index 0000000..8d6bdd2 --- /dev/null +++ b/src/api/app/appProduct/model.ts @@ -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; +} diff --git a/src/views/shop/dashboard/index.vue b/src/views/shop/dashboard/index.vue index cc642e3..9ae1beb 100644 --- a/src/views/shop/dashboard/index.vue +++ b/src/views/shop/dashboard/index.vue @@ -168,7 +168,27 @@ {{ tenantStore.company?.createTime || '-' }} - {{ siteInfo?.expirationTime || '-' }} + + + + {{ runDays }} 天 @@ -223,6 +243,80 @@ + + + + + + + + +
+
{{ payProductName }}
+
+ 支付金额 + ¥{{ payPrice.toFixed(2) }} +
+
+ 支付二维码 + +
+
+ + 请使用微信扫描上方二维码完成支付 +
+
订单号:{{ paySubscriptionNo }}
+
+
@@ -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(null); +// 当前应用的有效订阅 +const currentSubscription = ref(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 | 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; @@ -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(); }); @@ -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); +}