Skip to content

Commit 30daf9d

Browse files
committed
feat(follows): 用户关注系统(粉丝/关注数 + follow/unfollow + 列表)
后端新增 user_follows 表(follower_id, followee_id, created_at), SaToken 白名单放行读接口,写接口强制登录。 端点: - POST /api/user-center/follows/{identifier} 关注(幂等) - DELETE /api/user-center/follows/{identifier} 取消关注(幂等) - GET /api/user-center/follows/stats/{identifier} 粉丝/关注数,附带登录用户 isFollowing - GET /api/user-center/follows/is-following/{identifier} 当前登录用户是否关注 - GET /api/user-center/follows/followers/{identifier} 粉丝列表 UserView[] - GET /api/user-center/follows/following/{identifier} 关注列表 UserView[] 实现要点: - ON CONFLICT DO NOTHING 保证 follow 幂等 + 避免并发竞争 - 不能关注自己(IllegalArgumentException) - identifier 复用 UserCenterService.findByIdentifier 支持数字/username 双路径 DB schema:已在 Neon 手动执行 CREATE TABLE,同步在 frontend/prisma/schema.prisma 加 model
1 parent 6a04f35 commit 30daf9d

3 files changed

Lines changed: 291 additions & 0 deletions

File tree

src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public void addInterceptors(InterceptorRegistry registry) {
2626
.notMatch("/analytics/events/summary") // 事件聚合摘要,公开只读接口
2727
.notMatch("/analytics/events") // 浏览器埋点写入,匿名也放行(登录用户通过 satoken header 识别)
2828
.notMatch("/api/user-center/profile/**") // 个人主页公开读接口,匿名可访问
29+
.notMatch("/api/user-center/follows/stats/**") // 粉丝/关注数公开读
30+
.notMatch("/api/user-center/follows/followers/**") // 粉丝列表公开读
31+
.notMatch("/api/user-center/follows/following/**") // 关注列表公开读
32+
.notMatch("/api/user-center/follows/is-following/**") // 匿名查询时返回 false
2933
.notMatch("/api/docs/history") // 文档修改历史公开读,匿名可访问
3034
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
3135
})).addPathPatterns("/**");
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package com.involutionhell.backend.usercenter.follows;
2+
3+
import cn.dev33.satoken.annotation.SaCheckLogin;
4+
import cn.dev33.satoken.stp.StpUtil;
5+
import com.involutionhell.backend.common.api.ApiResponse;
6+
import com.involutionhell.backend.usercenter.dto.UserView;
7+
import com.involutionhell.backend.usercenter.model.UserAccount;
8+
import com.involutionhell.backend.usercenter.service.UserCenterService;
9+
import org.springframework.web.bind.annotation.DeleteMapping;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PathVariable;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RequestParam;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
import java.util.HashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.Optional;
21+
22+
/**
23+
* 用户关注 / 粉丝相关接口。
24+
*
25+
* 公开接口(SaToken 白名单):
26+
* - GET /api/user-center/follows/stats/{identifier} 统计某人的粉丝/关注数
27+
* - GET /api/user-center/follows/followers/{identifier} 谁关注了 Ta
28+
* - GET /api/user-center/follows/following/{identifier} Ta 关注了谁
29+
* - GET /api/user-center/follows/is-following/{identifier} 当前登录用户是否关注 Ta(匿名返回 false)
30+
*
31+
* 登录才能操作的接口(SaCheckLogin):
32+
* - POST /api/user-center/follows/{identifier}
33+
* - DELETE /api/user-center/follows/{identifier}
34+
*
35+
* identifier 参数和 /u/{identifier} 保持一致:纯数字按 github_id 查,否则按 username 查。
36+
*/
37+
@RestController
38+
@RequestMapping("/api/user-center/follows")
39+
public class FollowController {
40+
41+
private final FollowService followService;
42+
private final UserCenterService userCenterService;
43+
44+
public FollowController(FollowService followService, UserCenterService userCenterService) {
45+
this.followService = followService;
46+
this.userCenterService = userCenterService;
47+
}
48+
49+
/** 关注 (identifier)。幂等。 */
50+
@SaCheckLogin
51+
@PostMapping("/{identifier}")
52+
public ApiResponse<Map<String, Object>> follow(@PathVariable String identifier) {
53+
long followerId = StpUtil.getLoginIdAsLong();
54+
UserAccount target = resolveOrThrow(identifier);
55+
followService.follow(followerId, target.id());
56+
return okStats(target);
57+
}
58+
59+
/** 取消关注 (identifier)。幂等。 */
60+
@SaCheckLogin
61+
@DeleteMapping("/{identifier}")
62+
public ApiResponse<Map<String, Object>> unfollow(@PathVariable String identifier) {
63+
long followerId = StpUtil.getLoginIdAsLong();
64+
UserAccount target = resolveOrThrow(identifier);
65+
followService.unfollow(followerId, target.id());
66+
return okStats(target);
67+
}
68+
69+
/** 统计 (identifier) 的粉丝 / 关注数。匿名可访问。 */
70+
@GetMapping("/stats/{identifier}")
71+
public ApiResponse<Map<String, Object>> stats(@PathVariable String identifier) {
72+
UserAccount target = resolveOrThrow(identifier);
73+
return okStats(target);
74+
}
75+
76+
/** 当前登录用户是否关注了 (identifier)。匿名返回 false。 */
77+
@GetMapping("/is-following/{identifier}")
78+
public ApiResponse<Map<String, Object>> isFollowing(@PathVariable String identifier) {
79+
UserAccount target = resolveOrThrow(identifier);
80+
boolean yes = false;
81+
try {
82+
if (StpUtil.isLogin()) {
83+
long followerId = StpUtil.getLoginIdAsLong();
84+
yes = followService.isFollowing(followerId, target.id());
85+
}
86+
} catch (Exception ignored) {
87+
// 未登录或 token 非法都按 false 处理
88+
}
89+
Map<String, Object> body = new HashMap<>();
90+
body.put("isFollowing", yes);
91+
return ApiResponse.ok(body);
92+
}
93+
94+
/** 粉丝列表:谁关注了 (identifier)。返回 UserView 数组。 */
95+
@GetMapping("/followers/{identifier}")
96+
public ApiResponse<List<UserView>> followers(
97+
@PathVariable String identifier,
98+
@RequestParam(defaultValue = "50") int limit,
99+
@RequestParam(defaultValue = "0") int offset
100+
) {
101+
UserAccount target = resolveOrThrow(identifier);
102+
List<Long> ids = followService.listFollowerIds(target.id(), limit, offset);
103+
return ApiResponse.ok(usersByIds(ids));
104+
}
105+
106+
/** 关注列表:(identifier) 关注了谁。返回 UserView 数组。 */
107+
@GetMapping("/following/{identifier}")
108+
public ApiResponse<List<UserView>> following(
109+
@PathVariable String identifier,
110+
@RequestParam(defaultValue = "50") int limit,
111+
@RequestParam(defaultValue = "0") int offset
112+
) {
113+
UserAccount target = resolveOrThrow(identifier);
114+
List<Long> ids = followService.listFollowingIds(target.id(), limit, offset);
115+
return ApiResponse.ok(usersByIds(ids));
116+
}
117+
118+
// ----- helpers -----
119+
120+
private UserAccount resolveOrThrow(String identifier) {
121+
Optional<UserAccount> account = userCenterService.findByIdentifier(identifier);
122+
if (account.isEmpty()) {
123+
throw new IllegalArgumentException("用户不存在: " + identifier);
124+
}
125+
return account.get();
126+
}
127+
128+
/**
129+
* 给定用户的粉丝/关注数标准返回体。
130+
* 登录用户再附带一条 `isFollowing` 方便前端一次拉到位。
131+
*/
132+
private ApiResponse<Map<String, Object>> okStats(UserAccount target) {
133+
Map<String, Object> body = new HashMap<>();
134+
body.put("userId", target.id());
135+
body.put("followerCount", followService.countFollowers(target.id()));
136+
body.put("followingCount", followService.countFollowing(target.id()));
137+
try {
138+
if (StpUtil.isLogin()) {
139+
long me = StpUtil.getLoginIdAsLong();
140+
body.put("isFollowing",
141+
me != target.id() && followService.isFollowing(me, target.id()));
142+
} else {
143+
body.put("isFollowing", false);
144+
}
145+
} catch (Exception ignored) {
146+
body.put("isFollowing", false);
147+
}
148+
return ApiResponse.ok(body);
149+
}
150+
151+
/**
152+
* 批量把 user_accounts.id 展开成 UserView。保留输入顺序(关注时间倒序)。
153+
*/
154+
private List<UserView> usersByIds(List<Long> ids) {
155+
return ids.stream()
156+
.map(userCenterService::getUser) // 不存在直接抛;这里我们默认 DB 一致
157+
.toList();
158+
}
159+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.involutionhell.backend.usercenter.follows;
2+
3+
import org.springframework.dao.DuplicateKeyException;
4+
import org.springframework.jdbc.core.JdbcTemplate;
5+
import org.springframework.stereotype.Service;
6+
7+
import java.util.List;
8+
9+
/**
10+
* 用户关注关系服务。操作 user_follows 表(follower_id, followee_id, created_at)。
11+
*
12+
* - follow / unfollow 都是幂等操作:重复 follow 不报错,unfollow 不存在的也 OK
13+
* - 反向查询(粉丝列表 / 关注列表)通过 JOIN user_accounts 补用户元信息,但这里
14+
* 只给出 id 列表和 count;元信息在 Controller 层组装 UserView 避免 Service 深度耦合
15+
* - 不对 follower_id / followee_id 做"用户是否存在"的前置校验:
16+
* user_accounts 是 SaToken 管理的,我们信任调用方传进来的 id;
17+
* 如果传了个不存在的 followee_id,无非就是一条脏记录,不影响正确性
18+
*/
19+
@Service
20+
public class FollowService {
21+
22+
private final JdbcTemplate jdbc;
23+
24+
public FollowService(JdbcTemplate jdbc) {
25+
this.jdbc = jdbc;
26+
}
27+
28+
/**
29+
* 关注:幂等。同一对 (follower, followee) 已存在时不报错。
30+
* 用 ON CONFLICT DO NOTHING 让数据库保证原子性,避免并发竞争。
31+
*/
32+
public void follow(long followerId, long followeeId) {
33+
if (followerId == followeeId) {
34+
throw new IllegalArgumentException("不能关注自己");
35+
}
36+
try {
37+
jdbc.update(
38+
"""
39+
INSERT INTO user_follows (follower_id, followee_id, created_at)
40+
VALUES (?, ?, NOW())
41+
ON CONFLICT (follower_id, followee_id) DO NOTHING
42+
""",
43+
followerId, followeeId
44+
);
45+
} catch (DuplicateKeyException ignored) {
46+
// 多数据库兼容:Postgres 走 ON CONFLICT 不会进这里,
47+
// 但其他 driver 可能抛 DuplicateKey,一起吞掉
48+
}
49+
}
50+
51+
/**
52+
* 取消关注:幂等。记录不存在也 return 0,不报错。
53+
*/
54+
public void unfollow(long followerId, long followeeId) {
55+
jdbc.update(
56+
"DELETE FROM user_follows WHERE follower_id = ? AND followee_id = ?",
57+
followerId, followeeId
58+
);
59+
}
60+
61+
/**
62+
* 判断 follower 是否关注了 followee。
63+
*/
64+
public boolean isFollowing(long followerId, long followeeId) {
65+
Integer cnt = jdbc.queryForObject(
66+
"SELECT COUNT(*) FROM user_follows WHERE follower_id = ? AND followee_id = ?",
67+
Integer.class,
68+
followerId, followeeId
69+
);
70+
return cnt != null && cnt > 0;
71+
}
72+
73+
/**
74+
* 某用户的粉丝数(被多少人关注)。
75+
*/
76+
public long countFollowers(long userId) {
77+
Long cnt = jdbc.queryForObject(
78+
"SELECT COUNT(*) FROM user_follows WHERE followee_id = ?",
79+
Long.class,
80+
userId
81+
);
82+
return cnt != null ? cnt : 0;
83+
}
84+
85+
/**
86+
* 某用户关注了多少人。
87+
*/
88+
public long countFollowing(long userId) {
89+
Long cnt = jdbc.queryForObject(
90+
"SELECT COUNT(*) FROM user_follows WHERE follower_id = ?",
91+
Long.class,
92+
userId
93+
);
94+
return cnt != null ? cnt : 0;
95+
}
96+
97+
/**
98+
* 某用户的粉丝 id 列表,按关注时间倒序(最新关注者在前)。
99+
*/
100+
public List<Long> listFollowerIds(long userId, int limit, int offset) {
101+
return jdbc.queryForList(
102+
"""
103+
SELECT follower_id FROM user_follows
104+
WHERE followee_id = ?
105+
ORDER BY created_at DESC
106+
LIMIT ? OFFSET ?
107+
""",
108+
Long.class,
109+
userId, Math.min(Math.max(limit, 1), 100), Math.max(offset, 0)
110+
);
111+
}
112+
113+
/**
114+
* 某用户关注的人 id 列表,按关注时间倒序(最近关注的在前)。
115+
*/
116+
public List<Long> listFollowingIds(long userId, int limit, int offset) {
117+
return jdbc.queryForList(
118+
"""
119+
SELECT followee_id FROM user_follows
120+
WHERE follower_id = ?
121+
ORDER BY created_at DESC
122+
LIMIT ? OFFSET ?
123+
""",
124+
Long.class,
125+
userId, Math.min(Math.max(limit, 1), 100), Math.max(offset, 0)
126+
);
127+
}
128+
}

0 commit comments

Comments
 (0)