diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..76631db
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Skill(superpowers:subagent-driven-development)",
+ "Skill(superpowers:subagent-driven-development:*)"
+ ]
+ }
+}
diff --git a/docs/superpowers/plans/2026-05-14-activity-ranking-modal-implementation-plan.md b/docs/superpowers/plans/2026-05-14-activity-ranking-modal-implementation-plan.md
new file mode 100644
index 0000000..c2e1edd
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-14-activity-ranking-modal-implementation-plan.md
@@ -0,0 +1,716 @@
+# ActivityRankingModal 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 创建 `ActivityRankingModal.vue` 弹窗组件,从 `ThemeBanner` 点击触发,显示单活动的排名数据
+
+**Architecture:** 复用 `RankingModal.vue` 的现有样式主题和数据转换逻辑,创建专用于活动排名的简化弹窗组件
+
+**Tech Stack:** Vue 3 Composition API, uni-app, 复用现有 RankingModal/TOP3Card/RankingListItem 组件
+
+---
+
+## 文件结构
+
+```
+frontend/pages/support-activity/
+├── components/
+│ ├── ActivityRankingModal.vue ← 新建
+│ ├── ThemeBanner.vue ← 修改:添加点击事件触发弹窗
+│ ├── TOP3Card.vue ← 复用(来自 pages/components/)
+│ └── RankingListItem.vue ← 复用(来自 pages/components/)
+└── index.vue ← 修改:引入并使用 ActivityRankingModal
+
+frontend/utils/api.js ← 无需修改(getActivityRankingApi 已存在)
+docs/superpowers/specs/2026-05-14-activity-ranking-modal-design.md ← 已存在
+```
+
+---
+
+## 任务分解
+
+### Task 1: 创建 ActivityRankingModal.vue 组件
+
+**文件:**
+- 创建: `frontend/pages/support-activity/components/ActivityRankingModal.vue`
+
+- [ ] **Step 1: 创建基础模板和样式结构**
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 加载中...
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
+ 暂无排名数据
+
+
+
+
+
+
+
+
+
+
+ 加载中...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatPopularityScore(currentUserInfo.popularityScore) }}
+
+
+
+ {{ formatCurrentUserRank(currentUserInfo.rank) }}
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: 实现 script 部分**
+
+```vue
+
+```
+
+- [ ] **Step 3: 添加样式**
+
+```vue
+
+```
+
+---
+
+### Task 2: 修改 ThemeBanner.vue 添加点击事件
+
+**文件:**
+- 修改: `frontend/pages/support-activity/components/ThemeBanner.vue:1-36`(template 部分)
+
+- [ ] **Step 1: 在 banner-content 上添加点击事件**
+
+在 `` 上添加 `@tap="handleBannerClick"`
+
+- [ ] **Step 2: 添加 emit 定义**
+
+在 script 部分添加:
+```javascript
+const emit = defineEmits(['tap'])
+
+// 添加点击处理函数
+const handleBannerClick = () => {
+ emit('tap')
+}
+```
+
+---
+
+### Task 3: 修改 support-activity/index.vue 引入和使用组件
+
+**文件:**
+- 修改: `frontend/pages/support-activity/index.vue:14-21`(template)
+- 修改: `frontend/pages/support-activity/index.vue:105-122`(script import)
+
+- [ ] **Step 1: 引入 ActivityRankingModal**
+
+```javascript
+import ActivityRankingModal from './components/ActivityRankingModal.vue'
+```
+
+- [ ] **Step 2: 添加弹窗状态和引用**
+
+```javascript
+const rankingModalVisible = ref(false)
+const currentActivityTitle = ref('')
+```
+
+- [ ] **Step 3: 在 template 中添加组件**
+
+```vue
+
+
+
+
+
+```
+
+- [ ] **Step 4: 添加打开弹窗方法**
+
+```javascript
+const openRankingModal = () => {
+ currentActivityTitle.value = config.value?.title || '活动排名'
+ rankingModalVisible.value = true
+}
+```
+
+---
+
+### Task 4: 提交代码
+
+- [ ] **Step 1: 提交所有更改**
+
+```bash
+git add frontend/pages/support-activity/components/ActivityRankingModal.vue \
+ frontend/pages/support-activity/components/ThemeBanner.vue \
+ frontend/pages/support-activity/index.vue
+git commit -m "feat: 添加 ActivityRankingModal 活动榜单弹窗组件"
+```
+
+---
+
+## 验证步骤
+
+1. 启动开发服务器:`cd frontend && npm run dev`
+2. 进入活动页面,点击 ThemeBanner 区域
+3. 验证弹窗正常显示TOP3和排名列表
+4. 验证下拉刷新和滚动加载更多功能
+5. 验证当前用户栏正确显示
+
+---
+
+## 依赖项
+
+- `TOP3Card.vue` - 来自 `frontend/pages/components/TOP3Card.vue`
+- `RankingListItem.vue` - 来自 `frontend/pages/components/RankingListItem.vue`
+- `getActivityRankingApi` - 来自 `frontend/utils/api.js`
\ No newline at end of file
diff --git a/docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md b/docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md
index 705e9d7..2076230 100644
--- a/docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md
+++ b/docs/superpowers/specs/2026-05-13-contribution-realtime-display-design.md
@@ -677,15 +677,284 @@ onHide(() => {
---
-## 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,避免重复拉取
-- [ ] 列表视觉样式是否有设计稿?
\ No newline at end of file
+## 11. Redis 缓存 + 时间窗口合并 + 分布式锁优化
+
+### 11.1 背景与目标
+
+#### 背景
+- 前端已实现每秒轮询 `GET /api/v1/activity/:activityId/contributions/latest`
+- 如果 10,000 用户同时轮询,每秒 10,000 次 DB 查询,存在性能瓶颈
+
+#### 目标
+- **秒级合并**:10s/12s/13s 的查询请求,统一在 10s 窗口执行一次 DB 查询
+- **有新数据立即返回**:如果缓存中检测到新记录,直接返回最新数据
+- **无新数据合并查询**:窗口内无写入时,后续请求复用缓存结果
+
+#### 性能指标
+- 10k 并发轮询 → 实际 DB 查询频率 ≤ 1次/秒(正常状态)
+- 缓存未命中时首个请求有 100-300ms 延迟,后续请求 < 5ms
+- 缓存 TTL 5秒,滚动窗口
+
+### 11.2 整体流程
+
+```
+用户轮询请求 (10k 并发)
+ │
+ ▼
+ Gateway API
+ │
+ ▼
+ 查询 Redis 缓存
+ activity:{id}:contributions:latest
+ │
+ ├── 有缓存 + 窗口有效(now_ms - updated_at < 1000ms)
+ │ │
+ │ ▼
+ │ 检查 sinceTimestamp:
+ │ - sinceTimestamp <= updated_at → 缓存数据够新,直接返回
+ │ - sinceTimestamp > updated_at → 继续查 DB(数据可能不够新)
+ │
+ └── 缓存不存在 / 过期
+ │
+ ▼
+ SETNX 加分布式锁
+ lock: activity:{id}:contributions:lock
+ (5秒自动释放,防止死锁)
+ │
+ ├── 获取锁成功
+ │ │
+ │ ▼
+ │ 查询 PostgreSQL
+ │ 回填 Redis (TTL=5秒)
+ │ 释放锁
+ │ │
+ │ ▼
+ │ 返回数据
+ │
+ └── 获取锁失败
+ │
+ ▼
+ 等待 100ms 重试
+ (最多 3 次,避免长时间等待)
+```
+
+### 11.3 Redis Key 设计
+
+| Key | 类型 | 说明 | TTL |
+|-----|------|------|-----|
+| `activity:{activityId}:contributions:latest` | Hash | 最新贡献记录缓存 | 5秒 |
+| `activity:{activityId}:contributions:lock` | String | 分布式锁 | 5秒 |
+
+### 11.4 缓存数据结构
+
+```json
+{
+ "records": [
+ {
+ "id": 12345,
+ "user_id": 1001,
+ "user_nickname": "用户昵称",
+ "user_avatar": "https://...",
+ "item_id": 1,
+ "item_type": "gift_flower",
+ "item_name": "玫瑰花",
+ "item_icon": "https://...",
+ "quantity": 10,
+ "crystal_spent": 100,
+ "contribution_points": 50,
+ "combo_count": 2,
+ "created_at": 1747133400000
+ }
+ ],
+ "updated_at": 1747133400000,
+ "latest_id": 12345
+}
+```
+
+**字段说明:**
+- `records`: 最新贡献记录数组(最多 5 条),**每条记录的 combo_count 已合并**
+- `updated_at`: 窗口时间戳(毫秒级 Unix 时间戳),用于判断是否在有效窗口内
+- `latest_id`: 最新记录的 ID,用于增量检测
+
+**时间戳单位**:统一使用**毫秒**,与前端 `sinceTimestamp` 参数单位一致
+
+### 11.5 查询流程(伪代码)
+
+```go
+func GetContributionsLatest(activityId int64, sinceTimestamp int64, sinceId int64, limit int) ([]*ContributionRecord, error) {
+ ctx := context.Background()
+ cacheKey := fmt.Sprintf("activity:%d:contributions:latest", activityId)
+ lockKey := fmt.Sprintf("activity:%d:contributions:lock", activityId)
+ nowMs := time.Now().UnixMilli()
+
+ // 1. 尝试获取缓存
+ cached := redis.Get(ctx, cacheKey)
+ if cached != nil {
+ cache := parseCache(cached)
+ // 缓存有效(1秒窗口内)→ 检查 sinceTimestamp 是否在窗口内
+ if cache != nil && nowMs-cache.UpdatedAt < 1000 {
+ // sinceTimestamp <= updated_at,说明请求的数据在缓存窗口内,直接返回
+ if sinceTimestamp <= cache.UpdatedAt {
+ return cache.Records, nil
+ }
+ // sinceTimestamp > updated_at,缓存数据可能不够新,继续查 DB
+ }
+ }
+
+ // 2. 缓存不存在或过期或数据不够新,尝试加锁
+ locked := redis.SetNX(ctx, lockKey, "1", 5*time.Second)
+ if !locked {
+ // 3. 获取锁失败,等待重试
+ for i := 0; i < 3; i++ {
+ time.Sleep(100 * time.Millisecond)
+ cached := redis.Get(ctx, cacheKey)
+ if cached != nil {
+ cache := parseCache(cached)
+ if cache != nil && nowMs-cache.UpdatedAt < 1000 && sinceTimestamp <= cache.UpdatedAt {
+ return cache.Records, nil
+ }
+ }
+ }
+ // 重试 3 次后仍失败,返回错误或旧缓存
+ return nil, errors.New("cache unavailable after retry")
+ }
+
+ // 4. 获取锁成功,查 DB
+ defer redis.Del(ctx, lockKey)
+
+ records, err := db.QueryContributions(activityId, sinceTimestamp, sinceId, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ // 5. 回填缓存(TTL 5秒)
+ if len(records) > 0 {
+ // 为每条记录获取最新的 combo_count
+ for _, record := range records {
+ comboCount, _ := redis.Get(ctx, fmt.Sprintf("combo:%d:%s", record.UserId, record.ItemType)).Int64()
+ if comboCount > 0 {
+ record.ComboCount = comboCount
+ }
+ }
+
+ cache := &ContributionCache{
+ Records: records,
+ UpdatedAt: nowMs,
+ LatestId: records[0].Id,
+ }
+ redis.Set(ctx, cacheKey, cache, 5*time.Second)
+ }
+
+ return records, nil
+}
+```
+
+### 11.6 写入流程(使缓存失效)
+
+在 `PurchaseItem` 成功写入 `activity_contributions` 后:
+
+```go
+func (s *activityService) PurchaseItem(...) error {
+ // ... 原有的购买逻辑 ...
+
+ // 1. 写数据库
+ err := s.repo.CreateContribution(contribution)
+ if err != nil {
+ return err
+ }
+
+ // 2. 使缓存失效(不是更新,是删除)
+ cacheKey := fmt.Sprintf("activity:%d:contributions:latest", activityId)
+ redis.Del(ctx, cacheKey)
+
+ return nil
+}
+```
+
+**为什么不更新缓存而是删除?**
+- 如果更新缓存,需要处理并发写入的 race condition
+- 删除缓存让下次查询触发重建,逻辑更简单且正确
+
+### 11.7 窗口合并策略
+
+#### 时间窗口对齐
+- 窗口粒度:**1 秒**(1000 毫秒,可调整)
+- 查询时:如果 `now_ms - cache.updated_at < 1000ms`,认为是同一窗口
+- 超过 1 秒:缓存过期,下次查询触发重建
+
+#### sinceTimestamp 过滤逻辑
+- 前端传 `sinceTimestamp`(毫秒)进行增量查询
+- 缓存命中时,判断 `sinceTimestamp <= cache.updated_at`:
+ - `true` → 请求的数据在缓存窗口内,直接返回缓存
+ - `false` → 缓存数据不够新,继续查 DB
+
+#### 有新数据时的处理
+- 后端对比 `sinceId`:
+ - `sinceId > cache.latest_id` → 有新数据,返回最新记录
+ - `sinceId <= cache.latest_id` → 无新数据,返回缓存数据
+
+#### combo_count 合并
+- 缓存回填时,从 Redis 获取每条记录的 `combo:{user_id}:{item_type}` 值
+- 合并到 `record.combo_count` 字段
+- 如果 Redis 无值,视为 1
+
+### 11.8 分布式锁设计
+
+#### 锁 Key
+```
+lock: activity:{activityId}:contributions:lock
+```
+
+#### 锁参数
+- **TTL**: 5 秒(防止进程崩溃导致死锁)
+- **重试**: 获取失败后等待 100ms 重试,最多 3 次
+- **释放**: 使用后立即删除(`DEL` 命令)
+
+#### 可靠性说明
+- 锁仅用于防止**缓存击穿**(cache stampede)
+- 锁持有时间极短(一次 DB 查询,约 10-50ms)
+- 单实例 Redis 足够,无需 Redlock
+
+### 11.9 错误处理
+
+| 场景 | 处理方式 |
+|------|----------|
+| Redis 不可用 | 回退到直接查 DB(降级) |
+| 获取锁失败 + 重试 3 次后仍失败 | 返回 503 Service Unavailable 或返回旧缓存 |
+| DB 查询失败 | 返回错误,前端显示重试 |
+| 缓存为空(无数据) | 返回空数组,不缓存 |
+
+### 11.10 影响范围
+
+#### 需要修改的文件
+1. **Gateway 层**
+ - `gateway/controller/activity_controller.go` — 添加 `GetContributionsLatest` 方法(如果尚未添加)
+
+2. **Service 层**
+ - `services/activityService/provider/activity_provider.go` — 实现缓存逻辑 + 锁逻辑
+
+3. **Repository 层**
+ - `services/activityService/repository/activity_repository.go` — `GetLatestContributions` 查询方法(如果尚未添加)
+
+4. **Proto 定义**(如使用 Dubbo RPC)
+ - `pkg/proto/activity/activity.proto` — 添加 `GetContributionsLatestRequest/Response`
+ - 重新生成 `activity.pb.go`
+
+#### 不需要修改的文件
+- 前端 `ContributionList.vue` 和 `useContributionPolling.js` 无需改动(接口兼容)
+
+### 11.11 测试要点
+
+1. **并发测试**:10k 请求同时发起,验证 DB 只查询 1 次
+2. **缓存失效测试**:Purchase 后,验证缓存被正确删除
+3. **锁竞争测试**:缓存失效瞬间,多个请求抢锁,验证只有一个请求查 DB
+4. **降级测试**:Redis 不可用时,验证服务能回退到直连 DB
+5. **增量查询测试**:传入 `sinceId`,验证只返回增量数据
+
+### 11.12 后续优化(可选)
+
+1. **多级缓存**:引入本地内存缓存(如 Go 的 `sync.Map`),减少 Redis 请求
+2. **窗口动态调整**:根据并发量动态调整窗口大小
+3. **监控告警**:监控缓存命中率、锁等待时间、DB 查询 QPS
\ No newline at end of file
diff --git a/frontend/pages/components/TOP3Card.vue b/frontend/pages/components/TOP3Card.vue
index c3e4f16..1a832b1 100644
--- a/frontend/pages/components/TOP3Card.vue
+++ b/frontend/pages/components/TOP3Card.vue
@@ -78,8 +78,6 @@
-
-
{{'用户 :'}}
diff --git a/frontend/pages/support-activity/components/ActionBar.vue b/frontend/pages/support-activity/components/ActionBar.vue
index 679de4a..be6e4c2 100644
--- a/frontend/pages/support-activity/components/ActionBar.vue
+++ b/frontend/pages/support-activity/components/ActionBar.vue
@@ -638,8 +638,16 @@ defineExpose({
display: flex;
align-items: center;
gap: 8rpx;
- background: rgba(0, 0, 0, 0.2);
+ /* background: rgba(0, 0, 0, 0.2); */
padding: 8rpx 20rpx;
+ background: linear-gradient(to bottom right,
+ #F0E4B1 0%,
+ #F08399 50%,
+ #B94E73 100%);
+ border-radius: 24rpx;
+ box-shadow:
+ 0 4rpx 12rpx rgba(255, 143, 158, 0.2),
+ inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
border-radius: 30rpx;
}
@@ -715,7 +723,6 @@ defineExpose({
gap: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.3);
- margin-top: 16rpx;
}
.quantity-selector {
diff --git a/frontend/pages/support-activity/components/ActivityRankingModal.vue b/frontend/pages/support-activity/components/ActivityRankingModal.vue
index b014ce5..a3a1025 100644
--- a/frontend/pages/support-activity/components/ActivityRankingModal.vue
+++ b/frontend/pages/support-activity/components/ActivityRankingModal.vue
@@ -6,21 +6,21 @@
-
+
-
@@ -42,11 +42,39 @@
- emit('view-profile', { userId: payload.userId })" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ user.nickname || '未知用户' }}
+
+
+
+
+ {{ formatPopularityScore(user.popularityScore)
+ }}
+
+
+
+ 拜访
+
+
@@ -58,12 +86,33 @@
-
+
+
+
+ {{ item.rank }}
+
+
+
+
+ {{ item.nickname || '未知用户' }}
+
+
+
+
+
+
+ {{
+ formatPopularityScore(item.popularityScore)
+ }}
+
+
+ 拜访
+
+
+
@@ -91,9 +140,9 @@
-
+
{{ formatPopularityScore(currentUserInfo.popularityScore)
- }}
+ }}
@@ -110,10 +159,8 @@