Просмотр исходного кода

feat: 邀请功能 + 前端页面优化

- 新增邀请页面及路由配置
- 优化 getBaseUrl 函数用于生成分享链接
- 更新 Prisma schema
- 优化 mine 页面
- invite/share 模块调整

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 месяцев назад
Родитель
Сommit
1185b99fad

+ 6 - 0
my-uniapp-vue3/src/pages.json

@@ -199,6 +199,12 @@
       "style": {
         "navigationStyle": "custom"
       }
+    },
+    {
+      "path": "pages/invite/index",
+      "style": {
+        "navigationStyle": "custom"
+      }
     }
   ],
   "globalStyle": {

+ 729 - 0
my-uniapp-vue3/src/pages/invite/index.vue

@@ -0,0 +1,729 @@
+<template>
+  <view class="page">
+    <!-- 顶部 Banner -->
+    <view class="banner">
+      <text class="banner-emoji">🎁</text>
+      <text class="banner-title">邀请好友</text>
+      <text class="banner-sub">邀请成功双方各得 10,000 积分</text>
+    </view>
+
+    <!-- 邀请码卡片 -->
+    <view class="invite-card">
+      <view class="invite-card-header">
+        <text class="invite-card-title">我的邀请码</text>
+      </view>
+      <view class="invite-code-box">
+        <text class="invite-code">{{ inviteCode || '加载中...' }}</text>
+        <view class="copy-btn" @click="copyCode">
+          <text class="copy-btn-text">复制</text>
+        </view>
+      </view>
+      <view class="invite-link-box">
+        <text class="invite-link-label">邀请链接:</text>
+        <text class="invite-link" selectable>{{ inviteLink }}</text>
+      </view>
+      <view class="invite-actions">
+        <view class="action-btn primary" @click="shareInvite">
+          <text class="action-btn-text">分享邀请链接</text>
+        </view>
+        <view class="action-btn" @click="copyLink">
+          <text class="action-btn-text">复制链接</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 邀请统计 -->
+    <view class="stats-card">
+      <view class="stat-item">
+        <text class="stat-value">{{ stats.totalInvites }}</text>
+        <text class="stat-label">已邀请人数</text>
+      </view>
+      <view class="stat-divider" />
+      <view class="stat-item">
+        <text class="stat-value">{{ formatNumber(stats.totalReward) }}</text>
+        <text class="stat-label">累计获得积分</text>
+      </view>
+    </view>
+
+    <!-- 邀请记录 -->
+    <view class="records-card" v-if="records.length > 0">
+      <view class="records-header">
+        <text class="records-title">邀请记录</text>
+      </view>
+      <view class="record-list">
+        <view class="record-item" v-for="record in records" :key="record.id">
+          <view class="record-info">
+            <text class="record-user">{{ record.inviteeNickname || '用户' + record.inviteeId }}</text>
+            <text class="record-date">{{ formatDate(record.createdAt) }}</text>
+          </view>
+          <view class="record-reward">
+            <text class="record-reward-text">+{{ formatNumber(record.rewardAmount) }}积分</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空白状态 -->
+    <view class="empty-card" v-else>
+      <text class="empty-icon">👥</text>
+      <text class="empty-text">还没有邀请记录</text>
+      <text class="empty-sub">快去邀请好友吧</text>
+    </view>
+
+    <!-- 分享增加积分说明 -->
+    <view class="share-reward-card">
+      <view class="share-reward-header">
+        <text class="share-reward-icon">📤</text>
+        <text class="share-reward-title">分享也能得积分</text>
+      </view>
+      <text class="share-reward-desc">分享书籍/音频给好友,每次分享可获得积分奖励</text>
+      <view class="share-reward-btn" @click="testShareReward">
+        <text class="share-reward-btn-text">测试分享得积分</text>
+      </view>
+    </view>
+
+    <!-- 规则说明 -->
+    <view class="rules-card">
+      <text class="rules-title">活动规则</text>
+      <view class="rule-item">
+        <text class="rule-num">1</text>
+        <text class="rule-text">每成功邀请1位好友,双方各获得10,000积分</text>
+      </view>
+      <view class="rule-item">
+        <text class="rule-num">2</text>
+        <text class="rule-text">被邀请人需为新用户(从未注册)</text>
+      </view>
+      <view class="rule-item">
+        <text class="rule-num">3</text>
+        <text class="rule-text">分享书籍/音频也可获得积分奖励</text>
+      </view>
+      <view class="rule-item">
+        <text class="rule-num">4</text>
+        <text class="rule-text">积分可全额抵扣音频生成费用</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { onShow } from '@dcloudio/uni-app';
+import { get, post } from '../../utils/request';
+import { getBaseUrl } from '../../utils/config';
+import { copyShareLink } from '../../utils/share';
+
+const inviteCode = ref('');
+const inviteLink = ref('');
+const stats = ref({ totalInvites: 0, totalReward: 0 });
+const records = ref<any[]>([]);
+const shareReward = ref(1000); // 每次分享奖励积分
+
+// 获取邀请码
+async function loadInviteCode() {
+  try {
+    const userInfo = await get<any>('/auth/me');
+    // 生成本地邀请码(基于用户ID)
+    const userId = userInfo?.id || 0;
+    inviteCode.value = `SG${userId.toString().padStart(6, '0')}`;
+    const baseUrl = getBaseUrl();
+    inviteLink.value = `${baseUrl}/#/pages/login/index?inviteCode=${inviteCode.value}`;
+  } catch (e) {
+    // 用时间戳生成临时码
+    inviteCode.value = `SG${Date.now().toString().slice(-6)}`;
+    const baseUrl = getBaseUrl();
+    inviteLink.value = `${baseUrl}/#/pages/login/index?inviteCode=${inviteCode.value}`;
+  }
+}
+
+// 获取邀请统计
+async function loadStats() {
+  try {
+    const data = await get<any>('/invite/stats');
+    stats.value = data || { totalInvites: 0, totalReward: 0 };
+  } catch (e) {
+    stats.value = { totalInvites: 0, totalReward: 0 };
+  }
+}
+
+// 获取邀请记录
+async function loadRecords() {
+  try {
+    const data = await get<any>('/invite/records');
+    records.value = data?.records || [];
+  } catch (e) {
+    records.value = [];
+  }
+}
+
+// 复制邀请码
+function copyCode() {
+  uni.setClipboardData({
+    data: inviteCode.value,
+    success: () => uni.showToast({ title: '邀请码已复制', icon: 'success' }),
+  });
+}
+
+// 复制邀请链接
+async function copyLink() {
+  await copyShareLink({
+    title: '声工坊 AI 有声书 - 邀请好友',
+    summary: `我的邀请码是 ${inviteCode.value},注册即得10,000积分!`,
+    link: inviteLink.value,
+  });
+}
+
+// 分享邀请链接
+function shareInvite() {
+  const shareInfo = {
+    title: '声工坊 AI 有声书 - 邀请好友',
+    summary: `我的邀请码是 ${inviteCode.value},注册即得10,000积分,快来加入!`,
+    link: inviteLink.value,
+  };
+
+  // #ifdef APP-PLUS
+  uni.share({
+    provider: 'weixin',
+    scene: 'WXSceneSession',
+    type: 0,
+    title: shareInfo.title,
+    summary: shareInfo.summary,
+    href: shareInfo.link,
+    success: async () => {
+      await recordShareAction('weixin_session');
+      uni.showToast({ title: '分享成功,+1000积分已到账', icon: 'success' });
+    },
+    fail: (err) => {
+      if (err.errMsg && !err.errMsg.includes('cancel')) {
+        uni.showToast({ title: '分享失败', icon: 'none' });
+      }
+    },
+  });
+  // #endif
+
+  // #ifdef H5
+  copyLink();
+  // #endif
+
+  // #ifdef MP-WEIXIN
+  uni.showToast({ title: '请使用微信分享', icon: 'none' });
+  // #endif
+}
+
+// 记录分享行为并获取积分
+async function recordShareAction(platform: string) {
+  try {
+    const res = await post<any>('/share/track', {
+      audioId: 0,
+      platform,
+      shareCode: inviteCode.value,
+      isInviteShare: true,
+    });
+    if (res?.reward) {
+      uni.showToast({ title: `分享成功,+${res.reward}积分已到账`, icon: 'success' });
+    }
+  } catch (e) {
+    console.log('[Invite] 记录分享失败', e);
+  }
+}
+
+// 测试分享得积分
+async function testShareReward() {
+  try {
+    const res = await post<any>('/share/track', {
+      audioId: 0,
+      platform: 'test',
+      shareCode: inviteCode.value,
+      isInviteShare: true,
+    });
+    if (res?.reward) {
+      uni.showToast({ title: `+${res.reward}积分已到账`, icon: 'success' });
+      loadStats();
+    } else {
+      uni.showToast({ title: '分享记录成功', icon: 'success' });
+    }
+  } catch (e) {
+    uni.showToast({ title: '操作成功', icon: 'success' });
+  }
+}
+
+// 格式化数字
+function formatNumber(num: number): string {
+  if (!num) return '0';
+  return num.toLocaleString();
+}
+
+// 格式化日期
+function formatDate(dateStr: string): string {
+  if (!dateStr) return '';
+  const date = new Date(dateStr);
+  return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
+}
+
+onShow(() => {
+  loadInviteCode();
+  loadStats();
+  loadRecords();
+});
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #f3f4f6;
+  padding-bottom: 40rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .page {
+    background: #1a1a2e;
+  }
+}
+
+/* Banner */
+.banner {
+  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
+  padding: 48rpx 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.banner-emoji {
+  font-size: 64rpx;
+  margin-bottom: 16rpx;
+}
+
+.banner-title {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #ffffff;
+  margin-bottom: 8rpx;
+}
+
+.banner-sub {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.85);
+}
+
+/* 邀请码卡片 */
+.invite-card {
+  margin: 24rpx 32rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+  .invite-card {
+    background: #2d2d44;
+  }
+}
+
+.invite-card-header {
+  margin-bottom: 24rpx;
+}
+
+.invite-card-title {
+  font-size: 28rpx;
+  color: #6b7280;
+}
+
+.invite-code-box {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #f9fafb;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .invite-code-box {
+    background: #374151;
+  }
+}
+
+.invite-code {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #4f46e5;
+  letter-spacing: 2rpx;
+}
+
+.copy-btn {
+  background: #4f46e5;
+  padding: 12rpx 24rpx;
+  border-radius: 8rpx;
+}
+
+.copy-btn-text {
+  font-size: 24rpx;
+  color: #ffffff;
+  font-weight: 500;
+}
+
+.invite-link-box {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 24rpx;
+}
+
+.invite-link-label {
+  font-size: 22rpx;
+  color: #9ca3af;
+  margin-bottom: 8rpx;
+}
+
+.invite-link {
+  font-size: 22rpx;
+  color: #6b7280;
+  word-break: break-all;
+}
+
+.invite-actions {
+  display: flex;
+  gap: 16rpx;
+}
+
+.action-btn {
+  flex: 1;
+  height: 80rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f3f4f6;
+}
+
+@media (prefers-color-scheme: dark) {
+  .action-btn {
+    background: #374151;
+  }
+}
+
+.action-btn.primary {
+  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
+}
+
+.action-btn-text {
+  font-size: 28rpx;
+  color: #374151;
+  font-weight: 500;
+}
+
+.action-btn.primary .action-btn-text {
+  color: #ffffff;
+}
+
+@media (prefers-color-scheme: dark) {
+  .action-btn-text {
+    color: #f9fafb;
+  }
+}
+
+/* 统计卡片 */
+.stats-card {
+  margin: 0 32rpx 24rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  display: flex;
+  align-items: center;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+  .stats-card {
+    background: #2d2d44;
+  }
+}
+
+.stat-item {
+  flex: 1;
+  text-align: center;
+}
+
+.stat-value {
+  display: block;
+  font-size: 44rpx;
+  font-weight: 700;
+  color: #4f46e5;
+  margin-bottom: 8rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .stat-value {
+    color: #818cf8;
+  }
+}
+
+.stat-label {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+
+.stat-divider {
+  width: 2rpx;
+  height: 60rpx;
+  background: #e5e7eb;
+}
+
+@media (prefers-color-scheme: dark) {
+  .stat-divider {
+    background: #374151;
+  }
+}
+
+/* 邀请记录 */
+.records-card {
+  margin: 0 32rpx 24rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+  .records-card {
+    background: #2d2d44;
+  }
+}
+
+.records-header {
+  margin-bottom: 24rpx;
+}
+
+.records-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+@media (prefers-color-scheme: dark) {
+  .records-title {
+    color: #f9fafb;
+  }
+}
+
+.record-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.record-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f3f4f6;
+}
+
+.record-item:last-child {
+  border-bottom: none;
+}
+
+@media (prefers-color-scheme: dark) {
+  .record-item {
+    border-bottom-color: #374151;
+  }
+  .record-item:last-child {
+    border-bottom: none;
+  }
+}
+
+.record-info {
+  display: flex;
+  flex-direction: column;
+}
+
+.record-user {
+  font-size: 28rpx;
+  color: #1f2937;
+  font-weight: 500;
+}
+
+@media (prefers-color-scheme: dark) {
+  .record-user {
+    color: #f9fafb;
+  }
+}
+
+.record-date {
+  font-size: 22rpx;
+  color: #9ca3af;
+  margin-top: 4rpx;
+}
+
+.record-reward-text {
+  font-size: 28rpx;
+  color: #10b981;
+  font-weight: 600;
+}
+
+/* 空白状态 */
+.empty-card {
+  margin: 0 32rpx 24rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 48rpx 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+  .empty-card {
+    background: #2d2d44;
+  }
+}
+
+.empty-icon {
+  font-size: 64rpx;
+  margin-bottom: 16rpx;
+}
+
+.empty-text {
+  font-size: 28rpx;
+  color: #6b7280;
+  font-weight: 500;
+}
+
+.empty-sub {
+  font-size: 24rpx;
+  color: #9ca3af;
+  margin-top: 8rpx;
+}
+
+/* 分享奖励卡片 */
+.share-reward-card {
+  margin: 0 32rpx 24rpx;
+  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+  border-radius: 20rpx;
+  padding: 28rpx;
+  box-shadow: 0 4rpx 16rpx rgba(251, 191, 36, 0.2);
+}
+
+@media (prefers-color-scheme: dark) {
+  .share-reward-card {
+    background: linear-gradient(135deg, #92400e 0%, #78350f 100%);
+  }
+}
+
+.share-reward-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+
+.share-reward-icon {
+  font-size: 36rpx;
+  margin-right: 12rpx;
+}
+
+.share-reward-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #92400e;
+}
+
+@media (prefers-color-scheme: dark) {
+  .share-reward-title {
+    color: #fef3c7;
+  }
+}
+
+.share-reward-desc {
+  font-size: 24rpx;
+  color: #b45309;
+  margin-bottom: 20rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .share-reward-desc {
+    color: #fde68a;
+  }
+}
+
+.share-reward-btn {
+  background: #ffffff;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.share-reward-btn-text {
+  font-size: 28rpx;
+  color: #92400e;
+  font-weight: 500;
+}
+
+/* 规则卡片 */
+.rules-card {
+  margin: 0 32rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+  .rules-card {
+    background: #2d2d44;
+  }
+}
+
+.rules-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 24rpx;
+}
+
+@media (prefers-color-scheme: dark) {
+  .rules-title {
+    color: #f9fafb;
+  }
+}
+
+.rule-item {
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 16rpx;
+}
+
+.rule-item:last-child {
+  margin-bottom: 0;
+}
+
+.rule-num {
+  width: 36rpx;
+  height: 36rpx;
+  background: #4f46e5;
+  border-radius: 50%;
+  font-size: 22rpx;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-right: 16rpx;
+}
+
+.rule-text {
+  flex: 1;
+  font-size: 26rpx;
+  color: #6b7280;
+  line-height: 1.5;
+}
+
+@media (prefers-color-scheme: dark) {
+  .rule-text {
+    color: #9ca3af;
+  }
+}
+</style>

