补充 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 { ShareModule } from './modules/share/share.module'
import { InterviewReviewModule } from './modules/interview-review/interview-review.module' import { InterviewReviewModule } from './modules/interview-review/interview-review.module'
import { CareerAdviceModule } from './modules/career-advice/career-advice.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' 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, ShareModule,
InterviewReviewModule, InterviewReviewModule,
CareerAdviceModule, CareerAdviceModule,
VirtualPaymentModule,
], ],
providers: [ providers: [
JwtStrategy, JwtStrategy,
@@ -428,8 +428,8 @@ const DEFAULT_CONFIG = {
} }
const DEFAULT_PRICING = { const DEFAULT_PRICING = {
interview: { pricePerSession: 500, creditsPerPurchase: 1 }, interview: { pricePerSession: 600, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 }, resumeOptimize: { freeLimit: 3, pricePerOptimize: 400, creditsPerPurchase: 1 },
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 }, resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
plans: { plans: {
growth: { growth: {
@@ -36,12 +36,12 @@ describe('PaymentController', () => {
} }
mockPricingService = { mockPricingService = {
getConfig: jest.fn().mockResolvedValue({ getConfig: jest.fn().mockResolvedValue({
interview: { pricePerSession: 500, creditsPerPurchase: 1 }, interview: { pricePerSession: 600, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 }, resumeOptimize: { freeLimit: 3, pricePerOptimize: 400, creditsPerPurchase: 1 },
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 }, resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
plans: { plans: {
growth: { price: 1990, durationDays: 30, gravityPerMonth: 250, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] }, growth: { price: 1990, durationDays: 30, gravityPerMonth: 80, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
sprint: { price: 4990, durationDays: 30, gravityPerMonth: 600, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, 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(result.plan).toBe('growth')
expect(mockUser.save).toHaveBeenCalled() expect(mockUser.save).toHaveBeenCalled()
expect(mockUser.plan).toBe('growth') expect(mockUser.plan).toBe('growth')
expect(mockUser.gravity).toBe(250) expect(mockUser.gravity).toBe(80)
expect(mockUser.freeOptimizeUsed).toBe(3) expect(mockUser.freeOptimizeUsed).toBe(3)
}) })
@@ -81,31 +81,41 @@ export function useGravityPurchase(onPaymentSuccess?: () => void) {
if (isMp.value) { if (isMp.value) {
try { try {
let res = await uni.request({ const loginRes = await new Promise<{ code: string }>((resolve, reject) => {
url: api('/payment/jsapi-product'), method: 'POST', uni.login({ provider: 'weixin', success: r => resolve(r), fail: reject })
data: { type, quantity }, })
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' }, header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
timeout: 30000, timeout: 30000,
}) })
payLoading.value = false payLoading.value = false
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.payParams) { if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.paySig) {
const pp = res.data.payParams as any const vp = res.data as any
currentOutTradeNo.value = res.data.outTradeNo || '' currentOutTradeNo.value = vp.outTradeNo
uni.requestPayment({
provider: 'wxpay', ;(wx as any).requestVirtualPayment({
timeStamp: pp.timeStamp, env: vp.env,
nonceStr: pp.nonceStr, offerId: vp.offerId,
package: pp.package, signData: vp.signData,
signType: pp.signType || 'RSA', paySig: vp.paySig,
paySign: pp.paySign, signature: vp.signature,
success: () => { success: () => {
const no = currentOutTradeNo.value || res.data.outTradeNo const no = currentOutTradeNo.value || vp.outTradeNo
pollPayResult(no) pollPayResult(no)
}, },
fail: () => { fail: (err: any) => {
payError.value = '支付未完成' payError.value = '支付未完成'
uni.showToast({ title: '支付未完成', icon: 'none' }) uni.showToast({ title: err?.errMsg || '支付未完成', icon: 'none' })
}, },
}) })
} else if (!res.statusCode || res.statusCode === 0) { } else if (!res.statusCode || res.statusCode === 0) {
@@ -116,9 +126,9 @@ export function useGravityPurchase(onPaymentSuccess?: () => void) {
payError.value = errMsg payError.value = errMsg
uni.showToast({ title: errMsg, icon: 'none' }) uni.showToast({ title: errMsg, icon: 'none' })
} }
} catch (e) { } catch (e: any) {
payLoading.value = false payLoading.value = false
payError.value = '网络错误,请重试' payError.value = e?.errMsg || '网络错误,请重试'
uni.showToast({ title: '网络错误', icon: 'none' }) uni.showToast({ title: '网络错误', icon: 'none' })
} }
} else { } else {
+4
View File
@@ -122,6 +122,10 @@ export const API_ENDPOINTS = {
CHAT: '/career-advice/chat', CHAT: '/career-advice/chat',
POSITIONS: '/career-advice/positions', POSITIONS: '/career-advice/positions',
}, },
VIRTUAL_PAYMENT: {
CREATE: '/virtual-payment/create',
CHECK: (outTradeNo: string) => `/virtual-payment/check/${outTradeNo}`,
},
} as const } as const
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn' 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> <view class="complete-bar" v-else>
<button class="cta-btn" @click="goResult">查看面试报告</button> <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>
<!-- 官网购买弹窗 --> <!-- 引力值不足弹窗 -->
<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> <view class="modal-content" @click.stop>
<text class="modal-title">引力值不足</text> <text class="modal-title">引力值不足</text>
<text class="modal-hint">您的引力值不足请补充后继续面试每次面试消耗 5 引力值</text> <text class="modal-hint">您的引力值不足请补充后继续面试每次面试消耗 5 引力值</text>
<view class="purchase-options"> <view class="purchase-options">
<view class="purchase-option" @click="goH5BuyAndClose"> <view class="purchase-option" @click="goGravityBuyAndClose">
<text class="purchase-name">官网购买引力值</text> <text class="purchase-name">购买引力值</text>
<text class="purchase-price">前往网页版充值</text> <text class="purchase-price">前往充值</text>
<text class="purchase-desc">打开官网 H5 页面支持多种支付方式</text> <text class="purchase-desc">小程序内直接购买微信支付安全便捷</text>
</view> </view>
</view> </view>
<text class="modal-close" @click="showH5BuyModal = false">取消</text> <text class="modal-close" @click="showGravityBuyModal = false">取消</text>
</view> </view>
</view> </view>
</view> </view>
@@ -136,7 +136,7 @@ const scrollToId = ref('')
const position = ref('') const position = ref('')
const avatarMode = ref(true) const avatarMode = ref(true)
const showPositionPicker = ref(false) const showPositionPicker = ref(false)
const showH5BuyModal = ref(false) const showGravityBuyModal = ref(false)
const positions = ref([]) const positions = ref([])
const positionsLoading = ref(false) const positionsLoading = ref(false)
const aiSpeechText = ref('') const aiSpeechText = ref('')
@@ -326,25 +326,13 @@ function onAvatarSilent() {
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` }) const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
// 官网购买引力值 // 购买引力值
const goH5Buy = () => { const goGravityBuy = () => {
showH5BuyModal.value = true showGravityBuyModal.value = true
} }
const goH5BuyAndClose = () => { const goGravityBuyAndClose = () => {
showH5BuyModal.value = false showGravityBuyModal.value = false
const token = uni.getStorageSync('token') || '' uni.navigateTo({ url: '/pages/member/member' })
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 scrollToBottom = () => { const scrollToBottom = () => {
nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) }) nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) })
+4 -22
View File
@@ -53,7 +53,7 @@
<view class="gravity-actions"> <view class="gravity-actions">
<text class="gravity-btn share" @click="goSharePage">分享得引力值</text> <text class="gravity-btn share" @click="goSharePage">分享得引力值</text>
<text class="gravity-btn contribute" @click="goContributePage">贡献面经</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> </view>
</view> </view>
@@ -77,14 +77,11 @@
<text class="menu-text">面试复盘</text> <text class="menu-text">面试复盘</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
<!-- 会员中心菜单注释隐藏微信审核 -->
<!--
<view class="menu-item" @click="goVip"> <view class="menu-item" @click="goVip">
<view class="menu-icon-wrap wrap-purple"><text class="menu-icon">💎</text></view> <view class="menu-icon-wrap wrap-purple"><text class="menu-icon">💎</text></view>
<text class="menu-text">会员中心</text> <text class="menu-text">会员中心</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
-->
<view class="menu-item" @click="requireLogin(goResume, '我的简历')"> <view class="menu-item" @click="requireLogin(goResume, '我的简历')">
<view class="menu-icon-wrap wrap-green"><text class="menu-icon">📄</text></view> <view class="menu-icon-wrap wrap-green"><text class="menu-icon">📄</text></view>
<text class="menu-text">我的简历</text> <text class="menu-text">我的简历</text>
@@ -148,12 +145,12 @@
<text class="gp-method-arrow"></text> <text class="gp-method-arrow"></text>
</view> </view>
<!-- 官网购买 --> <!-- 购买引力值 -->
<view class="gp-get-method" @click="goH5Buy"> <view class="gp-get-method" @click="goH5Buy">
<view class="gp-method-icon-wrap"><text class="gp-method-icon">🛒</text></view> <view class="gp-method-icon-wrap"><text class="gp-method-icon">🛒</text></view>
<view class="gp-method-info"> <view class="gp-method-info">
<text class="gp-method-name">官网购买</text> <text class="gp-method-name">购买引力值</text>
<text class="gp-method-desc">通过官网网页端可购买引力值套餐支持更多支付方式</text> <text class="gp-method-desc">小程序内直接购买微信支付安全便捷</text>
</view> </view>
<text class="gp-method-arrow"></text> <text class="gp-method-arrow"></text>
</view> </view>
@@ -278,22 +275,7 @@ const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const showGetGravityModal = ref(false) const showGetGravityModal = ref(false)
const openBuyModal = () => { showGetGravityModal.value = true } const openBuyModal = () => { showGetGravityModal.value = true }
const goH5Buy = () => { 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' }) 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' }) 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), request(API_ENDPOINTS.CAREER.CHAT, 'POST', { message, history }, true),
positions: () => request(API_ENDPOINTS.CAREER.POSITIONS, 'GET', undefined, 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 export default apiService