497 lines
13 KiB
Markdown
497 lines
13 KiB
Markdown
# 好友功能设计决策汇总
|
||
|
||
> **文档状态**:已确认 ✅
|
||
> **确认日期**: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 并发场景处理
|
||
|
||
**场景1:A 和 B 同时向对方发送请求**
|
||
- 允许两个请求都创建成功
|
||
- 任一方接受后,双方都成为好友
|
||
|
||
**场景2:A 删除好友的同时,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
|
||
**状态**:已确认,可以开始实现 ✅
|
||
|