Skip to content

Commit 8625f17

Browse files
authored
feat(community): 社区分享链接墙 M1-M9(UGC 链接聚合 + AI 异步审核) (#16)
* feat(community): M1 shared-links module skeleton 社区分享链接墙后端骨架。与 Fumadocs 完全隔离的 UGC 链路。 - schema.sql 新增 shared_links + link_reports 两表 - community/ 模块 16 个文件:model / util / dto / repository / service / controller - UrlNormalizer 严格根域匹配,防 weixin.qq.com.evil.com 钓鱼 - DomainWhitelist 精确匹配 7 个白名单域 - SharedLinkService 同步路径:提交 + 限频(5/日滚动) + 举报自动降级(3票) - 异步 OG 抓取 + DeepSeek 分类接口已留(enrich 方法),M2-M4 填充 - SaTokenConfigure 放行 GET /api/community/links 公开读 - UrlNormalizerTests 8/8 通过,覆盖钓鱼防御 + scheme 白名单 + 大小写 + fragment 设计见 ih-wiki/Community-Shared-Links.md * feat(community): M2+M3+M4 OG fetch + DeepSeek classification + async worker M2 OgFetchService: 用 JDK HttpClient + Jsoup 抓 og:title/description/image/site_name, 失败降级(不抛异常),记录 errorMessage 供排障。User-Agent=InvolutionHellBot/1.0,超时 10s。 M3 ClassificationService: 独立调 DeepSeek /chat/completions(复用 openai.api-url/key), temperature=0 强约束只返回 JSON {"category","nsfw","ad","flame"},失败降级为 other+全 false。 M4 SharedLinkEnrichmentWorker (@async): submit() 后触发异步富化, 白名单+安全→APPROVED / 非白名单+安全→PENDING_MANUAL / 任一 flag→FLAGGED, 双层 try/catch 确保 status 一定从 PENDING 推进到终态。 BackendApplication 新增 @EnableAsync。 SharedLinkService 用 @lazy setter 注入 Worker(打破循环依赖)。 pom.xml 新增 jsoup 1.18.3。 单测 23 个,全部通过(OgFetch 6 + Classification 9 + Worker 8)。 * feat(community): M7+M9 admin moderation + archive probe job - SharedLinkAdminController: /api/admin/community/pending /approve /reject, 走 @SaCheckRole("admin") - SharedLinkArchiveJob: @scheduled weekly HEAD 探活, 连续 2 次失败 → ARCHIVED - schema.sql 加 probe_fail_count / probe_last_at 两列, 兼容老库用 ALTER IF NOT EXISTS - Repository 扩: findApprovedForProbe / incrementProbeFail / resetProbeFail / touchProbeLastAt - BackendApplication 加 @EnableScheduling - 所有测试绿: 31/31(UrlNormalizer + OgFetch + Classification + EnrichmentWorker) 设计见 ih-wiki/Community-Shared-Links.md §4.9 * fix(community): address CR — admin reject reason / jsonb H2 compat / status API 分拆 Copilot CR 反馈: 1. updateStatus(id, status, reason) 在非 ARCHIVED 时静默丢弃 reason —— 导致 admin reject 的 reason 永远落不到库。 2. insert / updateEnrichment 用 `?::jsonb` 硬编码 PG 方言,与 H2 兼容性注释冲突。 改动: - Repository API 拆两半: - transitionStatus(id, status, adminNote): 通用状态迁移(approve/reject/ 举报降级等),adminNote 落到新增的 admin_note 列 - archive(id, reason): 失效归档,写 archived_at + archived_reason - JDBC: flags 改用 setObject(Types.OTHER) + 字符串绑定,PG JSONB / H2 VARCHAR 都吃;与 JdbcEventRepository.speakers 保持一致 - schema: 新增 admin_note TEXT 列(CREATE TABLE 和已有库 ALTER 都加) - 所有调用点同步更新: SharedLinkService.report / SharedLinkAdminController approve+reject / SharedLinkArchiveJob Tests 31/31 仍绿。
1 parent 29f275b commit 8625f17

29 files changed

Lines changed: 2802 additions & 0 deletions

pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@
207207
<scope>test</scope>
208208
</dependency>
209209

210+
<!-- Jsoup:解析 HTML 抓取 Open Graph meta 标签 -->
211+
<dependency>
212+
<groupId>org.jsoup</groupId>
213+
<artifactId>jsoup</artifactId>
214+
<version>1.18.3</version>
215+
</dependency>
216+
210217
</dependencies>
211218

212219
<dependencyManagement>

src/main/java/com/involutionhell/backend/BackendApplication.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.boot.context.properties.EnableConfigurationProperties;
66
import org.springframework.cache.annotation.EnableCaching;
7+
import org.springframework.scheduling.annotation.EnableAsync;
8+
import org.springframework.scheduling.annotation.EnableScheduling;
79
import com.involutionhell.backend.analytics.config.Ga4Properties;
810

911
@SpringBootApplication
1012
@EnableCaching
13+
@EnableAsync // M4:启用 @Async,用于社区链接富化 worker 的异步执行
14+
@EnableScheduling // M9:启用 @Scheduled,用于社区分享链接失效探活定时任务
1115
@EnableConfigurationProperties(Ga4Properties.class)
1216
public class BackendApplication {
1317

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ public void addInterceptors(InterceptorRegistry registry) {
3737
// /api/events/{id}/interest 感兴趣接口需要登录,由 @SaCheckLogin 在方法级别兜底。
3838
// /api/admin/events/** 不放行,走 @SaCheckRole("admin") 校验。
3939
.notMatch("/api/events", "/api/events/*")
40+
// Community 公开读:GET /api/community/links 列表匿名可访问。
41+
// POST 提交 / 举报 / GET /mine 走方法级 @SaCheckLogin 校验。
42+
// /api/admin/community/** 不放行,走 @SaCheckRole("admin") 校验。
43+
.notMatch("/api/community/links")
4044
.notMatch("/api/chat/sessions/save") // AI 对话持久化(匿名 / 登录都写,登录时自动关联 userId)
4145
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
4246
})).addPathPatterns("/**");
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.involutionhell.backend.community.controller;
2+
3+
import cn.dev33.satoken.annotation.SaCheckRole;
4+
import com.involutionhell.backend.common.api.ApiResponse;
5+
import com.involutionhell.backend.community.dto.SharedLinkView;
6+
import com.involutionhell.backend.community.model.SharedLink;
7+
import com.involutionhell.backend.community.model.SharedLinkStatus;
8+
import com.involutionhell.backend.community.repository.SharedLinkRepository;
9+
import com.involutionhell.backend.community.service.SharedLinkService;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.web.bind.annotation.GetMapping;
15+
import org.springframework.web.bind.annotation.PathVariable;
16+
import org.springframework.web.bind.annotation.PostMapping;
17+
import org.springframework.web.bind.annotation.RequestBody;
18+
import org.springframework.web.bind.annotation.RequestMapping;
19+
import org.springframework.web.bind.annotation.RestController;
20+
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.Optional;
24+
25+
/**
26+
* 社区分享链接 admin 接口。所有端点均要求 admin 角色。
27+
*
28+
* 设计:
29+
* - GET /api/admin/community/pending 待审列表(PENDING_MANUAL + FLAGGED)
30+
* - POST /api/admin/community/{id}/approve 通过 → APPROVED
31+
* - POST /api/admin/community/{id}/reject 拒绝 → REJECTED,可附 reason
32+
*
33+
* 这个 Controller 直接依赖 Repository 做状态变更(通过/拒绝是单纯的 status 切换,
34+
* 不需要走 Service 的业务编排),和 EventAdminController 一样。
35+
*
36+
* 若将来需要审核日志 / 操作者 ID 绑定,再把状态变更逻辑挪到 Service 层。
37+
*/
38+
@RestController
39+
@RequestMapping("/api/admin/community")
40+
@SaCheckRole("admin")
41+
public class SharedLinkAdminController {
42+
43+
private static final Logger log = LoggerFactory.getLogger(SharedLinkAdminController.class);
44+
45+
private final SharedLinkService service;
46+
private final SharedLinkRepository linkRepo;
47+
48+
public SharedLinkAdminController(SharedLinkService service,
49+
SharedLinkRepository linkRepo) {
50+
this.service = service;
51+
this.linkRepo = linkRepo;
52+
}
53+
54+
@GetMapping("/pending")
55+
public ApiResponse<List<SharedLinkView>> listPending() {
56+
List<SharedLinkView> views = service.listPendingForAdmin()
57+
.stream().map(SharedLinkView::from).toList();
58+
return ApiResponse.ok(views);
59+
}
60+
61+
@PostMapping("/{id}/approve")
62+
public ResponseEntity<ApiResponse<SharedLinkView>> approve(@PathVariable Long id) {
63+
Optional<SharedLink> maybe = linkRepo.findById(id);
64+
if (maybe.isEmpty()) {
65+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
66+
.body(new ApiResponse<>(false, "link not found", null));
67+
}
68+
linkRepo.transitionStatus(id, SharedLinkStatus.APPROVED, null);
69+
log.info("admin approve shared-link id={}", id);
70+
return linkRepo.findById(id)
71+
.map(link -> ResponseEntity.ok(ApiResponse.ok(SharedLinkView.from(link))))
72+
.orElseGet(() -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
73+
.body(new ApiResponse<>(false, "link disappeared after update", null)));
74+
}
75+
76+
@PostMapping("/{id}/reject")
77+
public ResponseEntity<ApiResponse<SharedLinkView>> reject(
78+
@PathVariable Long id,
79+
@RequestBody(required = false) Map<String, String> body) {
80+
Optional<SharedLink> maybe = linkRepo.findById(id);
81+
if (maybe.isEmpty()) {
82+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
83+
.body(new ApiResponse<>(false, "link not found", null));
84+
}
85+
String reason = body != null ? body.getOrDefault("reason", null) : null;
86+
// reason 现在落到 admin_note 列(之前 updateStatus 在非 ARCHIVED 时会静默丢弃)
87+
linkRepo.transitionStatus(id, SharedLinkStatus.REJECTED, reason);
88+
log.info("admin reject shared-link id={} reason={}", id, reason);
89+
return linkRepo.findById(id)
90+
.map(link -> ResponseEntity.ok(ApiResponse.ok(SharedLinkView.from(link))))
91+
.orElseGet(() -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
92+
.body(new ApiResponse<>(false, "link disappeared after update", null)));
93+
}
94+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.involutionhell.backend.community.controller;
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.community.dto.SharedLinkRequest;
7+
import com.involutionhell.backend.community.dto.SharedLinkView;
8+
import com.involutionhell.backend.community.model.SharedLink;
9+
import com.involutionhell.backend.community.service.SharedLinkService;
10+
import org.springframework.dao.DuplicateKeyException;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.GetMapping;
14+
import org.springframework.web.bind.annotation.PathVariable;
15+
import org.springframework.web.bind.annotation.PostMapping;
16+
import org.springframework.web.bind.annotation.RequestBody;
17+
import org.springframework.web.bind.annotation.RequestMapping;
18+
import org.springframework.web.bind.annotation.RequestParam;
19+
import org.springframework.web.bind.annotation.RestController;
20+
21+
import java.util.HashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
25+
/**
26+
* 公开/登录接口:
27+
* - GET /api/community/links 公开列表(匿名可访问,仅 APPROVED)
28+
* - POST /api/community/links 提交链接(需登录)
29+
* - POST /api/community/links/{id}/report 举报(需登录)
30+
* - GET /api/community/links/mine 我提交的所有链接(需登录)
31+
*
32+
* 公开读放行在 SaTokenConfigure 里配 /api/community/links 与 /api/community/links/*
33+
* (对 POST 的写接口由方法级 @SaCheckLogin 兜底)。
34+
*/
35+
@RestController
36+
@RequestMapping("/api/community/links")
37+
public class SharedLinkController {
38+
39+
private final SharedLinkService service;
40+
41+
public SharedLinkController(SharedLinkService service) {
42+
this.service = service;
43+
}
44+
45+
@GetMapping
46+
public ApiResponse<List<SharedLinkView>> list(
47+
@RequestParam(required = false) String category,
48+
@RequestParam(defaultValue = "50") int limit,
49+
@RequestParam(defaultValue = "0") int offset) {
50+
int safeLimit = Math.min(Math.max(limit, 1), 100);
51+
int safeOffset = Math.max(offset, 0);
52+
List<SharedLinkView> views = service.listApproved(category, safeLimit, safeOffset)
53+
.stream().map(SharedLinkView::from).toList();
54+
return ApiResponse.ok(views);
55+
}
56+
57+
@PostMapping
58+
@SaCheckLogin
59+
public ResponseEntity<ApiResponse<SharedLinkView>> submit(@RequestBody SharedLinkRequest req) {
60+
if (req == null || req.url() == null || req.url().trim().isEmpty()) {
61+
return ResponseEntity.badRequest()
62+
.body(new ApiResponse<>(false, "url is required", null));
63+
}
64+
long uid = StpUtil.getLoginIdAsLong();
65+
try {
66+
SharedLink saved = service.submit(uid, req.url(), req.recommendation());
67+
return ResponseEntity.ok(ApiResponse.ok(SharedLinkView.from(saved)));
68+
} catch (IllegalArgumentException e) {
69+
return ResponseEntity.badRequest()
70+
.body(new ApiResponse<>(false, e.getMessage(), null));
71+
} catch (DuplicateKeyException e) {
72+
return ResponseEntity.status(HttpStatus.CONFLICT)
73+
.body(new ApiResponse<>(false, "url already submitted", null));
74+
} catch (SharedLinkService.RateLimitExceeded e) {
75+
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
76+
.body(new ApiResponse<>(false, e.getMessage(), null));
77+
}
78+
}
79+
80+
@PostMapping("/{id}/report")
81+
@SaCheckLogin
82+
public ApiResponse<Map<String, Object>> report(
83+
@PathVariable Long id,
84+
@RequestBody(required = false) Map<String, String> body) {
85+
long uid = StpUtil.getLoginIdAsLong();
86+
String reason = body != null ? body.getOrDefault("reason", null) : null;
87+
boolean demoted = service.report(id, uid, reason);
88+
Map<String, Object> res = new HashMap<>();
89+
res.put("demoted", demoted);
90+
return ApiResponse.ok(res);
91+
}
92+
93+
@GetMapping("/mine")
94+
@SaCheckLogin
95+
public ApiResponse<List<SharedLinkView>> mine() {
96+
long uid = StpUtil.getLoginIdAsLong();
97+
List<SharedLinkView> views = service.listBySubmitter(uid)
98+
.stream().map(SharedLinkView::from).toList();
99+
return ApiResponse.ok(views);
100+
}
101+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.involutionhell.backend.community.dto;
2+
3+
/**
4+
* 用户提交分享链接的请求体。
5+
* recommendation 可空(没有推荐语也允许提交,UGC 习惯)。
6+
*/
7+
public record SharedLinkRequest(String url, String recommendation) {
8+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.involutionhell.backend.community.dto;
2+
3+
import com.involutionhell.backend.community.model.SharedLink;
4+
5+
import java.time.Instant;
6+
7+
/**
8+
* 对外展示的链接视图。
9+
* - 不暴露 urlHash / flags(内部字段)
10+
* - status 暴露给"我提交的"页面;公开 /feed 只拿 APPROVED 时前端可不渲染
11+
*/
12+
public record SharedLinkView(
13+
Long id,
14+
Long submitterId,
15+
String url,
16+
String host,
17+
String recommendation,
18+
String ogTitle,
19+
String ogDescription,
20+
String ogCover,
21+
String ogSiteName,
22+
boolean ogFetchFailed,
23+
String category,
24+
String status,
25+
int reportCount,
26+
Instant archivedAt,
27+
Instant createdAt
28+
) {
29+
public static SharedLinkView from(SharedLink s) {
30+
return new SharedLinkView(
31+
s.id(),
32+
s.submitterId(),
33+
s.url(),
34+
s.host(),
35+
s.recommendation(),
36+
s.ogTitle(),
37+
s.ogDescription(),
38+
s.ogCover(),
39+
s.ogSiteName(),
40+
s.ogFetchError() != null && !s.ogFetchError().isEmpty(),
41+
s.category(),
42+
s.status(),
43+
s.reportCount(),
44+
s.archivedAt(),
45+
s.createdAt()
46+
);
47+
}
48+
}

0 commit comments

Comments
 (0)