关联文档:运营方案
制定日期:2026-05-21
当前代码分析日期:2026-05-21
| 编号 | 任务 | 优先级 | 工时 | 依赖 | 涉及模块 |
|---|---|---|---|---|---|
| DEV-01 | 接入百度统计SDK | 🔴 P0 | 0.5天 | 无 | 前端 only |
| DEV-03 | 分享功能完善 | 🔴 P0 | 2天 | DEV-04 | Share + Player |
| DEV-04 | 邀请奖励机制 | 🔴 P0 | 2天 | DEV-03 | Share + Subscription |
| DEV-08 | 分享卡片美化 | 🟡 P1 | 0.5天 | DEV-03 | Share + UI |
前端页面加入用户行为统计,能跟踪:PV/UV、页面停留时长、按钮点击事件、用户来源渠道。
选择百度统计,原因:
cd my-uniapp-vue3
npm install @baidu/stat-mp --save # 百度统计小程序SDK
文件:my-uniapp-vue3/src/utils/analytics.ts(新建)
/**
* 百度统计分析工具
* 使用前需在百度统计后台创建应用,获取 appKey
*/
// 百度统计小程序 key(需在百度统计后台申请)
const BAIDU_APP_KEY = ''; // TODO: 替换为实际key
// H5 环境使用传统百度统计
const H5_BAIDU_ID = ''; // TODO: 替换为实际id
/**
* 初始化统计
*/
export function initAnalytics() {
// #ifdef MP-WEIXIN
if (BAIDU_APP_KEY) {
// 微信小程序接入百度统计
const mtj = require('@baidu/stat-mp');
mtj.default.init(BAIDU_APP_KEY, {
autoTrack: true, // 自动采集页面浏览
});
}
// #endif
// #ifdef H5
if (H5_BAIDU_ID) {
// H5 接入百度统计
const script = document.createElement('script');
script.src = `https://hm.baidu.com/hm.js?${H5_BAIDU_ID}`;
document.head.appendChild(script);
}
// #endif
}
/**
* 自定义事件上报
* @param eventName 事件名称
* @param params 事件参数
*/
export function trackEvent(eventName: string, params?: Record<string, any>) {
// #ifdef MP-WEIXIN
try {
const mtj = require('@baidu/stat-mp');
mtj.default.event(eventName, params || {});
} catch (e) {
// 静默失败
}
// #endif
// #ifdef H5
try {
if ((window as any)._hmt) {
(window as any)._hmt.push(['_trackEvent', eventName, 'click', JSON.stringify(params || {})]);
}
} catch (e) {
// 静默失败
}
// #endif
}
/**
* 预定义事件常量(业务事件)
*/
export const AnalyticsEvents = {
// 用户行为
USER_REGISTER: 'user_register',
USER_LOGIN: 'user_login',
// TTS 生成
TTS_GENERATE_START: 'tts_generate_start',
TTS_GENERATE_SUCCESS: 'tts_generate_success',
TTS_GENERATE_FAIL: 'tts_generate_fail',
// 播放器
PLAYER_PLAY: 'player_play',
PLAYER_COMPLETE: 'player_complete',
PLAYER_FAVORITE: 'player_favorite',
PLAYER_SHARE: 'player_share',
// 付费转化
MEMBER_PAGE_VIEW: 'member_page_view',
MEMBER_CLICK_BUY: 'member_click_buy',
MEMBER_PAY_START: 'member_pay_start',
MEMBER_PAY_SUCCESS: 'member_pay_success',
// 签到
SIGN_IN: 'sign_in',
// 分享裂变
SHARE_CLICK: 'share_click',
SHARE_COMPLETE: 'share_complete',
INVITE_ACCEPT: 'invite_accept',
} as const;
export default { init: initAnalytics, trackEvent, AnalyticsEvents };
文件:my-uniapp-vue3/src/App.vue
在 <script setup> 的 onLaunch 中追加:
import { initAnalytics } from '@/utils/analytics';
onLaunch(() => {
// ... 现有代码 ...
userStore.initUser();
applyDarkMode();
// 新增:初始化统计
initAnalytics();
});
在以下位置添加 trackEvent 调用:
| 位置 | 事件 | 代码位置 |
|---|---|---|
| 注册成功后 | USER_REGISTER |
store/user.ts login 方法 |
| 点击生成按钮 | TTS_GENERATE_START |
pages/create/index.vue |
| 生成成功后 | TTS_GENERATE_SUCCESS |
TTS回调处 |
| 播放音频 | PLAYER_PLAY |
pages/player/index.vue onPlay |
| 点击付费按钮 | MEMBER_CLICK_BUY |
pages/member/index.vue |
analytics.ts将目前的"纯前端复制链接"改造为完整的分享追踪+奖励体系。
现状:
后端:
✅ GET /api/share/card/:audioId — 生成分享卡片数据(标题、描述、封面)
✅ GET /api/share/qrcode/:audioId — 生成二维码数据
✅ POST /api/share/track — 「占位」console.log,无实际数据库写入
❌ 无 ShareRecord 数据表
❌ 无分享奖励逻辑
前端(player/index.vue):
✅ handleShare() — H5用navigator.share,小程序复制链接
❌ 不调用 /api/share/track
❌ 不调用 /api/share/card 获取卡片数据
❌ 无邀请码参数
在 server/prisma/schema.prisma 中新增 ShareRecord 模型:
model ShareRecord {
id Int @id @default(autoincrement())
userId Int
audioId Int
platform String @default("wechat") // wechat, moments, copy_link
shareCode String @unique // 分享码,用于追踪
createdAt DateTime @default(now())
@@index([userId])
@@index([shareCode])
@@index([audioId])
@@map("share_records")
}
执行迁移:
cd server
npx prisma db push
文件:server/src/modules/share/share.service.ts(重写)
import { prisma } from '../../models';
import crypto from 'crypto';
interface ShareCardData {
title: string;
description: string;
coverUrl: string;
shareUrl: string;
shareCode: string;
}
/**
* 生成分享卡片(增强版:含分享码)
*/
export async function generateShareCard(chapterId: number, userId?: number): Promise<ShareCardData> {
const chapter = await prisma.bookChapter.findUnique({
where: { id: chapterId },
include: { book: true },
});
if (!chapter) {
throw new Error(`章节 ${chapterId} 不存在`);
}
// 生成唯一分享码(8位)
const shareCode = generateShareCode();
const shareUrl = `${getBaseUrl()}/#/pages/player/index?id=${chapterId}&sc=${shareCode}`;
return {
title: chapter.title || chapter.book?.title || '声工坊有声作品',
description: `听听这篇《${chapter.book?.title || '有声作品'}》,用AI朗读超自然!`,
coverUrl: chapter.book?.coverUrl || '',
shareUrl,
shareCode,
};
}
/**
* 记录分享行为
*/
export async function trackShare(
userId: number,
audioId: number,
platform: string,
shareCode?: string
) {
const code = shareCode || generateShareCode();
const record = await prisma.shareRecord.create({
data: {
userId,
audioId,
platform,
shareCode: code,
},
});
console.log(`📤 用户 ${userId} 分享了章节 ${audioId} 到 ${platform},分享码: ${code}`);
return record;
}
/**
* 通过分享码追踪访问来源
*/
export async function trackShareVisit(shareCode: string, visitorId?: number) {
const shareRecord = await prisma.shareRecord.findUnique({
where: { shareCode },
include: {
user: { select: { id: true, nickname: true } },
},
});
if (shareRecord) {
console.log(`👀 分享码 ${shareCode} 被访问,分享者: ${shareRecord.userId},访客: ${visitorId || '匿名'}`);
}
return shareRecord;
}
/**
* 生成8位分享码
*/
function generateShareCode(): string {
return crypto.randomBytes(4).toString('hex').toUpperCase();
}
/**
* 获取基础URL
*/
function getBaseUrl(): string {
return process.env.BASE_URL || 'https://book.rrbrr.com';
}
文件:server/src/modules/share/share.controller.ts(修改)
新增一个端点:
/**
* GET /api/share/visit?code=xxx
* 通过分享码追踪访问,供前端页面加载时调用
*/
router.get('/visit', async (ctx) => {
const { code } = ctx.query;
if (!code) {
ctx.status = 400;
ctx.body = { code: -1, message: '缺少分享码参数' };
return;
}
const record = await shareService.trackShareVisit(code as string);
ctx.body = {
code: 0,
data: {
valid: !!record,
sharer: record ? { id: record.userId, nickname: record.user?.nickname } : null,
},
};
});
文件:my-uniapp-vue3/src/pages/player/index.vue
修改 handleShare() 和新增逻辑:
import { get, post } from '@/utils/request';
import { trackEvent, AnalyticsEvents } from '@/utils/analytics';
// 分享数据
const shareData = ref<ShareCardData | null>(null);
/**
* 获取分享卡片数据(从后端)
*/
async function fetchShareCard() {
if (!audio.value?._id) return;
try {
const res = await get<ShareCardData>(`/share/card/${audio.value._id}`);
shareData.value = res;
} catch (e) {
console.error('获取分享卡片失败', e);
}
}
/**
* 处理分享(增强版)
*/
async function handleShare() {
trackEvent(AnalyticsEvents.SHARE_CLICK, { audioId: audio.value?._id });
// 先获取最新的分享卡片数据
await fetchShareCard();
const shareTitle = shareData.value?.title || '声工坊有声作品';
const shareDesc = shareData.value?.description || '用AI生成的高品质有声书,快来听!';
const shareUrl = shareData.value?.shareUrl || copyShareLink();
// #ifdef H5
if (navigator.share) {
try {
await navigator.share({
title: shareTitle,
text: shareDesc,
url: shareUrl,
});
await trackShareComplete('h5_share_api');
} catch (e) {
// 用户取消分享
if ((e as Error).name !== 'AbortError') {
await copyToClipboard(shareUrl);
}
}
return;
}
// #endif
// 小程序/App:复制链接
await copyToClipboard(shareUrl);
}
/**
* 向后端上报分享完成
*/
async function trackShareComplete(platform: string) {
try {
await post('/share/track', {
audioId: audio.value?._id,
platform,
shareCode: shareData.value?.shareCode,
});
trackEvent(AnalyticsEvents.SHARE_COMPLETE, {
audioId: audio.value?._id,
platform,
});
} catch (e) {
// 静默失败
}
}
/**
* 复制到剪贴板(带分享码链接)
*/
async function copyToClipboard(url: string) {
try {
await uni.setClipboardData({ data: url });
uni.showToast({ title: '链接已复制,分享给朋友吧!', icon: 'success' });
await trackShareComplete('copy_link');
} catch (e) {
uni.showToast({ title: '请截图分享', icon: 'none' });
}
}
// 页面加载时,检测分享码来源
onLoad((options) => {
const shareCode = options?.sc;
if (shareCode) {
// 上报分享访问
get(`/share/visit?code=${shareCode}`).catch(() => {});
trackEvent(AnalyticsEvents.INVITE_ACCEPT, { shareCode });
// 存储分享码,用于后续注册绑定
uni.setStorageSync('invite_code', shareCode);
}
});
文件:my-uniapp-vue3/src/store/user.ts
注册时携带邀请码:
async function login(phone: string, code: string) {
const inviteCode = uni.getStorageSync('invite_code') || '';
const res = await post('/auth/login', {
phone,
code,
inviteCode, // 新增:邀请码
});
// ... 原有逻辑 ...
uni.removeStorageSync('invite_code'); // 清除已使用的邀请码
}
| 操作 | 文件 | 说明 |
|---|---|---|
| 修改 | server/prisma/schema.prisma |
新增 ShareRecord 模型 |
| 重写 | server/src/modules/share/share.service.ts |
完整分享追踪逻辑 |
| 修改 | server/src/modules/share/share.controller.ts |
新增 /visit 端点 |
| 修改 | my-uniapp-vue3/src/pages/player/index.vue |
重写 handleShare |
| 修改 | my-uniapp-vue3/src/store/user.ts |
login 携带邀请码 |
| 修改 | my-uniapp-vue3/src/utils/analytics.ts |
添加分享相关事件 |
# 1. 获取分享卡片
curl "http://localhost:3000/api/share/card/1"
# 预期:返回 { title, description, coverUrl, shareUrl, shareCode }
# 2. 记录分享
curl -X POST http://localhost:3000/api/share/track \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"audioId":1,"platform":"copy_link","shareCode":"AB12CD34"}'
# 预期:返回 ShareRecord 记录
# 3. 追踪访问
curl "http://localhost:3000/api/share/visit?code=AB12CD34"
# 预期:返回 { valid: true, sharer: {...} }
# 4. 前端 Playwright 测试
# - 打开播放器页面
# - 点击分享按钮
# - H5: 触发 navigator.share / 小程序: 复制链接
# - 验证链接中包含 ?sc= 参数
用户通过分享链接邀请新用户注册,双方各获得奖励(免费生成时长)。
在 server/prisma/schema.prisma 中新增 InviteRecord 模型:
model InviteRecord {
id Int @id @default(autoincrement())
inviterId Int // 邀请人
inviteeId Int @unique // 被邀请人(一个用户只能被邀请一次)
shareCode String? // 关联的分享码
rewarded Boolean @default(false) // 是否已发放奖励
createdAt DateTime @default(now())
@@index([inviterId])
@@map("invite_records")
}
文件(新建):server/src/modules/invite/invite.service.ts
import { prisma } from '../../models';
const INVITE_REWARD = {
inviterBonus: 10 * 60, // 邀请人获得10分钟(600秒)
inviteeBonus: 10 * 60, // 被邀请人获得10分钟
};
/**
* 处理邀请:被邀请人注册时调用
*/
export async function handleInvite(inviterId: number, inviteeId: number, shareCode?: string) {
// 防止自邀请
if (inviterId === inviteeId) return;
// 防止重复被邀请
const existing = await prisma.inviteRecord.findUnique({
where: { inviteeId },
});
if (existing) return;
// 创建邀请记录
await prisma.inviteRecord.create({
data: {
inviterId,
inviteeId,
shareCode,
rewarded: false,
},
});
// 发放邀请人奖励
await grantInviteReward(inviterId, INVITE_REWARD.inviterBonus);
// 发放被邀请人奖励
await grantInviteReward(inviteeId, INVITE_REWARD.inviteeBonus);
// 标记奖励已发放
await prisma.inviteRecord.updateMany({
where: { inviteeId },
data: { rewarded: true },
});
console.log(`🎁 邀请奖励发放: ${inviterId} → ${inviteeId},各得${INVITE_REWARD.inviterBonus}秒`);
}
/**
* 发放奖励(增加Token余额或costLimit)
*/
async function grantInviteReward(userId: number, seconds: number) {
// 转换为分钟额度
const minutes = Math.ceil(seconds / 60);
// 增加 costLimit(月度费用上限)
await prisma.user.update({
where: { id: userId },
data: {
costLimit: { increment: minutes * 0.032 }, // 按入门版单价
},
});
// 也增加 Token 余额
const tokens = minutes * 1000; // 分钟→token换算
await prisma.tokenBalance.upsert({
where: { userId },
create: {
userId,
totalTokens: tokens,
usedTokens: 0,
},
update: {
totalTokens: { increment: tokens },
},
});
}
/**
* 获取用户的邀请统计
*/
export async function getInviteStats(userId: number) {
const count = await prisma.inviteRecord.count({
where: { inviterId: userId, rewarded: true },
});
return {
totalInvites: count,
totalReward: count * INVITE_REWARD.inviterBonus, // 秒
};
}
文件:server/src/modules/auth/auth.service.ts
在 loginWithPhone 的注册环节追加邀请处理:
// 在创建用户之后的代码中
if (isNewUser) {
// 处理邀请
const { inviteCode } = params;
if (inviteCode) {
// 查找邀请人
const shareRecord = await prisma.shareRecord.findUnique({
where: { shareCode: inviteCode },
});
if (shareRecord) {
await handleInvite(shareRecord.userId, user.id, inviteCode);
}
}
}
文件:my-uniapp-vue3/src/pages/mine/index.vue
新增邀请卡片入口:
<!-- 在功能入口菜单中新增 -->
<view class="menu-item" @click="goInvite">
<text class="menu-icon">🎁</text>
<text class="menu-label">邀请好友 · 双方得10分钟</text>
<text class="menu-arrow">→</text>
</view>
| 操作 | 文件 | 说明 |
|---|---|---|
| 修改 | server/prisma/schema.prisma |
新增 InviteRecord 模型 |
| 新建 | server/src/modules/invite/invite.service.ts |
邀请逻辑 |
| 新建 | server/src/modules/invite/invite.controller.ts |
邀请相关API |
| 修改 | server/src/modules/auth/auth.service.ts |
注册时绑定邀请关系 |
| 修改 | server/src/app.ts |
注册 invite 路由 |
| 修改 | my-uniapp-vue3/src/pages/mine/index.vue |
邀请入口 |
优化分享卡片的视觉呈现,提升点击转化率。
文件:server/src/modules/share/share.service.ts
优化 generateShareCard 返回的标题和描述,根据内容自动生成更有吸引力的文案:
function generateShareTitle(chapter: any): string {
const bookTitle = chapter.book?.title || '';
const chapterTitle = chapter.title || '';
if (bookTitle && chapterTitle) {
return `🎧 《${bookTitle}》${chapterTitle}`;
}
if (bookTitle) {
return `🎧 AI朗读《${bookTitle}》,超自然!`;
}
return '🎧 用AI生成的超自然有声书';
}
function generateShareDescription(chapter: any): string {
const duration = chapter.audioDuration
? `${Math.round(chapter.audioDuration / 60)}分钟`
: '几分钟';
const templates = [
`AI朗读效果太逼真了!${duration}的有声书,快来听听~`,
`不用自己读,AI帮你把文字变成${duration}音频,效果惊人!`,
`在声工坊用AI生成的${duration}有声书,分享给你!`,
];
return templates[Math.floor(Math.random() * templates.length)];
}
文件:my-uniapp-vue3/src/pages/player/index.vue
分享按钮增加动画效果和引导文案:
<view class="control-btn share-btn" @click="handleShare">
<text class="control-icon">📤</text>
<text class="share-label">分享得时长</text>
</view>
第一阶段(本周)P0任务 — 上线必备
┌─────────────────────────────────────┐
│ DEV-01 百度统计 (0.5天) │ → 先接入数据,后续所有动作都能追踪
│ DEV-03 分享完善 (2天) │ → 裂变增长的基础设施
└─────────────────────────────────────┘
↓
第二阶段(下周)P0任务 — 增长引擎
┌─────────────────────────────────────┐
│ DEV-04 邀请奖励 (2天) │ → 基于DEV-03的分享码体系
└─────────────────────────────────────┘
↓
第三阶段(第3周)P1任务 — 体验优化
┌─────────────────────────────────────┐
│ DEV-08 分享美化 (0.5天) │ → 锦上添花
└─────────────────────────────────────┘
| 阶段 | 任务 | 工时 |
|---|---|---|
| 第一阶段 | DEV-01, 03 | 2.5天 |
| 第二阶段 | DEV-04 | 2天 |
| 第三阶段 | DEV-08 | 0.5天 |
| 合计 | 4个任务 | 5天 |
schema.prisma 后执行 npx prisma db push文档版本:V1.0
关联文档:运营方案