+ 1 - 1
my-uniapp-vue3/src/pages/mine/index.vue

@@ -148,7 +148,7 @@
 
       <view class="menu-item" @click="goInvite">
         <text class="menu-icon">🎁</text>
-        <text class="menu-text">邀请好友 · 双方得10分</text>
+        <text class="menu-text">邀请好友 · 双方得10000积分</text>
         <text class="menu-arrow">›</text>
       </view>
 

+ 11 - 0
my-uniapp-vue3/src/utils/config.ts

@@ -71,6 +71,17 @@ export function getServerBaseUrl(): string {
   return apiUrl.replace('/api', '');
 }
 
+// 获取前端基础地址(用于生成分享链接)
+export function getBaseUrl(): string {
+  // #ifdef H5
+  if (typeof window !== 'undefined') {
+    return window.location.origin;
+  }
+  // #endif
+  const apiUrl = getApiBaseUrl();
+  return apiUrl.replace('/api', '');
+}
+
 // 获取局域网 IP 地址
 export function getLocalIP(): string {
   // 可以通过执行系统命令获取,或者硬编码配置

+ 12 - 9
server/prisma/schema.prisma

@@ -538,23 +538,26 @@ model ShareRecord {
   userId    Int
   audioId   Int
   platform  String
-  shareCode String   @unique
+  shareCode String
+  rewarded  Boolean  @default(false)
   createdAt DateTime @default(now())
   user      User     @relation(fields: [userId], references: [id])
 
   @@index([userId])
   @@index([shareCode])
+  @@index([createdAt])
 }
 
 model InviteRecord {
-  id        Int      @id @default(autoincrement())
-  inviterId Int
-  inviteeId Int      @unique
-  shareCode String?
-  rewarded  Boolean  @default(false)
-  createdAt DateTime @default(now())
-  inviter   User     @relation("InviterRelation", fields: [inviterId], references: [id])
-  invitee   User     @relation("InviteeRelation", fields: [inviteeId], references: [id])
+  id           Int      @id @default(autoincrement())
+  inviterId    Int
+  inviteeId    Int      @unique
+  shareCode    String?
+  rewarded     Boolean  @default(false)
+  rewardAmount Int      @default(10000)
+  createdAt    DateTime @default(now())
+  inviter      User     @relation("InviterRelation", fields: [inviterId], references: [id])
+  invitee      User     @relation("InviteeRelation", fields: [inviteeId], references: [id])
 
   @@index([inviterId])
   @@index([shareCode])

+ 23 - 1
server/src/modules/invite/invite.controller.ts

@@ -1,5 +1,5 @@
 import Router from '@koa/router';
-import { getInviteStats } from './invite.service';
+import { getInviteStats, getInviteRecords } from './invite.service';
 import { authMiddleware } from '../../middleware/auth';
 
 const router = new Router();
@@ -26,4 +26,26 @@ router.get('/stats', authMiddleware, async (ctx) => {
   }
 });
 
+/**
+ * 获取当前用户的邀请记录
+ * GET /api/invite/records
+ */
+router.get('/records', authMiddleware, async (ctx) => {
+  try {
+    const userId = (ctx.state as any).user.id;
+    const records = await getInviteRecords(parseInt(userId));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { records },
+    };
+  } catch (error: any) {
+    ctx.body = {
+      code: 400,
+      message: error.message || '获取邀请记录失败',
+    };
+  }
+});
+
 export default router;

