topfans/backend/docs/好友功能设计决策汇总.md
2026-04-07 22:29:48 +08:00

497 lines
13 KiB
Markdown
Raw Permalink 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.

# 好友功能设计决策汇总
> **文档状态**:已确认 ✅
> **确认日期**2026-01-06
> **目的**:快速查阅所有设计决策和关键配置
---
## 📋 设计决策快速索引
| 问题 | 决策 | 配置位置 | 状态 |
|------|------|---------|------|
| 好友数量限制 | 暂不限制,后续规则表动态配置 | `friend_config.go` | ✅ |
| 重复请求冷却期 | 7天 | `friend_config.go` | ✅ |
| 请求过期时间 | 30天 | `friend_config.go` | ✅ |
| 删除好友通知 | 不通知对方 | - | ✅ |
| 好友关系保留 | 完全删除(物理删除) | - | ✅ |
| 好友列表优化 | 分页查询 + 数据库索引 | 见索引设计 | ✅ |
| 并发控制 | 数据库事务 + 唯一索引 | - | ✅ |
| 跨服务调用 | Dubbo RPC | - | ✅ |
---
## 1. 业务逻辑决策
### 1.1 好友数量限制 ✅
**决策**:暂不限制,后续引入规则表动态配置
**配置位置**`backend/services/friendService/config/friend_config.go`
```go
FriendLimit: FriendLimitConfig{
Enabled: false, // 暂不启用
DefaultLimit: 0, // 不限制
MaxLimit: 10000, // 预留最大值
}
```
**理由**
- 初期不限制,简化实现
- 预留扩展性,后续可通过规则表根据用户等级、会员状态等动态限制
---
### 1.2 重复请求冷却期 ⭐ **关键** ✅
**决策**7天后可以再次发送
**配置位置**`backend/services/friendService/config/friend_config.go`
```go
TimeConstraints: TimeConstraints{
RejectionCooldownDays: 7,
RejectionCooldownMillis: 7 * 24 * 60 * 60 * 1000, // 7天
}
```
**实现逻辑**
1. 查询最近的请求记录(所有状态)
2. 如果状态为 `rejected`,检查 `processed_at` 距今是否超过 7 天
3. 未超过冷却期:返回 429 错误 + 剩余天数提示
4. 已超过冷却期:允许发送新请求
**使用方法**
```go
// 检查是否在冷却期内
if config.GlobalFriendConfig.IsInCooldownPeriod(rejectedAtMillis) {
remainingDays := config.GlobalFriendConfig.CalculateRemainingCooldownDays(rejectedAtMillis)
return fmt.Errorf("请求已被拒绝,请 %d 天后再试", remainingDays)
}
```
---
### 1.3 请求过期时间 ✅
**决策**30天
**配置位置**`backend/services/friendService/config/friend_config.go`
```go
TimeConstraints: TimeConstraints{
RequestExpiryDays: 30,
RequestExpiryMillis: 30 * 24 * 60 * 60 * 1000, // 30天
}
```
**实现逻辑**
- 创建请求时,设置 `expires_at = created_at + 30天`
- 定时任务扫描过期请求,更新状态为 `expired`
**使用方法**
```go
// 计算过期时间
expiryTime := config.GlobalFriendConfig.CalculateExpiryTime(createdAtMillis)
// 检查是否已过期
if config.GlobalFriendConfig.IsExpired(createdAtMillis) {
return errors.New("该请求已过期")
}
```
---
### 1.4 删除好友通知 ✅
**决策**:不通知对方(静默删除)
**理由**
- 避免尴尬和不必要的冲突
- 符合大多数社交产品的设计习惯
- 对方在下次查看好友列表时会自然发现
**实现要点**
- 删除好友时,只删除双向关系记录
- 不发送任何推送通知
- 不在对方界面显示"已解除好友关系"
---
### 1.5 好友关系保留 ✅
**决策**:完全删除(物理删除)
**理由**
- 简化数据模型,无需软删除字段
- 减少数据库存储压力
- 符合用户预期(删除就是删除)
**实现要点**
```sql
-- 删除双向关系
DELETE FROM friendships
WHERE (user_id = ? AND friend_id = ? AND star_id = ?)
OR (user_id = ? AND friend_id = ? AND star_id = ?);
-- 同时更新 fan_profiles 表的 social 字段
UPDATE fan_profiles
SET social = social - 1
WHERE (user_id = ? OR user_id = ?) AND star_id = ?;
```
---
## 2. 技术实现决策
### 2.1 好友列表查询优化 ✅
**决策**:支持分页查询 + 添加数据库索引
#### 2.1.1 分页查询
**API 参数**
```json
{
"page": 1,
"page_size": 20,
"keyword": "小明" // 可选,搜索昵称或备注
}
```
#### 2.1.2 数据库索引设计
**friendships 表索引**4个
```sql
-- 1. 复合唯一索引(防止重复添加)
CREATE UNIQUE INDEX uk_friendships_user_friend_star
ON friendships(user_id, friend_id, star_id);
-- 2. 用于基础好友列表查询
CREATE INDEX idx_friendships_user_star_status
ON friendships(user_id, star_id, status);
-- 3. 用于按时间排序的好友列表查询
CREATE INDEX idx_friendships_user_star_created
ON friendships(user_id, star_id, created_at DESC);
-- 4. 覆盖索引,包含常用查询字段,避免回表查询
CREATE INDEX idx_friendships_list_query
ON friendships(user_id, star_id, status, friend_id, created_at, remark);
```
**friend_requests 表索引**5个
```sql
-- 1. 用于查询收到的请求(按状态筛选)
CREATE INDEX idx_friend_requests_to_status
ON friend_requests(to_user_id, status, created_at DESC);
-- 2. 用于查询发出的请求
CREATE INDEX idx_friend_requests_from_status
ON friend_requests(from_user_id, status, created_at DESC);
-- 3. 用于查询特定明星下的请求
CREATE INDEX idx_friend_requests_star
ON friend_requests(star_id);
-- 4. 用于定时任务扫描过期请求
CREATE INDEX idx_friend_requests_expires
ON friend_requests(expires_at, status);
-- 5. 用于查询两个用户之间的最近请求(防骚扰机制)
CREATE INDEX idx_friend_requests_users_star
ON friend_requests(from_user_id, to_user_id, star_id, created_at DESC);
```
**fan_profiles 表索引**(用于关键词搜索):
```sql
-- 用于昵称搜索
CREATE INDEX idx_fan_profiles_nickname
ON fan_profiles(nickname);
```
#### 2.1.3 查询示例
**基础查询(无关键词)**
```sql
SELECT f.friend_id, f.remark, f.intimacy, f.created_at,
fp.nickname, fp.avatar_url, fp.fan_level
FROM friendships f
LEFT JOIN fan_profiles fp ON f.friend_id = fp.user_id AND f.star_id = fp.star_id
WHERE f.user_id = ? AND f.star_id = ? AND f.status = 'accepted'
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;
```
**带关键词搜索**
```sql
SELECT f.friend_id, f.remark, f.intimacy, f.created_at,
fp.nickname, fp.avatar_url, fp.fan_level
FROM friendships f
LEFT JOIN fan_profiles fp ON f.friend_id = fp.user_id AND f.star_id = fp.star_id
WHERE f.user_id = ? AND f.star_id = ? AND f.status = 'accepted'
AND (f.remark LIKE ? OR fp.nickname LIKE ?)
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;
```
---
### 2.2 并发控制 ✅
**决策**:使用数据库事务保证原子性
#### 2.2.1 唯一索引防止重复
```sql
CONSTRAINT uk_friendships_user_friend_star
UNIQUE (user_id, friend_id, star_id)
```
#### 2.2.2 事务使用场景
**场景1接受好友请求**
```go
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 1. 更新请求状态
tx.Model(&FriendRequest{}).Where("id = ?", requestID).
Updates(map[string]interface{}{
"status": "accepted",
"processed_at": time.Now().UnixMilli(),
})
// 2. 创建双向好友关系
tx.Create(&Friendship{UserID: userA, FriendID: userB, StarID: starID})
tx.Create(&Friendship{UserID: userB, FriendID: userA, StarID: starID})
// 3. 更新 social 字段
tx.Model(&FanProfile{}).Where("user_id = ? AND star_id = ?", userA, starID).
Update("social", gorm.Expr("social + ?", 1))
tx.Model(&FanProfile{}).Where("user_id = ? AND star_id = ?", userB, starID).
Update("social", gorm.Expr("social + ?", 1))
tx.Commit()
```
**场景2删除好友**
```go
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 1. 删除双向好友关系
tx.Where("(user_id = ? AND friend_id = ? AND star_id = ?) OR "+
"(user_id = ? AND friend_id = ? AND star_id = ?)",
userA, userB, starID, userB, userA, starID).
Delete(&Friendship{})
// 2. 更新 social 字段
tx.Model(&FanProfile{}).Where("user_id = ? AND star_id = ?", userA, starID).
Update("social", gorm.Expr("social - ?", 1))
tx.Model(&FanProfile{}).Where("user_id = ? AND star_id = ?", userB, starID).
Update("social", gorm.Expr("social - ?", 1))
tx.Commit()
```
#### 2.2.3 并发场景处理
**场景1A 和 B 同时向对方发送请求**
- 允许两个请求都创建成功
- 任一方接受后,双方都成为好友
**场景2A 删除好友的同时B 也在删除**
- 使用事务保证原子性
- 第一个事务成功,第二个事务会因为记录不存在而失败
- 返回友好的错误提示
---
### 2.3 跨服务调用 ✅
**决策**:通过 Dubbo RPC 调用
#### 2.3.1 UserService 客户端接口
```go
type UserServiceClient interface {
// 验证用户是否存在
ValidateUser(ctx context.Context, userID int64) (bool, error)
// 验证用户是否有指定的粉丝身份
ValidateFanProfile(ctx context.Context, userID, starID int64) (bool, error)
// 批量获取用户信息(用于好友列表展示)
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]*pb.UserInfo, error)
}
```
#### 2.3.2 调用时机
1. **发送好友请求前**
- 验证目标用户是否存在
- 验证目标用户是否有该粉丝身份
2. **获取好友列表时**
- 批量获取好友的用户信息(避免 N+1 问题)
#### 2.3.3 错误处理
```go
// RPC 调用失败
if err != nil {
return nil, errors.New("服务暂时不可用,请稍后重试")
}
// 用户不存在
if !exists {
return nil, errors.New("用户不存在")
}
// 用户没有该粉丝身份
if !hasFanProfile {
return nil, errors.New("对方没有该粉丝身份")
}
```
---
## 3. 配置文件说明
### 3.1 配置文件位置
`backend/services/friendService/config/friend_config.go`
### 3.2 配置结构
```go
type FriendConfig struct {
TimeConstraints TimeConstraints // 时间限制配置
FriendLimit FriendLimitConfig // 好友数量限制(预留)
}
type TimeConstraints struct {
RejectionCooldownDays int // 被拒绝后的冷却期(天数) = 7
RequestExpiryDays int // 好友请求的过期时间(天数) = 30
RejectionCooldownMillis int64 // 被拒绝后的冷却期(毫秒)
RequestExpiryMillis int64 // 好友请求的过期时间(毫秒)
}
type FriendLimitConfig struct {
Enabled bool // 是否启用好友数量限制 = false
DefaultLimit int // 默认好友数量上限 = 0 (不限制)
MaxLimit int // 最大好友数量上限 = 10000
}
```
### 3.3 使用方法
```go
// 初始化配置
config.InitFriendConfig()
// 获取全局配置
cfg := config.GlobalFriendConfig
// 检查是否在冷却期内
if cfg.IsInCooldownPeriod(rejectedAtMillis) {
remainingDays := cfg.CalculateRemainingCooldownDays(rejectedAtMillis)
return fmt.Errorf("请求已被拒绝,请 %d 天后再试", remainingDays)
}
// 计算过期时间
expiryTime := cfg.CalculateExpiryTime(createdAtMillis)
// 检查是否已过期
if cfg.IsExpired(createdAtMillis) {
return errors.New("该请求已过期")
}
```
---
## 4. 错误码定义
| 错误码 | 错误信息 | HTTP 状态码 | 说明 |
|--------|---------|------------|------|
| 20001 | 用户不存在 | 404 | 目标用户不存在 |
| 20002 | 对方没有该粉丝身份 | 400 | 目标用户没有指定的粉丝身份 |
| 20003 | 已经是好友 | 400 | 不能重复添加好友 |
| 20004 | 已发送过好友请求 | 400 | 已有未处理的好友请求 |
| 20005 | 好友请求不存在 | 404 | 请求 ID 不存在 |
| 20006 | 该请求已被处理 | 400 | 请求已经被接受或拒绝 |
| 20007 | 该请求已过期 | 400 | 请求已过期 |
| 20008 | 好友关系不存在 | 404 | 不是好友关系 |
| 20009 | 不能添加自己为好友 | 400 | 参数错误 |
| 20010 | 已达到好友数量上限 | 400 | 好友数量已达上限 |
| 20011 | 请求被拒绝,冷却期内 | 429 | 请求已被拒绝请X天后再试 |
---
## 5. 实现检查清单
### 5.1 数据层
- [ ] 创建 `Friendship` 模型
- [ ] 创建 `FriendRequest` 模型
- [ ] 实现 `FriendRepository` 接口
- [ ] 创建数据库迁移脚本
- [ ] 添加所有索引friendships 4个friend_requests 5个
### 5.2 服务层
- [ ] 定义 Proto 文件
- [ ] 实现 `SendFriendRequest` 方法(含防骚扰机制)
- [ ] 实现 `GetFriendRequests` 方法(分页)
- [ ] 实现 `HandleFriendRequest` 方法(事务)
- [ ] 实现 `GetFriendList` 方法(分页 + 关键词搜索)
- [ ] 实现 `DeleteFriend` 方法(事务)
- [ ] 实现 `SetFriendRemark` 方法
- [ ] 创建 userService RPC 客户端
### 5.3 网关层
- [ ] 创建 DTO 定义
- [ ] 创建转换器
- [ ] 实现 Controller
- [ ] 配置路由
### 5.4 测试
- [ ] 单元测试
- [ ] 集成测试
- [ ] 性能测试
---
## 6. 后续优化方向
### 6.1 短期优化
- [ ] 添加 Redis 缓存(缓存好友列表)
- [ ] 实现定时任务(扫描过期请求)
- [ ] 添加监控和日志
### 6.2 长期优化
- [ ] 引入规则表动态配置好友数量限制
- [ ] 实现好友推荐功能
- [ ] 实现屏蔽功能
- [ ] 实现亲密度系统
---
**文档版本**v1.0
**最后更新**2026-01-06
**状态**:已确认,可以开始实现 ✅