feat: 管理端聚合页、小程序/抖音埋点与统计、飞书线索 webhook、API 迁移与路由
- admin:OrdersHub/UsersHub、Commerce/Ops/Enterprise Hub、MpAnalytics、Feishu/小程序配置面板、鉴权存储 - api:Analytics、DataMigration、FeishuLeadWebhook、mp 事件迁移 SQL - 微信/抖音小程序:analytics 上报与相关页面调整 - 开发文档与 scripts 补充 Made-with: Cursor
This commit is contained in:
74
miniprogram/utils/analytics.js
Normal file
74
miniprogram/utils/analytics.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 小程序埋点:批量上报 /api/analytics/events(可选登录,便于超管后台关联用户)
|
||||
*/
|
||||
const { request } = require('./request.js')
|
||||
|
||||
const MAX_BATCH = 30
|
||||
const queue = []
|
||||
let lastPageReport = { path: '', t: 0 }
|
||||
|
||||
function getAppSafe() {
|
||||
try {
|
||||
return getApp()
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentRoute() {
|
||||
const pages = getCurrentPages()
|
||||
const p = pages[pages.length - 1]
|
||||
return p && p.route ? p.route : ''
|
||||
}
|
||||
|
||||
function track(eventName, props) {
|
||||
if (!eventName || typeof eventName !== 'string') return
|
||||
const app = getAppSafe()
|
||||
const openId =
|
||||
app && app.globalData
|
||||
? (app.globalData.openId || (app.globalData.userInfo && (app.globalData.userInfo.openid || app.globalData.userInfo.openId)) || '')
|
||||
: ''
|
||||
queue.push({
|
||||
event_name: eventName,
|
||||
page_path: getCurrentRoute(),
|
||||
props: props && typeof props === 'object' ? props : {},
|
||||
client_ts: Date.now(),
|
||||
openid: openId || undefined
|
||||
})
|
||||
if (queue.length >= MAX_BATCH) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面曝光(防抖:同路径 2 秒内只记一次) */
|
||||
function reportPageView() {
|
||||
const path = getCurrentRoute()
|
||||
if (!path) return
|
||||
const now = Date.now()
|
||||
if (path === lastPageReport.path && now - lastPageReport.t < 2000) {
|
||||
return
|
||||
}
|
||||
lastPageReport = { path, t: now }
|
||||
track('page_view', { path })
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (queue.length === 0) return
|
||||
const events = queue.splice(0, queue.length)
|
||||
request({
|
||||
url: '/api/analytics/events',
|
||||
method: 'POST',
|
||||
needAuth: true,
|
||||
data: { events },
|
||||
allow401: false,
|
||||
success() {},
|
||||
fail() {}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
track,
|
||||
flush,
|
||||
reportPageView,
|
||||
getCurrentRoute
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
const app = getApp()
|
||||
const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js')
|
||||
|
||||
function paymentApiBase() {
|
||||
const b = (app.globalData && app.globalData.apiBase) ? String(app.globalData.apiBase) : ''
|
||||
return b.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/** 创建订单时传给后端的 enterpriseId(0 表示无企业上下文);与个人/企业 Tab 一致 */
|
||||
function enterpriseIdForOrder() {
|
||||
const eid = getEnterpriseIdForApiPayload()
|
||||
@@ -64,7 +69,21 @@ function generateOrderId(productType) {
|
||||
*/
|
||||
function wxPay(options) {
|
||||
const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options
|
||||
|
||||
|
||||
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
|
||||
@@ -72,7 +91,7 @@ function wxPay(options) {
|
||||
|
||||
// 1. 调用后端创建支付订单
|
||||
wx.request({
|
||||
url: `${app.globalData.apiBase}/api/payment/create`,
|
||||
url: `${paymentApiBase()}/api/payment/create`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${wx.getStorageSync('token')}`,
|
||||
@@ -174,7 +193,7 @@ function wxPay(options) {
|
||||
*/
|
||||
function notifyPaymentSuccess(orderId, prepayId) {
|
||||
wx.request({
|
||||
url: `${app.globalData.apiBase}/api/payment/notify`,
|
||||
url: `${paymentApiBase()}/api/payment/notify`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': `Bearer ${wx.getStorageSync('token')}`,
|
||||
@@ -199,7 +218,7 @@ function notifyPaymentSuccess(orderId, prepayId) {
|
||||
*/
|
||||
function queryOrderStatus(orderId, callback) {
|
||||
wx.request({
|
||||
url: `${app.globalData.apiBase}/api/payment/query`,
|
||||
url: `${paymentApiBase()}/api/payment/query`,
|
||||
method: 'GET',
|
||||
header: {
|
||||
'Authorization': `Bearer ${wx.getStorageSync('token')}`
|
||||
|
||||
@@ -1,113 +1,116 @@
|
||||
/**
|
||||
* 手机号授权工具:基于微信 getPhoneNumber + 服务器端换取手机号接口
|
||||
* 个人资料完整性检查:头像、昵称、手机号为必填,生日和性别选填
|
||||
*/
|
||||
|
||||
/**
|
||||
* 个人资料是否已完善(头像、昵称、手机号必填,生日和性别选填)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProfileComplete() {
|
||||
const app = getApp()
|
||||
const user = app.globalData.userInfo || wx.getStorageSync('userInfo')
|
||||
if (!user) return false
|
||||
const avatar = (user.avatar || user.avatarUrl || '').trim()
|
||||
const nickname = (user.nickname || user.nickName || '').trim()
|
||||
const phone = (user.phone || user.phoneNumber || '').trim()
|
||||
return avatar.length > 0 && nickname.length > 0 && phone.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 若资料未完善则跳转到个人资料页,需登录
|
||||
* @returns {boolean} true=已完善可继续,false=已跳转
|
||||
*/
|
||||
function ensureProfileCompleteAndRedirect() {
|
||||
const app = getApp()
|
||||
const token = app.globalData.token || wx.getStorageSync('token')
|
||||
if (!token) return true
|
||||
if (isProfileComplete()) return true
|
||||
wx.showToast({ title: '请先完善个人资料', icon: 'none' })
|
||||
wx.navigateTo({ url: '/pages/user-profile/index' })
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户是否已有手机号(从 globalData.userInfo 或 storage 读取)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasPhone() {
|
||||
const app = getApp()
|
||||
const user = app.globalData.userInfo || wx.getStorageSync('userInfo')
|
||||
const phone = (user && (user.phone || user.phoneNumber)) ? String(user.phone || user.phoneNumber).trim() : ''
|
||||
return phone.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 getPhoneNumber 回调里的 code 调用后端接口换取手机号,并写回 userInfo
|
||||
* @param {string} code
|
||||
* @returns {Promise<object>} resolve 为更新后的 userInfo
|
||||
*/
|
||||
function bindPhoneByCode(code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!code) {
|
||||
wx.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
reject(new Error('empty code'))
|
||||
return
|
||||
}
|
||||
|
||||
const app = getApp()
|
||||
const token = app.globalData.token || wx.getStorageSync('token')
|
||||
if (!token) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
reject(new Error('no token'))
|
||||
return
|
||||
}
|
||||
|
||||
const apiBase = app.globalData.apiBase || ''
|
||||
if (!apiBase) {
|
||||
wx.showToast({ title: '服务未配置', icon: 'none' })
|
||||
reject(new Error('no api base'))
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '处理中...', mask: true })
|
||||
wx.request({
|
||||
url: `${apiBase.replace(/\/$/, '')}/api/auth/wechat/phone`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: { code },
|
||||
success: (res) => {
|
||||
wx.hideLoading()
|
||||
if (res.statusCode === 200 && res.data && res.data.code === 200) {
|
||||
const data = res.data.data || {}
|
||||
const user = data.user || app.globalData.userInfo || {}
|
||||
const phone = data.phone || user.phone || ''
|
||||
const newUser = { ...user, phone }
|
||||
app.globalData.userInfo = newUser
|
||||
wx.setStorageSync('userInfo', newUser)
|
||||
wx.showToast({ title: '授权成功', icon: 'success' })
|
||||
resolve(newUser)
|
||||
} else {
|
||||
const msg = res.data && res.data.message ? res.data.message : '获取手机号失败'
|
||||
wx.showToast({ title: msg, icon: 'none' })
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '网络请求失败', icon: 'none' })
|
||||
reject(new Error('network error'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasPhone,
|
||||
bindPhoneByCode,
|
||||
isProfileComplete,
|
||||
ensureProfileCompleteAndRedirect,
|
||||
}
|
||||
/**
|
||||
* 手机号授权工具:基于微信 getPhoneNumber + 服务器端换取手机号接口
|
||||
* 手机号与个人资料:详见 isProfileComplete 规则
|
||||
*/
|
||||
|
||||
/**
|
||||
* 个人资料是否已满足业务门禁(避免反复跳转「完善资料」)
|
||||
* - 已绑定手机号:视为可用(付费/深度服务以手机为准;仅首字头像无 URL 不再卡死)
|
||||
* - 未绑手机:需昵称+头像,引导去资料页补齐并授权手机
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProfileComplete() {
|
||||
const app = getApp()
|
||||
const user = app.globalData.userInfo || wx.getStorageSync('userInfo')
|
||||
if (!user) return false
|
||||
const phone = (user.phone || user.phoneNumber || '').trim()
|
||||
if (phone.length > 0) return true
|
||||
const nickname = (user.nickname || user.nickName || user.username || '').trim()
|
||||
const avatar = (user.avatar || user.avatarUrl || '').trim()
|
||||
return nickname.length > 0 && avatar.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 若资料未完善则跳转到个人资料页,需登录
|
||||
* @returns {boolean} true=已完善可继续,false=已跳转
|
||||
*/
|
||||
function ensureProfileCompleteAndRedirect() {
|
||||
const app = getApp()
|
||||
const token = app.globalData.token || wx.getStorageSync('token')
|
||||
if (!token) return true
|
||||
if (isProfileComplete()) return true
|
||||
wx.showToast({ title: '请先完善个人资料', icon: 'none' })
|
||||
wx.navigateTo({ url: '/pages/user-profile/index' })
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户是否已有手机号(从 globalData.userInfo 或 storage 读取)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasPhone() {
|
||||
const app = getApp()
|
||||
const user = app.globalData.userInfo || wx.getStorageSync('userInfo')
|
||||
const phone = (user && (user.phone || user.phoneNumber)) ? String(user.phone || user.phoneNumber).trim() : ''
|
||||
return phone.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 getPhoneNumber 回调里的 code 调用后端接口换取手机号,并写回 userInfo
|
||||
* @param {string} code
|
||||
* @returns {Promise<object>} resolve 为更新后的 userInfo
|
||||
*/
|
||||
function bindPhoneByCode(code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!code) {
|
||||
wx.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
reject(new Error('empty code'))
|
||||
return
|
||||
}
|
||||
|
||||
const app = getApp()
|
||||
const token = app.globalData.token || wx.getStorageSync('token')
|
||||
if (!token) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
reject(new Error('no token'))
|
||||
return
|
||||
}
|
||||
|
||||
const apiBase = app.globalData.apiBase || ''
|
||||
if (!apiBase) {
|
||||
wx.showToast({ title: '服务未配置', icon: 'none' })
|
||||
reject(new Error('no api base'))
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '处理中...', mask: true })
|
||||
wx.request({
|
||||
url: `${apiBase.replace(/\/$/, '')}/api/auth/wechat/phone`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: { code },
|
||||
success: (res) => {
|
||||
wx.hideLoading()
|
||||
if (res.statusCode === 200 && res.data && res.data.code === 200) {
|
||||
const data = res.data.data || {}
|
||||
const user = data.user || app.globalData.userInfo || {}
|
||||
const phone = data.phone || user.phone || ''
|
||||
const newUser = { ...user, phone }
|
||||
app.globalData.userInfo = newUser
|
||||
wx.setStorageSync('userInfo', newUser)
|
||||
wx.showToast({ title: '授权成功', icon: 'success' })
|
||||
resolve(newUser)
|
||||
} else {
|
||||
const msg = res.data && res.data.message ? res.data.message : '获取手机号失败'
|
||||
wx.showToast({ title: msg, icon: 'none' })
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '网络请求失败', icon: 'none' })
|
||||
reject(new Error('network error'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasPhone,
|
||||
bindPhoneByCode,
|
||||
isProfileComplete,
|
||||
ensureProfileCompleteAndRedirect,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user