+ 94 - 28
server/src/modules/invite/invite.service.ts

@@ -1,11 +1,12 @@
 import { prisma } from '../../models';
-import { BILLING } from '../subscription/subscription.service';
 
 const INVITE_REWARD = {
-  inviterBonus: 10 * 60,  // 邀请人获得10分钟(600秒)
-  inviteeBonus: 10 * 60,  // 被邀请人获得10分钟
+  inviterBonus: 10000,   // 邀请人获得 10000 积分
+  inviteeBonus: 10000,   // 被邀请人获得 10000 积分
 };
 
+const SHARE_REWARD = 1000; // 每次分享奖励 1000 积分
+
 /**
  * 处理邀请:被邀请人注册时调用
  * @param inviterId 邀请人ID
@@ -29,14 +30,15 @@ export async function handleInvite(inviterId: number, inviteeId: number, shareCo
       inviteeId,
       shareCode,
       rewarded: false,
+      rewardAmount: INVITE_REWARD.inviterBonus,
     },
   });
 
-  // 发放邀请人奖励
-  await grantInviteReward(inviterId, INVITE_REWARD.inviterBonus);
+  // 发放邀请人奖励(积分)
+  await grantTokenReward(inviterId, INVITE_REWARD.inviterBonus);
 
-  // 发放被邀请人奖励
-  await grantInviteReward(inviteeId, INVITE_REWARD.inviteeBonus);
+  // 发放被邀请人奖励(积分)
+  await grantTokenReward(inviteeId, INVITE_REWARD.inviteeBonus);
 
   // 标记奖励已发放
   await prisma.inviteRecord.updateMany({
@@ -44,28 +46,15 @@ export async function handleInvite(inviterId: number, inviteeId: number, shareCo
     data: { rewarded: true },
   });
 
-  console.log(`🎁 邀请奖励发放: ${inviterId} → ${inviteeId},各得${INVITE_REWARD.inviterBonus}`);
+  console.log(`🎁 邀请奖励: ${inviterId} → ${inviteeId},各得${INVITE_REWARD.inviterBonus}积分`);
 }
 
 /**
- * 发放奖励(增加Token余额和costLimit)
+ * 发放积分奖励
  * @param userId 用户ID
- * @param seconds 奖励秒数
+ * @param tokens 积分数量
  */
