Files
happy-life-star/docs/superpowers/specs/2026-07-19-favorites-real-api-design.md
T

271 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
author: AI Assistant
created_at: 2026-07-19
purpose: 小程序收藏功能真实 API 化,沿用现有独立收藏表架构
---
# 小程序收藏功能真实 API 化设计(v2)
## 概述
当前小程序的「收藏」功能(剧本收藏、人生事件收藏)完全基于前端 localStorage,未对接后端 API。
**重要发现**:剧本收藏后端 API 已经完整实现(独立的 `t_script_favorite` 表 + `ScriptFavoriteService` + Controller),但前端 `ScriptLibraryView.vue` 没有使用这些 API,还在用 localStorage。
本次修复:
1. **剧本收藏**:前端从 localStorage 切换到现有 API
2. **人生事件收藏**:仿照剧本收藏,新建独立的收藏表和 API,删除假接口
## 现状问题
### 剧本收藏
| 位置 | 问题 |
|---|---|
| `ScriptLibraryView.vue:159` | `localFavorites = ref(uni.getStorageSync('script_favorites') \|\| {})` |
| `ScriptLibraryView.vue:319-369` | `toggleFavorite` / `deleteScriptFromFavorites` 只写 localStorage |
| `ScriptLibraryView.vue:244` | `isFavorite``script.isFavorite \|\| localFavorites.value[id]` 读取 |
| `services/epicScript.js:206-232` | ✅ 已有 `toggleFavoriteScript` / `checkFavoriteScript` API 封装,但前端未调用 |
**后端已实现**
- `POST /epicScript/favorite/toggle` — 切换收藏
- `GET /epicScript/favorite/check?scriptId=xxx` — 检查单个剧本收藏状态
- `GET /epicScript/favorite/page` — 分页查询收藏列表
- `ScriptFavoriteService.getFavoritedScriptIds(Set<String>)` — 批量检查(Controller 未暴露)
### 人生事件收藏
| 位置 | 问题 |
|---|---|
| `life-event/detail.vue:144` | `isFavorite``event_favorite_${id}` localStorage 读 |
| `life-event/detail.vue:315-316` | toggle 写/删 `event_favorite_${id}` localStorage |
| `services/lifeEvent.js:67-69` | `favoriteEvent` 调用 `/lifeEvent/favorite-placeholder` 假接口 |
| `LifeEventController.java:153-163` | `/lifeEvent/favorite-placeholder` 是空壳,返回 echo + `placeholder: true` |
## 设计方案
### 存储方案(沿用现有架构)
**剧本收藏**:沿用现有 `t_script_favorite` 独立表(无需改动)。
**人生事件收藏**:仿照剧本,新建独立的 `t_event_favorite` 表。
```sql
CREATE TABLE t_event_favorite (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
create_by VARCHAR(64) COMMENT '创建人',
create_time DATETIME COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人',
update_time DATETIME COMMENT '更新时间',
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
remarks VARCHAR(500) COMMENT '备注',
INDEX idx_user_event (user_id, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
```
**选择独立表的理由**
1. 与剧本收藏架构保持一致,两套收藏机制对称
2. 支持多用户场景(每个用户独立收藏)
3. 支持分页查询"我的收藏"列表
4. 逻辑删除保留历史记录
### 后端改动
#### 1. 人生事件收藏(新增)
**Entity**`EventFavorite.java`
```java
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_event_favorite")
public class EventFavorite extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("event_id")
private String eventId;
}
```
**Mapper**`EventFavoriteMapper.java`
```java
@Mapper
public interface EventFavoriteMapper extends BaseMapper<EventFavorite> {
}
```
**Service**`EventFavoriteService.java` + `EventFavoriteServiceImpl.java`
```java
public interface EventFavoriteService extends IService<EventFavorite> {
EventFavoriteResponse toggleFavorite(String eventId);
PageResult<LifeEventResponse> getFavoritePage(long current, long size);
Set<String> getFavoritedEventIds(Set<String> eventIds);
boolean isFavorited(String eventId);
long getFavoriteCount();
}
```
实现完全仿照 `ScriptFavoriteServiceImpl`
**DTO**
- `EventFavoriteToggleRequest.java``{ eventId: String }`
- `EventFavoriteResponse.java``{ eventId, isFavorited, favoriteTime }`
**Controller**`LifeEventController.java` 新增
```java
// ==================== 人生事件收藏 ====================
@PostMapping(value = "/favorite/toggle")
public Result<EventFavoriteResponse> toggleFavorite(
@Valid @RequestBody EventFavoriteToggleRequest request) {
return Result.success(eventFavoriteService.toggleFavorite(request.getEventId()));
}
@GetMapping(value = "/favorite/page")
public Result<PageResult<LifeEventResponse>> getFavoritePage(
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "10") long size) {
return Result.success(eventFavoriteService.getFavoritePage(current, size));
}
@GetMapping(value = "/favorite/check")
public Result<EventFavoriteResponse> checkFavorite(@RequestParam String eventId) {
boolean favorited = eventFavoriteService.isFavorited(eventId);
EventFavoriteResponse r = new EventFavoriteResponse();
r.setEventId(eventId);
r.setIsFavorited(favorited);
return Result.success(r);
}
```
**删除**`/lifeEvent/favorite-placeholder` 假接口
#### 2. 剧本收藏(无需改动)
后端已完整,无需新增。
### 前端改动
#### 1. `ScriptLibraryView.vue`
**删除**
- `const localFavorites = ref(uni.getStorageSync('script_favorites') || {})`
- `toggleFavorite` / `deleteScriptFromFavorites` 的 localStorage 写/删逻辑
**修改**
- `isFavorite` 计算改为:调用 `epicScriptService.checkFavoriteScript(script.id)` 或在列表加载后批量检查
- `toggleFavorite` 改为调 `epicScriptService.toggleFavoriteScript(script.id)`
**优化**:为避免 N+1 请求,可在后端新增 `GET /epicScript/favorite/ids` 接口,返回当前用户所有收藏的 scriptId 集合,前端列表加载后一次性拿到。但本次先用 `checkFavoriteScript` 逐个检查,后续优化。
#### 2. `services/lifeEvent.js`
**修改**
```js
// 之前:post('/lifeEvent/favorite-placeholder', { id, favorite })
// 之后:
export const toggleFavoriteEvent = async (eventId) => {
const res = await post('/lifeEvent/favorite/toggle', { eventId })
return {
success: true,
isFavorited: res.data?.isFavorited ?? false,
favoriteTime: res.data?.favoriteTime ?? null
}
}
export const checkFavoriteEvent = async (eventId) => {
const res = await get('/lifeEvent/favorite/check', { eventId })
return res.data?.isFavorited ?? false
}
```
**删除**:旧的 `favoriteEvent` 方法
#### 3. `life-event/detail.vue`
**删除**
- `const isFavorite = ref(Boolean(uni.getStorageSync('event_favorite_${id}')))`
- `toggleFavorite` 的 localStorage 写/删
**修改**
- `isFavorite` 改为 `ref(false)`,页面加载时调用 `checkFavoriteEvent(id)` 初始化
- `toggleFavorite` 改为调 `toggleFavoriteEvent(id)`
## 数据流
```
剧本列表页 ScriptLibraryView
└─ getScriptList() (GET /epicScript/listAll)
└─ 返回剧本列表
└─ 对每个剧本调用 checkFavoriteScript(id) (GET /epicScript/favorite/check)
└─ 返回 isFavorited
└─ 前端渲染收藏状态
用户点剧本收藏
└─ toggleFavoriteScript(id) (POST /epicScript/favorite/toggle)
└─ 后端 t_script_favorite 表 insert/delete
└─ 返回 isFavorited
└─ 前端更新 UI
人生事件详情页 life-event/detail
└─ getEventById(id) (GET /lifeEvent/detail)
└─ 返回事件详情
└─ checkFavoriteEvent(id) (GET /lifeEvent/favorite/check)
└─ 返回 isFavorited
└─ 前端渲染收藏状态
用户点事件收藏
└─ toggleFavoriteEvent(id) (POST /lifeEvent/favorite/toggle)
└─ 后端 t_event_favorite 表 insert/delete
└─ 返回 isFavorited
└─ 前端更新 UI
```
## 不影响范围
- 列表渲染、筛选、排序逻辑不变,只换数据源(localStorage → API
- 详情页展示逻辑不变(数据源变化在 sub-project B 清理)
- 其他页面的剧本/事件展示(如首页推荐)不受影响
## 数据库迁移
新增迁移 SQL 文件 `server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql`
```sql
CREATE TABLE t_event_favorite (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
event_id VARCHAR(64) NOT NULL COMMENT '事件ID',
create_by VARCHAR(64) COMMENT '创建人',
create_time DATETIME COMMENT '创建时间',
update_by VARCHAR(64) COMMENT '更新人',
update_time DATETIME COMMENT '更新时间',
is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是',
remarks VARCHAR(500) COMMENT '备注',
INDEX idx_user_event (user_id, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表';
```
如果项目不用 Flyway,则手动执行或写入项目约定的 SQL 目录。
## 验收标准
- [ ] 剧本收藏:点击心形图标 → 调用 `POST /epicScript/favorite/toggle` → 后端字段更新 → 刷新页面后仍保持状态
- [ ] 人生事件收藏:点击收藏 → 调用 `POST /lifeEvent/favorite/toggle` → 同上
- [ ] 取消收藏:再次点击 → 同上
- [ ] 列表页 `isFavorite` 来自 API,不再依赖 localStorage
- [ ] 详情页 `isFavorite` 来自 API,不再依赖 localStorage
- [ ] `/lifeEvent/favorite-placeholder` 假接口已删除
- [ ] 浏览器 Console 无报错
- [ ] 后端 `mvn clean install` 编译通过
- [ ] 通过 `deploy.py backend` 部署到服务器后,在服务器环境验证