Files
MBTI_wang/miniprogram/utils/payment.js
Ghost a411bf7b21 高考版优化
审核模式优化
2026-04-29 18:01:26 +08:00

763 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// miniprogram/utils/payment.js
// 微信支付工具类 - 复刻自Soul项目
const app = getApp()
const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js')
const { triggerOrderPaid } = require('./pushHook.js')
function paymentApiBase() {
const b = (app.globalData && app.globalData.apiBase) ? String(app.globalData.apiBase) : ''
return b.replace(/\/$/, '')
}
/** 创建订单时传给后端的 enterpriseId0 表示无企业上下文);与个人/企业 Tab 一致 */
function enterpriseIdForOrder() {
const eid = getEnterpriseIdForApiPayload()
return eid != null && Number(eid) > 0 ? Number(eid) : 0
}
/**
* 生成符合微信规则的订单号
* 规则示例FACE20260226151900001前缀+时间戳+随机数最长不超过32位
* @param {String} productType - 业务类型,如 face/mbti/disc/pdp/report/team_analysis/vip 等
*/
function generateOrderId(productType) {
const now = new Date()
const pad = (n, len = 2) => String(n).padStart(len, '0')
const prefixMap = {
face: 'FACE',
mbti: 'MBTI',
disc: 'DISC',
pdp: 'PDP',
report: 'REPT',
team_analysis: 'TEAM',
vip: 'VIP',
test_count: 'TNUM',
single_test: 'TSGL',
recharge: 'RCG',
deep_personal: 'DPER',
deep_team: 'DTEAM',
gaokao: 'GAOKAO'
}
const prefix = prefixMap[productType] || (productType || 'ORD').toUpperCase().slice(0, 6)
const dateStr =
now.getFullYear().toString() +
pad(now.getMonth() + 1) +
pad(now.getDate()) +
pad(now.getHours()) +
pad(now.getMinutes()) +
pad(now.getSeconds())
// 3位随机数避免重复如 001~999
const rand = pad(Math.floor(Math.random() * 1000), 3)
const raw = `${prefix}${dateStr}${rand}` // 示例FACE20260226151900001
// 微信 out_trade_no 最长 32 字节,这里兜底截断
return raw.length > 32 ? raw.slice(0, 32) : raw
}
/**
* 发起微信支付
* @param {Object} options - 支付选项
* @param {String} options.orderId - 订单ID
* @param {Number} [options.amount] - 支付金额(分),部分产品使用(如充值/VIP/测试次数等);深度服务与按配置定价的产品传 0 或不传
* @param {String} options.description - 商品描述
* @param {String} options.productType - 商品类型: 'vip' | 'test_count' | 'single_test' | 'face' | 'mbti' 等
* @param {Number} [options.testResultId] - 可选,对应 mbti_test_results.id用于精确关联测试记录
* @param {String} [options.deepProductId] - 可选深度服务对应的套餐ID/产品Key用于后台从 categories 中选中具体价格
* @param {Function} options.success - 成功回调
* @param {Function} options.fail - 失败回调
*/
function wxPay(options) {
const {
orderId,
amount = 0,
description,
productType,
testResultId,
deepProductId,
enterpriseId,
pricingScope,
success,
fail
} = options
if (app.globalData && app.globalData.miniprogramAuditMode) {
const msg = '版本审核期间不可发起支付'
wx.showToast({ title: msg, icon: 'none' })
if (typeof fail === 'function') fail(new Error(msg))
return
}
try {
const analyticsMod = require('./analytics')
if (analyticsMod && typeof analyticsMod.track === 'function') {
if (productType === 'recharge') {
analyticsMod.track('click_recharge', { action: '点击充值并发起支付', productType: 'recharge' })
} else {
analyticsMod.track('click_pay', { action: '发起支付', productType: productType || '' })
}
if (typeof analyticsMod.flush === 'function') {
analyticsMod.flush()
}
}
} catch (e) {}
wx.showLoading({
title: '正在支付...',
mask: true
})
// 1. 调用后端创建支付订单
wx.request({
url: `${paymentApiBase()}/api/payment/create`,
method: 'POST',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`,
'Content-Type': 'application/json'
},
data: {
orderId,
amount,
description,
productType,
paymentMethod: 'wechat',
openId: app.globalData.openId || '',
enterpriseId: enterpriseId || 0,
// 创建订单时将本次测试记录ID传给后端避免每次都只更新“最新一条”
testResultId: testResultId || 0,
// 深度服务使用的具体套餐ID/产品Key用于从 categories 中选择价格)
deepProductId: deepProductId || '',
// 高考志愿单GaokaoService 仍用 personal|enterprise高考 Tab 下其它测评单传 gaokao与 runtime scope 一致
pricingScope: (() => {
if (pricingScope) return pricingScope
const tabScope = (app.globalData && app.globalData.appScope) || 'personal'
if (productType === 'gaokao') {
return tabScope === 'enterprise' ? 'enterprise' : 'personal'
}
return tabScope === 'gaokao' ? 'gaokao' : ''
})()
},
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200 && res.data.code === 200) {
const paymentData = res.data.data
// 后端 0 元单(如高考志愿定价为 0不调 wx.requestPayment直接视为成功
if (paymentData && paymentData.skipWxPay) {
wx.hideLoading()
try {
triggerOrderPaid(orderId)
} catch (e) {}
try {
require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount: 0, note: 'zero_skip_wx' })
} catch (e) {}
wx.showToast({ title: '已解锁', icon: 'success', duration: 2000 })
success && success({ skipWxPay: true, order: paymentData })
return
}
// 2. 调起微信支付
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'MD5',
paySign: paymentData.paySign,
success: (payRes) => {
console.log('支付成功', payRes)
// 按照微信文档推荐,仅依赖查询接口确认支付结果,不做本地立即标记
// 支付成功后轮询后端订单状态 3~5 次,确保通过微信订单查询接口确认成功
pollOrderStatus(orderId, 5, 1000, (ok, order) => {
if (ok) {
try { reportCrmTestPaymentAfterSuccess(productType, testResultId, enterpriseId || 0) } catch (e) {}
try { triggerOrderPaid(orderId) } catch (e) {}
try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount }) } catch (e) {}
wx.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
success && success({ payRes, order })
} else {
try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount, note: 'poll_pending' }) } catch (e) {}
wx.showToast({
title: '支付结果处理中,请稍后在历史中查看',
icon: 'none',
duration: 2500
})
success && success({ payRes, order: null })
}
})
},
fail: (payErr) => {
console.error('支付失败', payErr)
const isCancel = payErr.errMsg && payErr.errMsg.indexOf('cancel') !== -1
try { require('./analytics').reportPayResult(false, { productType: productType || '', orderId, reason: isCancel ? 'cancel' : 'fail' }) } catch (e) {}
if (isCancel) {
wx.showToast({
title: '支付已取消',
icon: 'none'
})
} else {
wx.showToast({
title: '支付失败',
icon: 'none'
})
}
fail && fail(payErr)
}
})
} else {
wx.showToast({
title: res.data.message || '创建订单失败',
icon: 'none'
})
fail && fail(res)
}
},
fail: (err) => {
wx.hideLoading()
console.error('请求失败', err)
wx.showToast({
title: '网络请求失败',
icon: 'none'
})
fail && fail(err)
}
})
}
/**
* 通知后端支付成功
*/
function notifyPaymentSuccess(orderId, prepayId) {
wx.request({
url: `${paymentApiBase()}/api/payment/notify`,
method: 'POST',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`,
'Content-Type': 'application/json'
},
data: {
orderId,
prepayId,
status: 'success'
},
success: (res) => {
console.log('支付通知成功', res)
},
fail: (err) => {
console.error('支付通知失败', err)
}
})
}
/**
* 查询订单状态
*/
function queryOrderStatus(orderId, callback) {
wx.request({
url: `${paymentApiBase()}/api/payment/query`,
method: 'GET',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
data: { orderId },
success: (res) => {
if (res.statusCode === 200 && res.data.code === 200) {
callback && callback(true, res.data.data)
} else {
callback && callback(false, null)
}
},
fail: () => {
callback && callback(false, null)
}
})
}
/**
* 按照微信文档建议:支付成功后,基于商户订单号轮询查询订单状态(本项目由后端代理查询)
* @param {String} orderId 商户订单号(如 FACE20260226151900001
* @param {Number} maxAttempts 最大轮询次数
* @param {Number} intervalMs 间隔毫秒
* @param {Function} done 回调 (ok:boolean, order?:object)
*/
function pollOrderStatus(orderId, maxAttempts = 5, intervalMs = 1000, done) {
let attempts = 0
const tick = () => {
attempts += 1
queryOrderStatus(orderId, (ok, order) => {
if (ok && order && (order.status === 'paid' || order.status === 'completed')) {
done && done(true, order)
return
}
if (attempts >= maxAttempts) {
done && done(false, order || null)
return
}
setTimeout(tick, intervalMs)
})
}
tick()
}
/**
* 测评类付费(人脸/MBTI/PDP/DISC支付确认成功后上报存客宝线索KEY 由后端按企业 cunkebao_keys 解析(对齐深度服务「完成付款」)
*/
function reportCrmTestPaymentAfterSuccess(productType, testResultId, contextEnterpriseId) {
const types = { face: true, mbti: true, disc: true, pdp: true }
if (!types[productType]) return
const token = wx.getStorageSync('token')
if (!token) return
wx.request({
url: `${paymentApiBase()}/api/crm/report`,
method: 'POST',
header: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: {
testType: productType,
testResultId: testResultId || 0,
contextEnterpriseId: contextEnterpriseId != null ? Number(contextEnterpriseId) : 0
},
success(res) {
console.log('[CRM] 测评付费线索', res.data)
},
fail(err) {
console.warn('[CRM] 测评付费线索请求失败', err)
}
})
}
/**
* 购买VIP会员
* @param {String} vipType - 'month' | 'quarter' | 'year' | 'lifetime'
*/
function purchaseVIP(vipType, success, fail) {
const prices = {
month: 1990, // 19.9元
quarter: 4990, // 49.9元
year: 9900, // 99元
lifetime: 19900, // 199元
personal_insight: 19800 // 198元 - 个人深度洞察版
}
const names = {
month: '月度VIP会员',
quarter: '季度VIP会员',
year: '年度VIP会员',
lifetime: '终身VIP会员',
personal_insight: '个人深度洞察版'
}
const orderId = generateOrderId('vip')
wxPay({
orderId,
amount: prices[vipType],
description: `MBTI性格测试 - ${names[vipType]}`,
productType: 'vip',
success: (res) => {
// 更新VIP状态
updateVIPStatus(vipType)
success && success(res)
},
fail
})
}
/**
* 购买测试次数
* @param {Number} count - 购买次数
*/
function purchaseTestCount(count, success, fail) {
// 单次价格 3.9元10次29元50次99元
let price = count * 390
if (count >= 10) price = Math.floor(count * 290)
if (count >= 50) price = Math.floor(count * 198)
const orderId = generateOrderId('test_count')
wxPay({
orderId,
amount: price,
description: `MBTI性格测试 - ${count}次测试次数`,
productType: 'test_count',
success: (res) => {
// 更新测试次数
addTestCount(count)
success && success(res)
},
fail
})
}
/**
* 购买单次测试
* @param {String} testType - 'mbti' | 'disc' | 'pdp' | 'ai'
*/
function purchaseSingleTest(testType, success, fail) {
const prices = {
mbti: 990, // 9.9元
disc: 690, // 6.9元
pdp: 690, // 6.9元
ai: 1990 // 19.9元
}
const names = {
mbti: 'MBTI性格测试',
disc: 'DISC行为风格测试',
pdp: 'PDP动物性格测试',
ai: 'AI人脸性格分析'
}
const orderId = generateOrderId(`single_${testType}`)
wxPay({
orderId,
amount: prices[testType],
description: names[testType],
productType: 'single_test',
success: (res) => {
// 解锁该测试
unlockTest(testType)
success && success(res)
},
fail
})
}
/**
* 使用后台定价购买单项测试/报告
* 支持:人脸测试/MBTI测试/DISC测试/PDP测试/完整报告/团队分析
* @param {String} productType - 'face' | 'mbti' | 'disc' | 'pdp' | 'report' | 'team_analysis'
* @param {String} description - 商品描述
* @param {Function|Object} extra - 兼容两种调用方式:
* - purchaseByPricing('face', 'xxx', success, fail)
* - purchaseByPricing('face', 'xxx', { testResultId, success, fail })
*/
function purchaseByPricing(productType, description, extra, maybeFail) {
let opts = {}
if (typeof extra === 'function' || extra === undefined) {
opts.success = extra
opts.fail = maybeFail
} else {
opts = extra || {}
}
const { testResultId, success, fail, pricingScope } = opts
const orderId = generateOrderId(productType)
wxPay({
orderId,
amount: 0, // 金额交由后端根据定价配置计算personal/enterprise
description,
productType,
testResultId,
enterpriseId: enterpriseIdForOrder(),
pricingScope,
success,
fail
})
}
// 人脸测试完整报告
// 兼容两种调用方式:
// - purchaseFaceTest(success, fail)
// - purchaseFaceTest({ testResultId, success, fail })
function purchaseFaceTest(arg1, arg2) {
let opts = {}
if (typeof arg1 === 'function' || arg1 === undefined) {
opts.success = arg1
opts.fail = arg2
} else {
opts = arg1 || {}
}
const { testResultId, success, fail } = opts
purchaseByPricing('face', 'AI人脸性格分析完整报告', { testResultId, success, fail })
}
// MBTI测试付费版支持 purchaseMbtiTest({ testResultId, success, fail })
function purchaseMbtiTest(arg1, arg2) {
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
const { testResultId, success, fail } = opts
purchaseByPricing('mbti', 'MBTI性格测试付费版', { testResultId, success, fail })
}
// DISC测试付费版
function purchaseDiscTest(arg1, arg2) {
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
const { testResultId, success, fail } = opts
purchaseByPricing('disc', 'DISC行为风格测试付费版', { testResultId, success, fail })
}
// PDP测试付费版
function purchasePdpTest(arg1, arg2) {
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
const { testResultId, success, fail } = opts
purchaseByPricing('pdp', 'PDP动物性格测试付费版', { testResultId, success, fail })
}
// SBTI 测试付费版
function purchaseSbtiTest(arg1, arg2) {
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
const { testResultId, success, fail } = opts
purchaseByPricing('sbti', 'SBTI 性格测试付费版', { testResultId, success, fail })
}
// 简历综合分析付费版(支持 purchaseResumeAnalysis({ testResultId, success, fail })
function purchaseResumeAnalysis(arg1, arg2) {
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
const { testResultId, success, fail } = opts
purchaseByPricing('resume', '简历综合分析付费版', { testResultId, success, fail })
}
// 完整报告整合MBTI/DISC/PDP/AI人脸等深度解读
function purchaseFullReport(success, fail) {
purchaseByPricing('report', '完整人格与职业发展报告', success, fail)
}
// 团队分析服务
function purchaseTeamAnalysis(success, fail) {
purchaseByPricing('team_analysis', '团队性格组合与冲突分析服务', success, fail)
}
/** 高考志愿分析报告(统一定价键 gaokao走 orders + test_results */
function purchaseGaokaoReport(extra, maybeFail) {
let opts = {}
if (typeof extra === 'function' || extra === undefined) {
opts.success = extra
opts.fail = maybeFail
} else {
opts = extra || {}
}
const { success, fail, testResultId } = opts
const pricingScope =
(app.globalData && app.globalData.appScope) === 'enterprise' ? 'enterprise' : 'personal'
purchaseByPricing('gaokao', '高考志愿分析报告', {
success,
fail,
testResultId,
pricingScope
})
}
/**
* 企业充值
* 支持两种调用:
* - recharge(100, success, fail)
* - recharge({ amountYuan: 100, enterpriseId: 6, success, fail })
*/
function recharge(arg1, arg2, arg3) {
const opts = (typeof arg1 === 'object' && arg1 !== null)
? arg1
: { amountYuan: arg1, success: arg2, fail: arg3 }
const safeAmount = Number(opts.amountYuan) || 0
const amount = Math.round(safeAmount * 100)
const orderId = generateOrderId('recharge')
wxPay({
orderId,
amount,
description: `账户充值 ¥${safeAmount.toFixed(2)}`,
productType: 'recharge',
enterpriseId: opts.enterpriseId || 0,
success: opts.success,
fail: opts.fail
})
}
/**
* 个人深度服务(价格从 PricingConfig deep_personal.categories 中读取)
* 支持两种调用方式:
* - purchasePersonalDeepService(deepProductId, success, fail)
* - purchasePersonalDeepService({ deepProductId, description, success, fail })
* @param {String|Object} arg1
* @param {Function} [arg2]
* @param {Function} [arg3]
*/
function purchasePersonalDeepService(arg1, arg2, arg3) {
let deepProductId = ''
let description = ''
let success
let fail
if (typeof arg1 === 'object' && arg1 !== null) {
deepProductId = arg1.deepProductId || ''
description = arg1.description || ''
success = arg1.success
fail = arg1.fail
} else {
deepProductId = typeof arg1 === 'string' ? arg1 : ''
success = arg2
fail = arg3
}
// 默认标题兜底
const desc = description || '个人深度服务1v1深度解读'
const orderId = generateOrderId('deep_personal')
wxPay({
orderId,
amount: 0,
description: desc,
productType: 'deep_personal',
deepProductId,
enterpriseId: enterpriseIdForOrder(),
success,
fail
})
}
/**
* 团队深度服务(价格从 PricingConfig.deep.team 读取)
*/
function purchaseTeamDeepService(success, fail) {
const orderId = generateOrderId('deep_team')
wxPay({
orderId,
amount: 0,
description: '团队深度服务(团队画像+策略)',
productType: 'deep_team',
enterpriseId: enterpriseIdForOrder(),
success,
fail
})
}
/**
* 更新VIP状态
*/
function updateVIPStatus(vipType) {
const durations = {
month: 30,
quarter: 90,
year: 365,
lifetime: 36500 // 100年
}
const expireDate = new Date()
expireDate.setDate(expireDate.getDate() + durations[vipType])
const vipInfo = {
isVIP: true,
vipType,
expireDate: expireDate.toISOString(),
purchaseDate: new Date().toISOString()
}
wx.setStorageSync('vipInfo', vipInfo)
app.globalData.vipInfo = vipInfo
}
/**
* 增加测试次数
*/
function addTestCount(count) {
const currentCount = wx.getStorageSync('testCount') || 0
const newCount = currentCount + count
wx.setStorageSync('testCount', newCount)
app.globalData.testCount = newCount
}
/**
* 解锁单次测试
*/
function unlockTest(testType) {
const unlockedTests = wx.getStorageSync('unlockedTests') || []
if (!unlockedTests.includes(testType)) {
unlockedTests.push(testType)
wx.setStorageSync('unlockedTests', unlockedTests)
}
app.globalData.unlockedTests = unlockedTests
}
/**
* 检查是否是VIP
*/
function checkVIP() {
const vipInfo = wx.getStorageSync('vipInfo')
if (!vipInfo || !vipInfo.isVIP) return false
const expireDate = new Date(vipInfo.expireDate)
return expireDate > new Date()
}
/**
* 检查测试是否可用VIP或已解锁或有测试次数
*/
function canTakeTest(testType) {
// 临时策略:所有测试免费开放,直接返回 true
// 保留原有支付与权益逻辑,后续若恢复收费可还原为 VIP/解锁/次数判断
return true
}
/**
* 消耗一次测试次数
*/
function consumeTestCount() {
const testCount = wx.getStorageSync('testCount') || 0
if (testCount > 0) {
wx.setStorageSync('testCount', testCount - 1)
app.globalData.testCount = testCount - 1
return true
}
return false
}
/**
* 获取用户权益信息
*/
function getUserBenefits() {
return {
isVIP: checkVIP(),
vipInfo: wx.getStorageSync('vipInfo') || null,
testCount: wx.getStorageSync('testCount') || 0,
unlockedTests: wx.getStorageSync('unlockedTests') || []
}
}
module.exports = {
wxPay,
queryOrderStatus,
pollOrderStatus,
generateOrderId,
purchaseVIP,
purchaseTestCount,
purchaseSingleTest,
// 基于后台定价的购买入口(测试/报告/团队分析/深度服务/充值)
purchaseFaceTest,
purchaseMbtiTest,
purchaseDiscTest,
purchasePdpTest,
purchaseSbtiTest,
purchaseResumeAnalysis,
purchaseFullReport,
purchaseTeamAnalysis,
purchaseGaokaoReport,
recharge,
purchasePersonalDeepService,
purchaseTeamDeepService,
checkVIP,
canTakeTest,
consumeTestCount,
getUserBenefits,
updateVIPStatus,
addTestCount,
unlockTest
}