691 lines
22 KiB
Markdown
691 lines
22 KiB
Markdown
# 用户贡献实时显示方案
|
||
|
||
## 1. 概述
|
||
|
||
### 1.1 需求描述
|
||
在活动页面实时展示所有用户的贡献动态,包括:
|
||
- 贡献者头像 / 昵称
|
||
- 道具图标 / 名称
|
||
- 贡献数量
|
||
|
||
### 1.2 展示形式
|
||
- **列表形式**:最新贡献显示在顶部
|
||
- **生命周期**:页面可见时显示,不可见时暂停
|
||
- **消失逻辑**:每条记录 5 秒无新数据则逐条淡出消失
|
||
|
||
### 1.3 技术方案
|
||
- 后端:Gateway 层新增 HTTP API,ActivityService 处理业务逻辑
|
||
- 前端:轮询 composable 与列表组件
|
||
- 数据库:`activity_contributions` 表已存在
|
||
|
||
---
|
||
|
||
## 2. 数据库设计
|
||
|
||
### 2.1 ER 关系
|
||
|
||
```
|
||
┌─────────────────┐ ┌──────────────────────────┐
|
||
│ activities │ │ activity_contributions │
|
||
│─────────────────│ │───────────────────────────│
|
||
│ id │──────< │ activity_id │
|
||
│ star_id │ │ user_id │
|
||
│ ... │ │ star_id │
|
||
└─────────────────┘ │ item_id │
|
||
│ item_type │
|
||
│ quantity │
|
||
│ contribution_points │
|
||
│ created_at(毫秒时间戳) │
|
||
└──────────────────────────┘
|
||
│
|
||
│ LEFT JOIN
|
||
▼
|
||
┌─────────────────┐
|
||
│ users │
|
||
│─────────────────│
|
||
│ id │
|
||
│ nickname │
|
||
│ avatar_url │
|
||
└─────────────────┘
|
||
│
|
||
│ LEFT JOIN
|
||
▼
|
||
┌─────────────────┐
|
||
│ activity_items │
|
||
│─────────────────│
|
||
│ id │
|
||
│ item_name │
|
||
│ icon_url │
|
||
└─────────────────┘
|
||
```
|
||
|
||
### 2.2 表结构
|
||
|
||
**activity_contributions(活动贡献记录表)**
|
||
|
||
| 字段 | 类型 | 约束 | 说明 |
|
||
|------|------|------|------|
|
||
| id | bigint | PK, AUTO_INCREMENT | 贡献记录 ID |
|
||
| activity_id | bigint | NOT NULL, INDEX | 关联活动 ID |
|
||
| user_id | bigint | NOT NULL | 贡献者用户 ID |
|
||
| star_id | bigint | NOT NULL | 明星 ID |
|
||
| item_id | bigint | NOT NULL | 道具 ID |
|
||
| item_type | varchar(50) | NOT NULL | 道具类型,如 `firework`、`megaphone` |
|
||
| quantity | int | DEFAULT 1 | 本次贡献数量 |
|
||
| crystal_spent | bigint | NOT NULL | 花费的水晶数量 |
|
||
| contribution_points | bigint | NOT NULL | 贡献值 |
|
||
| created_at | bigint | NOT NULL, INDEX | 创建时间(毫秒时间戳) |
|
||
|
||
**索引设计**
|
||
|
||
| 索引名 | 索引字段 | 类型 | 用途 |
|
||
|--------|----------|------|------|
|
||
| idx_activity_id | (activity_id) | BTREE | 按活动 ID 筛选 |
|
||
| idx_activity_created | (activity_id, created_at DESC, id DESC) | BTREE | 按活动时间降序+ID降序查询,支持 since_timestamp + since_id 轮询 |
|
||
|
||
> `created_at DESC` 索引可加速 `WHERE activity_id = ? AND created_at > sinceTimestamp` 查询。
|
||
|
||
### 2.3 写入时机
|
||
|
||
`purchaseItem` 成功后,**同步写入**此表。
|
||
|
||
**连击计数器(Redis)**:
|
||
|
||
用户每次点击购买道具时,更新 Redis 连击计数器:
|
||
- **Key**:`combo:{user_id}:{item_type}`
|
||
- **Value**:当前连击数
|
||
- **TTL**:3 秒(无新点击则过期)
|
||
|
||
```
|
||
用户点击购买 → Redis INCR combo:{user_id}:{item_type} + EXPIRE 3秒
|
||
```
|
||
|
||
### 2.4 查询时获取连击数
|
||
|
||
前端轮询时,后端从 Redis 获取当前连击数,合并到返回结果中:
|
||
|
||
```
|
||
GET combo:{user_id}:{item_type} → 返回当前计数
|
||
```
|
||
|
||
**连击逻辑**:
|
||
- 同一用户、同一道具:3 秒内的多次点击累加显示
|
||
- 不同道具:独立计数,互不影响
|
||
|
||
---
|
||
|
||
## 3. 后端 API 设计
|
||
|
||
### 3.1 接口概述
|
||
|
||
| 接口 | 方法 | 路径 | 说明 |
|
||
|------|------|------|------|
|
||
| 获取最新贡献记录 | GET | `/api/v1/activity/:activityId/contributions/latest` | 实时轮询用 |
|
||
|
||
### 3.2 获取最新贡献记录接口
|
||
|
||
**请求**
|
||
```
|
||
GET /api/v1/activity/:activityId/contributions/latest
|
||
```
|
||
|
||
**Query 参数**
|
||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||
|------|------|------|--------|------|
|
||
| since_timestamp | int64 | 否 | 0 | 起始时间戳(毫秒),获取该时间之后的最新记录 |
|
||
| since_id | int64 | 否 | 0 | 起始记录 ID(用于同时间戳内的二次筛选) |
|
||
| limit | int32 | 否 | 1 | 每次拉取条数,最大 5 |
|
||
|
||
**响应(成功)**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": {
|
||
"records": [
|
||
{
|
||
"id": 12345,
|
||
"activity_id": 1001,
|
||
"user_id": 888,
|
||
"user_nickname": "小明",
|
||
"user_avatar": "https://example.com/avatar.jpg",
|
||
"item_type": "firework",
|
||
"item_name": "烟花",
|
||
"item_icon": "https://example.com/firework.png",
|
||
"quantity": 3,
|
||
"contribution_points": 3,
|
||
"combo_count": 2,
|
||
"created_at": 1747133400000
|
||
}
|
||
],
|
||
"latest_timestamp": 1747133400000,
|
||
"latest_id": 12345
|
||
}
|
||
}
|
||
```
|
||
|
||
**字段说明**:
|
||
| 字段 | 说明 |
|
||
|------|------|
|
||
| quantity | 本次 contribution 的实际贡献数量 |
|
||
| combo_count | 从 Redis 获取的当前连击数(3秒内同用户同道具的累计点击) |
|
||
|
||
**响应(失败)**
|
||
```json
|
||
{
|
||
"code": 40401,
|
||
"message": "活动不存在",
|
||
"data": null
|
||
}
|
||
```
|
||
|
||
### 3.3 接口流程
|
||
|
||
```
|
||
请求到达
|
||
│
|
||
▼
|
||
参数校验(activityId 格式、limit 范围)
|
||
│
|
||
▼
|
||
Gateway 调用 ActivityService(直接查 Repository)
|
||
│
|
||
▼
|
||
查询 activity_contributions 表,并联表查询用户信息和道具信息
|
||
SELECT c.id, c.activity_id, c.user_id,
|
||
u.nickname as user_nickname, u.avatar_url as user_avatar,
|
||
i.item_type, i.item_name, i.icon_url as item_icon,
|
||
c.quantity, c.contribution_points, c.created_at
|
||
FROM activity_contributions c
|
||
LEFT JOIN users u ON c.user_id = u.id
|
||
LEFT JOIN activity_items i ON c.item_id = i.id
|
||
WHERE c.activity_id = ?
|
||
AND (c.created_at > ? OR (c.created_at = ? AND c.id > ?))
|
||
ORDER BY c.created_at DESC, c.id DESC
|
||
LIMIT limit
|
||
│
|
||
▼
|
||
组装响应
|
||
latest_timestamp = 本次返回的最新记录时间戳
|
||
latest_id = 本次返回的最大 ID
|
||
combo_count = 从 Redis 获取连击数
|
||
│
|
||
▼
|
||
返回 code: 0 + records + latest_timestamp + latest_id
|
||
```
|
||
|
||
> 使用时间戳 + ID 双重条件查询,好处是:
|
||
> 1. 用户退出页面再进来时,用列表中最新记录的时间戳和 ID 作为起点
|
||
> 2. 同一毫秒内多条记录时,用 ID 做二次筛选,不会漏数据
|
||
> 3. `ORDER BY created_at DESC, id DESC` 保证同时间戳内 ID 大的在前
|
||
|
||
### 3.4 后端实现
|
||
|
||
#### Gateway 层(HTTP API)
|
||
|
||
新增 Controller:`gateway/controller/contribution_controller.go`
|
||
|
||
```go
|
||
package controller
|
||
|
||
type ContributionController struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewContributionController(db *gorm.DB) *ContributionController
|
||
|
||
// GetLatestContributions 获取最新贡献记录
|
||
// @Summary 获取活动最新贡献记录(实时轮询用)
|
||
// @Tags contributions
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param activityId path int64 true "活动ID"
|
||
// @Param since_timestamp query int64 false "起始时间戳(毫秒)"
|
||
// @Param since_id query int64 false "起始记录ID(用于同时间戳内的二次筛选)"
|
||
// @Param limit query int32 false "拉取条数,默认1,最大5"
|
||
// @Success 200 {object} response.Response
|
||
// @Router /api/v1/activity/{activityId}/contributions/latest [get]
|
||
func (ctrl *ContributionController) GetLatestContributions(c *gin.Context)
|
||
```
|
||
|
||
#### Repository 层
|
||
|
||
```go
|
||
// services/activityService/repository/activity_repository.go
|
||
|
||
// GetLatestContributions 获取最新的贡献记录(用于实时轮询)
|
||
// sinceTimestamp: 起始时间戳,sinceId: 起始记录ID(用于同时间戳内的二次筛选)
|
||
func (r *activityRepository) GetLatestContributions(activityID int64, sinceTimestamp int64, sinceId int64, limit int) ([]*models.ActivityContribution, int64, int64, error) {
|
||
query := r.db.Model(&models.ActivityContribution{}).
|
||
Where("activity_id = ?", activityID)
|
||
|
||
if sinceTimestamp > 0 && sinceId > 0 {
|
||
// 同一毫秒内用 ID 做二次筛选
|
||
query = query.Where("(created_at > ? OR (created_at = ? AND id > ?))", sinceTimestamp, sinceTimestamp, sinceId)
|
||
} else if sinceTimestamp > 0 {
|
||
query = query.Where("created_at > ?", sinceTimestamp)
|
||
}
|
||
|
||
var contributions []*models.ActivityContribution
|
||
if err := query.Order("created_at DESC, id DESC").Limit(limit).Find(&contributions).Error; err != nil {
|
||
return nil, 0, 0, err
|
||
}
|
||
|
||
// 返回本次返回的最新时间戳和最大 ID
|
||
var latestTimestamp, latestId int64
|
||
if len(contributions) > 0 {
|
||
latestTimestamp = contributions[0].CreatedAt
|
||
latestId = contributions[0].Id
|
||
}
|
||
|
||
return contributions, latestTimestamp, latestId, nil
|
||
}
|
||
```
|
||
|
||
**获取连击数(Service 层)**:
|
||
|
||
```go
|
||
func (s *ActivityService) GetComboCount(userID int64, itemType string) (int64, error) {
|
||
key := fmt.Sprintf("combo:%d:%s", userID, itemType)
|
||
count, err := s.redis.Get(key).Int64()
|
||
if err != nil || count == 0 {
|
||
return 1, nil // 无连击时返回 1
|
||
}
|
||
return count, nil
|
||
}
|
||
```
|
||
|
||
#### 写入时机(purchaseItem 改造)
|
||
|
||
当前 `PurchaseItem` 成功后已调用 `CreateContribution`,需确保冗余字段(user_nickname、user_avatar、item_name、item_icon)一并写入。
|
||
|
||
**连击计数**:
|
||
```go
|
||
// Redis INCR + EXPIRE
|
||
rdb.Incr(ctx, fmt.Sprintf("combo:%d:%s", userID, itemType))
|
||
rdb.Expire(ctx, fmt.Sprintf("combo:%d:%s", userID, itemType), 3*time.Second)
|
||
```
|
||
|
||
#### 查询时获取连击数
|
||
|
||
```go
|
||
// 从 Redis 获取 combo_count
|
||
comboCount, _ := rdb.Get(ctx, fmt.Sprintf("combo:%d:%s", userID, itemType)).Int64()
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 前端架构设计
|
||
|
||
### 4.1 目录结构
|
||
|
||
```
|
||
frontend/pages/support-activity/
|
||
├── components/
|
||
│ └── ContributionList.vue # 新增:贡献列表组件
|
||
├── composables/
|
||
│ └── useContributionPolling.js # 新增:贡献轮询逻辑
|
||
└── index.vue
|
||
```
|
||
|
||
### 4.2 数据流
|
||
|
||
```
|
||
页面可见
|
||
│
|
||
▼
|
||
useContributionPolling.start()
|
||
│
|
||
▼
|
||
首次全量拉取:GET /api/v1/activity/{id}/contributions/latest?since_timestamp=0&since_id=0&limit=5
|
||
│
|
||
▼
|
||
records = 返回的最新记录(最多 5 条)
|
||
latestTimestamp = 本次返回的最新时间戳
|
||
latestId = 本次返回的最大 ID
|
||
│
|
||
▼
|
||
定时轮询(每秒)
|
||
│
|
||
▼
|
||
GET /api/v1/activity/{id}/contributions/latest?since_timestamp={latestTimestamp}&since_id={latestId}&limit=1
|
||
│
|
||
▼
|
||
比对 records:
|
||
- 有新记录(时间戳更新 或 同时间戳内 ID 更新)→ 插入到列表头部,移除超出的记录,重置所有记录计时器
|
||
- 无新记录 → 不更新,计时器继续倒计时
|
||
│
|
||
▼
|
||
更新 latestTimestamp 和 latestId
|
||
|
||
页面不可见
|
||
│
|
||
▼
|
||
useContributionPolling.stop() — 停止轮询,保留 latestTimestamp 和 records
|
||
|
||
页面重新可见
|
||
│
|
||
▼
|
||
useContributionPolling.start() — 直接用现有的 latestTimestamp 继续轮询
|
||
```
|
||
|
||
### 4.3 轮询策略
|
||
|
||
| 参数 | 值 | 说明 |
|
||
|------|-----|------|
|
||
| 轮询间隔 | 1000ms | 每秒拉取一次,确保实时感 |
|
||
| 首次请求 | 全量拉取 5 条最新记录 | 获取最近 5 条实时贡献 |
|
||
| 轮询请求 | limit=1 | 每秒拉取 1 条,保持实时感 |
|
||
| 列表上限 | 5 条 | 超出后移除最旧记录 |
|
||
| 消失计时 | 每条记录 5 秒无更新则逐条淡出消失 | 无新数据时,最早那条记录 5 秒后先消失,后续记录依次前移并同样计时 |
|
||
| 暂停条件 | onHide | 页面隐藏时停止轮询,保留 latestTimestamp、latestId 和 records |
|
||
| 恢复条件 | onShow | 页面显示时继续轮询,使用现有的 latestTimestamp 和 latestId |
|
||
| 切换活动 | - | activityId 变化时,调用 reset() 重置所有状态 |
|
||
|
||
---
|
||
|
||
## 5. 组件设计
|
||
|
||
### 5.1 ContributionList.vue
|
||
|
||
**功能**
|
||
- 展示贡献记录滚动列表,最新记录在顶部
|
||
- 页面不可见时整体隐藏(v-if)
|
||
- 新记录有淡入动画,记录超时淡出消失
|
||
|
||
**Props**
|
||
| 属性 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| activityId | string | 是 | 活动 ID |
|
||
|
||
**模板结构**
|
||
```vue
|
||
<template>
|
||
<view class="contribution-list" v-if="visible">
|
||
<view class="list-header">
|
||
<text class="header-title">实时贡献</text>
|
||
</view>
|
||
<scroll-view class="list-content" scroll-y>
|
||
<view
|
||
v-for="(record, index) in records"
|
||
:key="record.id"
|
||
class="contribution-item"
|
||
:class="{ 'new-item': index === 0, 'fading-out': record.fading }"
|
||
>
|
||
<image class="user-avatar" :src="record.user_avatar" mode="aspectFill" />
|
||
<text class="user-nickname">{{ record.user_nickname }}</text>
|
||
<text class="contribute-text">贡献了</text>
|
||
<image class="item-icon" :src="record.item_icon" mode="aspectFill" />
|
||
<text class="item-name">{{ record.item_name }}</text>
|
||
<text class="item-quantity">x{{ record.combo_count > 1 ? record.combo_count : record.quantity }}</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
```
|
||
|
||
**样式要点**
|
||
- 固定高度(建议 200rpx),超出部分滚动
|
||
- 半透明背景(rgba(0,0,0,0.3))
|
||
- 每条记录一行:头像 + 昵称 + "贡献了" + 道具图标 + 名称 + 数量
|
||
- 新记录(index === 0)有 0.3s 淡入动画
|
||
- 消失时添加 fading 类,0.5s 淡出动画
|
||
|
||
### 5.2 useContributionPolling.js
|
||
|
||
**功能**
|
||
- 管理贡献记录的轮询逻辑
|
||
- 提供 start / stop 方法
|
||
- 自动处理增量拉取和列表更新
|
||
- 每条记录独立计时,超时淡出消失
|
||
|
||
**接口**
|
||
```javascript
|
||
// 输入
|
||
activityId: Ref<string>
|
||
|
||
// 输出
|
||
{
|
||
records, // Ref<ContributionRecord[]> — 当前展示的贡献列表
|
||
visible, // Ref<boolean> — 列表是否可见(用于 v-if 控制)
|
||
loading, // Ref<boolean> — 是否正在加载
|
||
error, // Ref<string | null> — 错误信息
|
||
start, // () => void — 开始轮询
|
||
stop, // () => void — 停止轮询(保留状态)
|
||
reset // () => void — 重置所有状态(切换活动时用)
|
||
}
|
||
|
||
// 内部状态
|
||
{
|
||
latestTimestamp, // number — 当前列表中最新记录的时间戳(继续轮询的起点)
|
||
latestId, // number — 当前列表中最新记录的 ID(同时间戳内二次筛选用)
|
||
pollingTimer, // number — setInterval 返回的 timer ID
|
||
isPolling, // boolean — 是否正在轮询
|
||
recordTimers, // Map — 记录 ID -> 定时器
|
||
}
|
||
```
|
||
|
||
**核心逻辑**
|
||
```javascript
|
||
const MAX_RECORDS = 5
|
||
const POLL_INTERVAL = 1000 // 每秒拉取
|
||
const RECORD_TTL = 5000 // 每条记录 5 秒后消失
|
||
|
||
let latestTimestamp = 0 // 初始为 0,获取所有最新记录
|
||
let latestId = 0 // 初始为 0
|
||
const recordTimers = new Map() // 记录 ID -> 定时器
|
||
|
||
function resetRecordTimer(record) {
|
||
// 清除已有定时器
|
||
if (recordTimers.has(record.id)) {
|
||
clearTimeout(recordTimers.get(record.id))
|
||
recordTimers.delete(record.id)
|
||
}
|
||
// 清除淡出状态(新数据到来时重置)
|
||
const target = records.value.find(r => r.id === record.id)
|
||
if (target) {
|
||
target.fading = false
|
||
}
|
||
// 设置新的消失定时器
|
||
const timer = setTimeout(() => {
|
||
// 标记为淡出状态
|
||
const target = records.value.find(r => r.id === record.id)
|
||
if (target) {
|
||
target.fading = true
|
||
// 等待动画完成后移除
|
||
setTimeout(() => {
|
||
records.value = records.value.filter(r => r.id !== record.id)
|
||
recordTimers.delete(record.id)
|
||
}, 500)
|
||
} else {
|
||
recordTimers.delete(record.id)
|
||
}
|
||
}, RECORD_TTL)
|
||
recordTimers.set(record.id, timer)
|
||
}
|
||
|
||
async function fetchLatest() {
|
||
// 拉取 since_timestamp 和 since_id 之后的最新记录
|
||
const res = await fetchActivityContributionsLatest(activityId.value, latestTimestamp, latestId, 1)
|
||
|
||
if (res.records.length === 0) return
|
||
|
||
const newRecord = res.records[0]
|
||
|
||
// 检测到新记录(时间戳更新,或时间戳相同但 ID 更新)
|
||
const isNew = newRecord.created_at > latestTimestamp ||
|
||
(newRecord.created_at === latestTimestamp && newRecord.id > latestId)
|
||
|
||
if (isNew) {
|
||
// 重置所有现有记录的计时器(新数据到来,刷新列表)
|
||
records.value.forEach(resetRecordTimer)
|
||
// 新记录插入到列表头部
|
||
records.value = [newRecord, ...records.value].slice(0, MAX_RECORDS)
|
||
// 为新记录启动消失计时器
|
||
resetRecordTimer(newRecord)
|
||
// 更新时间戳和 ID
|
||
latestTimestamp = newRecord.created_at
|
||
latestId = newRecord.id
|
||
}
|
||
}
|
||
|
||
function start() {
|
||
if (pollingTimer) return
|
||
isPolling.value = true
|
||
// 如果 latestTimestamp 为 0,说明是首次或切换活动,清空列表
|
||
if (latestTimestamp === 0) {
|
||
records.value = []
|
||
}
|
||
fetchLatest()
|
||
pollingTimer = setInterval(fetchLatest, POLL_INTERVAL)
|
||
}
|
||
|
||
function stop() {
|
||
if (pollingTimer) {
|
||
clearInterval(pollingTimer)
|
||
pollingTimer = null
|
||
}
|
||
// 停止所有记录的计时器
|
||
recordTimers.forEach(timer => clearTimeout(timer))
|
||
recordTimers.clear()
|
||
isPolling.value = false
|
||
// 不清空 records、latestTimestamp、latestId,保留状态以便恢复
|
||
}
|
||
|
||
function reset() {
|
||
// 切换活动时调用,重置所有状态
|
||
stop()
|
||
latestTimestamp = 0
|
||
latestId = 0
|
||
records.value = []
|
||
}
|
||
|
||
// 监听页面显示/隐藏
|
||
onShow(() => {
|
||
visible.value = true
|
||
start()
|
||
})
|
||
|
||
onHide(() => {
|
||
visible.value = false
|
||
stop()
|
||
})
|
||
```
|
||
|
||
> `fetchActivityContributionsLatest` 调用 `GET /api/v1/activity/{id}/contributions/latest?since_timestamp={sinceTimestamp}&since_id={sinceId}&limit={limit}`
|
||
>
|
||
> 新增 `reset()` 方法,切换活动时调用,重置所有状态。
|
||
|
||
---
|
||
|
||
## 6. 页面集成
|
||
|
||
### 6.1 组件引入
|
||
|
||
在 `ThemeBanner` 下方、`StageArea` 上方引入:
|
||
|
||
```vue
|
||
<!-- ThemeBanner 下方 -->
|
||
<ThemeBanner v-if="config" ... />
|
||
|
||
<!-- 贡献列表 -->
|
||
<ContributionList
|
||
v-if="activityId && !isLoading"
|
||
:activity-id="activityId"
|
||
class="contribution-list-wrapper"
|
||
/>
|
||
|
||
<!-- StageArea -->
|
||
<StageArea v-if="config" ... />
|
||
```
|
||
|
||
### 6.2 样式控制
|
||
|
||
```css
|
||
.contribution-list-wrapper {
|
||
width: 100%;
|
||
padding: 0 24rpx;
|
||
}
|
||
|
||
.contribution-item.fading-out {
|
||
animation: fadeOut 0.5s forwards;
|
||
}
|
||
|
||
@keyframes fadeOut {
|
||
from { opacity: 1; }
|
||
to { opacity: 0; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 性能与优化
|
||
|
||
| 优化点 | 方案 |
|
||
|--------|------|
|
||
| 列表更新 | 新记录插入到头部,使用展开运算符 |
|
||
| 去重 | 以 `record.id` 作为 v-for 的 :key + 时间戳+ID 双重比对 |
|
||
| 列表上限 | 超过 5 条时移除最旧记录 |
|
||
| 记录消失 | 每条记录 5 秒无更新则淡出消失 |
|
||
| 内存占用 | 页面隐藏时停止计时器,保留 records |
|
||
| 轮询策略 | 每秒拉取 1 条,保持实时感 |
|
||
| 同毫秒去重 | 使用 `since_timestamp + since_id` 双重条件,同一毫秒多条不漏 |
|
||
|
||
---
|
||
|
||
## 8. 错误处理
|
||
|
||
| 场景 | 处理方式 |
|
||
|------|----------|
|
||
| 接口返回 404(活动不存在) | 组件隐藏,不影响页面其他功能 |
|
||
| 接口返回 500(服务器错误) | 静默忽略,继续等待下一次 |
|
||
| 网络断开 | 页面 onHide 会停止轮询;恢复后 onShow 继续轮询 |
|
||
| 返回数据为空 | 不更新列表,不更新 latestTimestamp 和 latestId |
|
||
| 切换活动 | 调用 reset() 重置所有状态 |
|
||
| 同毫秒多条记录 | 使用 since_timestamp + since_id 双重筛选,不会漏数据 |
|
||
| 连击显示 | combo_count 从 Redis 获取,3秒内同用户同道具累加 |
|
||
|
||
---
|
||
|
||
## 9. 实施步骤
|
||
|
||
### 9.1 后端实现
|
||
1. [x] 在 `activity_contributions` 表联表查询用户信息和道具信息(LEFT JOIN users, LEFT JOIN activity_items)
|
||
2. [ ] 在 Redis 中实现连击计数器 `combo:{user_id}:{item_type}`,TTL 3秒
|
||
3. [ ] 在 `activity_repository.go` 添加 `GetLatestContributions` 方法
|
||
4. [ ] 在 Gateway 层添加 `contribution_controller.go`
|
||
5. [ ] 在路由注册新接口 `/api/v1/activity/:activityId/contributions/latest`
|
||
6. [ ] 验证 `purchaseItem` 后 `CreateContribution` 正常工作,并更新 Redis 连击计数器
|
||
|
||
### 9.2 前端 API 层
|
||
1. [ ] 在 `frontend/utils/activity-config.js` 中新增 `fetchActivityContributionsLatest` 方法
|
||
|
||
### 9.3 前端组件
|
||
1. [ ] 创建 `useContributionPolling.js`
|
||
2. [ ] 创建 `ContributionList.vue`
|
||
|
||
### 9.4 页面集成
|
||
1. [ ] 在 `index.vue` 中引入 `ContributionList`
|
||
|
||
### 9.5 测试验证
|
||
1. [ ] 轮询正常:每秒间隔拉取数据
|
||
2. [ ] 增量正确:新贡献出现时列表头部插入
|
||
3. [ ] 消失逻辑:无新数据 5 秒后记录淡出消失
|
||
4. [ ] 页面切换:onHide 停止,onShow 继续(latestTimestamp 保持)
|
||
5. [ ] 列表上限:超过 5 条时移除最旧记录
|
||
|
||
---
|
||
|
||
## 10. 待确认
|
||
|
||
- [x] 后端接口由我方实现
|
||
- [x] 道具图标由后端提供(冗余字段直接存储)
|
||
- [x] 仅显示当前页面实时数据(页面切换时暂停)
|
||
- [x] 后端框架:Go + Gin(Gateway)+ Dubbo-go(微服务)+ GORM
|
||
- [x] 数据库:MySQL
|
||
- [x] ORM:GORM
|
||
- [x] 冗余字段:user_nickname、user_avatar、item_name、item_icon 直接存在表中
|
||
- [x] 不需要"几秒前"等相对时间显示,只显示实时贡献
|
||
- [x] 轮询使用时间戳(since_timestamp)而非 ID,避免重复拉取
|
||
- [ ] 列表视觉样式是否有设计稿? |