补充 VP 模块注册 + 前端 API 封装

- app.module.ts: 注册 VirtualPaymentModule
- config.ts + api.ts: 添加虚拟支付 API 端点
- useGravityPurchase.ts: VP 购买逻辑
- interview.vue / user.vue: 适配 VP 调用
- admin.controller.ts: 管理端虚拟支付相关调整
- payment.controller.spec.ts: 测试适配
This commit is contained in:
yuzhiran
2026-06-24 10:35:25 +08:00
parent 81f86d995d
commit 52e5350ba0
8 changed files with 66 additions and 74 deletions
+2
View File
@@ -28,6 +28,7 @@ import { PricingModule } from './modules/schemas/pricing.module'
import { ShareModule } from './modules/share/share.module'
import { InterviewReviewModule } from './modules/interview-review/interview-review.module'
import { CareerAdviceModule } from './modules/career-advice/career-advice.module'
import { VirtualPaymentModule } from './modules/virtual-payment/virtual-payment.module'
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
@@ -64,6 +65,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
ShareModule,
InterviewReviewModule,
CareerAdviceModule,
VirtualPaymentModule,
],
providers: [
JwtStrategy,
@@ -428,8 +428,8 @@ const DEFAULT_CONFIG = {
}
const DEFAULT_PRICING = {
interview: { pricePerSession: 500, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 },
interview: { pricePerSession: 600, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 400, creditsPerPurchase: 1 },
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
plans: {
growth: {
@@ -36,12 +36,12 @@ describe('PaymentController', () => {
}
mockPricingService = {
getConfig: jest.fn().mockResolvedValue({
interview: { pricePerSession: 500, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 },
interview: { pricePerSession: 600, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 400, creditsPerPurchase: 1 },
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
plans: {
growth: { price: 1990, durationDays: 30, gravityPerMonth: 250, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
sprint: { price: 4990, durationDays: 30, gravityPerMonth: 600, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] },
growth: { price: 1990, durationDays: 30, gravityPerMonth: 80, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
sprint: { price: 4990, durationDays: 30, gravityPerMonth: 200, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] },
},
}),
}
@@ -158,7 +158,7 @@ describe('PaymentController', () => {
expect(result.plan).toBe('growth')
expect(mockUser.save).toHaveBeenCalled()
expect(mockUser.plan).toBe('growth')
expect(mockUser.gravity).toBe(250)
expect(mockUser.gravity).toBe(80)
expect(mockUser.freeOptimizeUsed).toBe(3)
})
@@ -81,31 +81,41 @@ export function useGravityPurchase(onPaymentSuccess?: () => void) {
if (isMp.value) {
try {
let res = await uni.request({
url: api('/payment/jsapi-product'), method: 'POST',
data: { type, quantity },
const loginRes = await new Promise<{ code: string }>((resolve, reject) => {
uni.login({ provider: 'weixin', success: r => resolve(r), fail: reject })
})
if (!loginRes.code) {
payLoading.value = false
payError.value = '微信登录失败,请重试'
uni.showToast({ title: '微信登录失败', icon: 'none' })
return
}
const res = await uni.request({
url: api('/virtual-payment/create'), method: 'POST',
data: { type, quantity, wxCode: loginRes.code },
header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
timeout: 30000,
})
payLoading.value = false
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.payParams) {
const pp = res.data.payParams as any
currentOutTradeNo.value = res.data.outTradeNo || ''
uni.requestPayment({
provider: 'wxpay',
timeStamp: pp.timeStamp,
nonceStr: pp.nonceStr,
package: pp.package,
signType: pp.signType || 'RSA',
paySign: pp.paySign,
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.paySig) {
const vp = res.data as any
currentOutTradeNo.value = vp.outTradeNo
;(wx as any).requestVirtualPayment({
env: vp.env,
offerId: vp.offerId,
signData: vp.signData,
paySig: vp.paySig,
signature: vp.signature,
success: () => {
const no = currentOutTradeNo.value || res.data.outTradeNo
const no = currentOutTradeNo.value || vp.outTradeNo
pollPayResult(no)
},
fail: () => {
fail: (err: any) => {
payError.value = '支付未完成'
uni.showToast({ title: '支付未完成', icon: 'none' })
uni.showToast({ title: err?.errMsg || '支付未完成', icon: 'none' })
},
})
} else if (!res.statusCode || res.statusCode === 0) {
@@ -116,9 +126,9 @@ export function useGravityPurchase(onPaymentSuccess?: () => void) {
payError.value = errMsg
uni.showToast({ title: errMsg, icon: 'none' })
}
} catch (e) {
} catch (e: any) {
payLoading.value = false
payError.value = '网络错误,请重试'
payError.value = e?.errMsg || '网络错误,请重试'
uni.showToast({ title: '网络错误', icon: 'none' })
}
} else {
+4
View File
@@ -122,6 +122,10 @@ export const API_ENDPOINTS = {
CHAT: '/career-advice/chat',
POSITIONS: '/career-advice/positions',
},
VIRTUAL_PAYMENT: {
CREATE: '/virtual-payment/create',
CHECK: (outTradeNo: string) => `/virtual-payment/check/${outTradeNo}`,
},
} as const
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn'
+15 -27
View File
@@ -90,22 +90,22 @@
<view class="complete-bar" v-else>
<button class="cta-btn" @click="goResult">查看面试报告</button>
<button class="buy-btn" v-if="completedReason === 'noCredits'" @click="goH5Buy">引力值不足官网购买 </button>
<button class="buy-btn" v-if="completedReason === 'noCredits'" @click="goGravityBuy">引力值不足立即补充 </button>
</view>
<!-- 官网购买弹窗 -->
<view class="modal-overlay" v-if="showH5BuyModal" @click="showH5BuyModal = false">
<!-- 引力值不足弹窗 -->
<view class="modal-overlay" v-if="showGravityBuyModal" @click="showGravityBuyModal = false">
<view class="modal-content" @click.stop>
<text class="modal-title">引力值不足</text>
<text class="modal-hint">您的引力值不足请补充后继续面试每次面试消耗 5 引力值</text>
<view class="purchase-options">
<view class="purchase-option" @click="goH5BuyAndClose">
<text class="purchase-name">官网购买引力值</text>
<text class="purchase-price">前往网页版充值</text>
<text class="purchase-desc">打开官网 H5 页面支持多种支付方式</text>
<view class="purchase-option" @click="goGravityBuyAndClose">
<text class="purchase-name">购买引力值</text>
<text class="purchase-price">前往充值</text>
<text class="purchase-desc">小程序内直接购买微信支付安全便捷</text>
</view>
</view>
<text class="modal-close" @click="showH5BuyModal = false">取消</text>
<text class="modal-close" @click="showGravityBuyModal = false">取消</text>
</view>
</view>
</view>
@@ -136,7 +136,7 @@ const scrollToId = ref('')
const position = ref('')
const avatarMode = ref(true)
const showPositionPicker = ref(false)
const showH5BuyModal = ref(false)
const showGravityBuyModal = ref(false)
const positions = ref([])
const positionsLoading = ref(false)
const aiSpeechText = ref('')
@@ -326,25 +326,13 @@ function onAvatarSilent() {
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
// 官网购买引力值
const goH5Buy = () => {
showH5BuyModal.value = true
// 购买引力值
const goGravityBuy = () => {
showGravityBuyModal.value = true
}
const goH5BuyAndClose = () => {
showH5BuyModal.value = false
const token = uni.getStorageSync('token') || ''
const url = `https://zhiyin.yzrcloud.cn/?buy=gravity${token ? '&token=' + token : ''}`
// #ifdef MP-WEIXIN
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制,请在手机浏览器中打开', icon: 'none', duration: 3000 })
},
fail: () => {
uni.showToast({ title: '复制失败,请手动访问 zhiyin.yzrcloud.cn', icon: 'none', duration: 3000 })
},
})
// #endif
const goGravityBuyAndClose = () => {
showGravityBuyModal.value = false
uni.navigateTo({ url: '/pages/member/member' })
}
const scrollToBottom = () => {
nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) })
+4 -22
View File
@@ -53,7 +53,7 @@
<view class="gravity-actions">
<text class="gravity-btn share" @click="goSharePage">分享得引力值</text>
<text class="gravity-btn contribute" @click="goContributePage">贡献面经</text>
<text class="gravity-btn h5buy" @click="goH5Buy">官网购买</text>
<text class="gravity-btn h5buy" @click="goH5Buy">购买引力值</text>
</view>
</view>
</view>
@@ -77,14 +77,11 @@
<text class="menu-text">面试复盘</text>
<text class="menu-arrow"></text>
</view>
<!-- 会员中心菜单注释隐藏微信审核 -->
<!--
<view class="menu-item" @click="goVip">
<view class="menu-icon-wrap wrap-purple"><text class="menu-icon">💎</text></view>
<text class="menu-text">会员中心</text>
<text class="menu-arrow"></text>
</view>
-->
<view class="menu-item" @click="requireLogin(goResume, '我的简历')">
<view class="menu-icon-wrap wrap-green"><text class="menu-icon">📄</text></view>
<text class="menu-text">我的简历</text>
@@ -148,12 +145,12 @@
<text class="gp-method-arrow"></text>
</view>
<!-- 官网购买 -->
<!-- 购买引力值 -->
<view class="gp-get-method" @click="goH5Buy">
<view class="gp-method-icon-wrap"><text class="gp-method-icon">🛒</text></view>
<view class="gp-method-info">
<text class="gp-method-name">官网购买</text>
<text class="gp-method-desc">通过官网网页端可购买引力值套餐支持更多支付方式</text>
<text class="gp-method-name">购买引力值</text>
<text class="gp-method-desc">小程序内直接购买微信支付安全便捷</text>
</view>
<text class="gp-method-arrow"></text>
</view>
@@ -278,22 +275,7 @@ const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const showGetGravityModal = ref(false)
const openBuyModal = () => { showGetGravityModal.value = true }
const goH5Buy = () => {
const token = uni.getStorageSync('token') || ''
const url = `https://zhiyin.yzrcloud.cn/?buy=gravity${token ? '&token=' + token : ''}`
// #ifdef H5
uni.navigateTo({ url: '/pages/member/member' })
// #endif
// #ifdef MP-WEIXIN
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({ title: '链接已复制,请在手机浏览器中打开', icon: 'none', duration: 3000 })
},
fail: () => {
uni.showToast({ title: '复制失败,请手动访问 zhiyin.yzrcloud.cn', icon: 'none', duration: 3000 })
},
})
// #endif
}
const goCareer = () => uni.navigateTo({ url: '/pages/career/career' })
+6
View File
@@ -105,6 +105,12 @@ const apiService = {
request(API_ENDPOINTS.CAREER.CHAT, 'POST', { message, history }, true),
positions: () => request(API_ENDPOINTS.CAREER.POSITIONS, 'GET', undefined, true),
},
virtualPayment: {
create: (data: { type: string; quantity: number; wxCode: string }) =>
request(API_ENDPOINTS.VIRTUAL_PAYMENT.CREATE, 'POST', data, true),
check: (outTradeNo: string) =>
request(API_ENDPOINTS.VIRTUAL_PAYMENT.CHECK(outTradeNo), 'GET', undefined, true),
},
}
export default apiService