运营功能开发文档.md 21 KB

运营功能开发文档

关联文档:运营方案
制定日期: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

二、DEV-01:接入百度统计SDK

2.1 目标

前端页面加入用户行为统计,能跟踪:PV/UV、页面停留时长、按钮点击事件、用户来源渠道。

2.2 技术方案

选择百度统计,原因:

  • 免费额度够用(日均PV < 10万免费)
  • 支持微信小程序 SDK
  • 接入成本最低,一行代码初始化

2.3 具体实现

2.3.1 安装与配置

cd my-uniapp-vue3
npm install @baidu/stat-mp --save  # 百度统计小程序SDK

2.3.2 新建统计工具文件

文件: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 };

2.3.3 在 App.vue 中初始化

文件:my-uniapp-vue3/src/App.vue

<script setup>onLaunch 中追加:

import { initAnalytics } from '@/utils/analytics';

onLaunch(() => {
  // ... 现有代码 ...
  userStore.initUser();
  applyDarkMode();
  
  // 新增:初始化统计
  initAnalytics();
});

2.3.4 关键页面埋点

在以下位置添加 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

2.4 测试验证

  • 微信小程序控制台确认 SDK 初始化成功
  • 百度统计后台实时访客能看到自己
  • 自定义事件能正常上报

2.5 百度统计后台配置

  1. 访问 https://tongji.baidu.com 注册账号
  2. 创建「应用」→ 选择「微信小程序」
  3. 获取 AppKey,填入 analytics.ts

三、DEV-03:分享功能完善

3.1 目标

将目前的"纯前端复制链接"改造为完整的分享追踪+奖励体系。

3.2 当前状态分析

现状:
  后端:
    ✅ 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 获取卡片数据
    ❌ 无邀请码参数

3.3 技术方案

3.3.1 数据库变更

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

3.3.2 后端改造

文件: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,
    },
  };
});

3.3.3 前端改造

文件: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'); // 清除已使用的邀请码
}

3.4 文件变更清单

操作 文件 说明
修改 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 添加分享相关事件

3.5 测试验证

# 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= 参数

四、DEV-04:邀请奖励机制

4.1 目标

用户通过分享链接邀请新用户注册,双方各获得奖励(免费生成时长)。

4.2 技术方案

4.2.1 数据库变更

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")
}

4.2.2 后端改造

文件(新建):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, // 秒
  };
}

4.2.3 对接注册流程

文件: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);
    }
  }
}

4.2.4 前端邀请统计展示

文件: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>

4.3 文件变更清单

操作 文件 说明
修改 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 邀请入口

五、DEV-08:分享卡片美化

5.1 目标

优化分享卡片的视觉呈现,提升点击转化率。

5.2 技术方案

文件: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天

七、注意事项

  1. 数据统计SDK优先:一定要在推广前接入,否则无法量化效果
  2. 支付先测通:虽然不在本文档范围内,但上线前必须确保微信支付/支付宝能正常回调
  3. 小程序审核:新增页面可能需要重新提审,预留3-5个工作日
  4. 数据库迁移:每次修改 schema.prisma 后执行 npx prisma db push
  5. 分享码8位随机:碰撞概率极低(16^8 ≈ 43亿),无需担心重复

文档版本:V1.0
关联文档运营方案