-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 * BILLING.monthlyPrice }, // 按月度售价
-    },
-  });
-
-  // 也增加 Token 余额
-  const tokens = minutes * 1000; // 分钟→token换算
+async function grantTokenReward(userId: number, tokens: number) {
   await prisma.tokenBalance.upsert({
     where: { userId },
     create: {
@@ -84,17 +73,94 @@ async function grantInviteReward(userId: number, seconds: number) {
  * @param userId 用户ID
  */
 export async function getInviteStats(userId: number) {
-  const count = await prisma.inviteRecord.count({
+  const records = await prisma.inviteRecord.findMany({
     where: { inviterId: userId, rewarded: true },
+    select: { rewardAmount: true },
   });
 
+  const totalInvites = records.length;
+  const totalReward = records.reduce((sum, r) => sum + (r.rewardAmount || INVITE_REWARD.inviterBonus), 0);
+
   return {
-    totalInvites: count,
-    totalReward: count * INVITE_REWARD.inviterBonus, // 秒
+    totalInvites,
+    totalReward,
   };
 }
 
+/**
+ * 获取用户的邀请记录
+ * @param userId 用户ID
+ */
+export async function getInviteRecords(userId: number) {
+  const records = await prisma.inviteRecord.findMany({
+    where: { inviterId: userId },
+    include: {
+      invitee: {
+        select: { nickname: true, avatar: true },
+      },
+    },
+    orderBy: { createdAt: 'desc' },
+    take: 50,
+  });
+
+  return records.map((r) => ({
+    id: r.id,
+    inviteeId: r.inviteeId,
+    inviteeNickname: r.invitee?.nickname || '',
+    rewardAmount: r.rewardAmount || INVITE_REWARD.inviterBonus,
+    createdAt: r.createdAt,
+  }));
+}
+
+/**
+ * 处理分享奖励
+ * @param userId 用户ID
+ * @param shareCode 分享码
+ * @returns 奖励积分数量
+ */
+export async function handleShareReward(userId: number, shareCode?: string): Promise<number> {
+  if (!userId) return 0;
+
+  // 一天内同类型分享最多奖励10次
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+
+  const existingCount = await prisma.shareRecord.count({
+    where: {
+      userId,
+      shareCode: shareCode || '',
+      createdAt: { gte: today },
+    },
+  });
+
+  if (existingCount >= 10) {
+    return 0;
+  }
+
+  // 记录分享
+  await prisma.shareRecord.create({
+    data: {
+      userId,
+      audioId: 0,
+      platform: 'invite',
+      shareCode: shareCode || `share_${Date.now()}`,
+      rewarded: true,
+    },
+  });
+
+  // 发放积分奖励
+  await grantTokenReward(userId, SHARE_REWARD);
+
+  console.log(`📤 分享奖励: 用户${userId}分享获得${SHARE_REWARD}积分`);
+
+  return SHARE_REWARD;
+}
+
 export default {
   handleInvite,
   getInviteStats,
-};
+  getInviteRecords,
+  handleShareReward,
+  INVITE_REWARD,
+  SHARE_REWARD,
+};

+ 15 - 2
server/src/modules/share/share.controller.ts

@@ -1,5 +1,6 @@
 import Router from '@koa/router';
 import { shareService } from './share.service';
+import { handleShareReward } from '../invite/invite.service';
 import { authMiddleware } from '../../middleware/auth';
 
 const router = new Router();
@@ -86,10 +87,21 @@ router.get('/qrcode/:audioId', async (ctx) => {
  */
 router.post('/track', authMiddleware, async (ctx) => {
   try {
-    const { audioId, platform, shareCode } = ctx.request.body as { audioId: string; platform: string; shareCode?: string };
+    const { audioId, platform, shareCode, isInviteShare } = ctx.request.body as {
+      audioId: string;
+      platform: string;
+      shareCode?: string;
+      isInviteShare?: boolean;
+    };
     const userId = (ctx.state as any).user.id;
 
-    const record = await shareService.trackShare(userId, parseInt(audioId), platform, shareCode);
+    const record = await shareService.trackShare(userId, parseInt(audioId) || 0, platform, shareCode);
+
+    // 邀请类分享发放积分奖励
+    let reward = 0;
+    if (isInviteShare && userId) {
+      reward = await handleShareReward(userId, shareCode);
+    }
 
     ctx.body = {
       code: 0,
@@ -97,6 +109,7 @@ router.post('/track', authMiddleware, async (ctx) => {
       data: {
         id: record.id,
         shareCode: record.shareCode,
+        reward,
       },
     };
   } catch (error: any) {