Skip to content

Commit 6d3b50f

Browse files
committed
feat(community): 机器人桥接渠道 + 审核摘要 API
新增两个 internal 接口(走 X-Internal-Key header 鉴权,不要求 sa-token 登录态), 为 Discord ChatBot 等机器人渠道提供 Discord → 主站 /share 的统一入口: - POST /api/community/links/internal 提交链接,固定挂 discord-bridge 系统账号 - GET /api/community/links/internal/{id} 按 id 查链接,Bot 用于轮询 async 富化结果 - GET /api/community/links/internal/summary 审核摘要,供每日 digest 推送 关键设计: - 所有机器人提交一律走后端既有 SharedLinkEnrichmentWorker(OG+DeepSeek+白名单), 与网站用户手动提交完全同一套审核管线,不旁路 - 真实提交人名放 recommendation(\"来自 Discord @xxx\"),避免给每个 Discord 用户建 user_accounts 行 - submitInternal 跳过 24h 限频(机器人整群刷会被卡死) - seed 一个不可登录的 discord-bridge 系统账号(password_hash 非法占位符) 后续:前端 /share 页面展示时显示 recommendation 中的真实来源。 端口:Bot 走 127.0.0.1:8080 直连,不经 Caddy,X-Internal-Key 二道锁防其它容器/本机 用户误访问。internal.api-key 在 application.properties 留空占位,未配置时接口返回 503。
1 parent c3877d0 commit 6d3b50f

9 files changed

Lines changed: 317 additions & 1 deletion

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
@@ -41,6 +41,10 @@ public void addInterceptors(InterceptorRegistry registry) {
4141
// POST 提交 / 举报 / GET /mine 走方法级 @SaCheckLogin 校验。
4242
// /api/admin/community/** 不放行,走 @SaCheckRole("admin") 校验。
4343
.notMatch("/api/community/links")
44+
// 机器人桥接渠道(Discord ChatBot 等):走 X-Internal-Key header 认证,
45+
// 不要求 sa-token 登录态。Controller 自己校验密钥。
46+
// 用 /** 覆盖子路径(/internal 提交 + /internal/summary 查询)。
47+
.notMatch("/api/community/links/internal", "/api/community/links/internal/**")
4448
.notMatch("/api/chat/sessions/save") // AI 对话持久化(匿名 / 登录都写,登录时自动关联 userId)
4549
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
4650
})).addPathPatterns("/**");
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package com.involutionhell.backend.community.controller;
2+
3+
import com.involutionhell.backend.common.api.ApiResponse;
4+
import com.involutionhell.backend.community.dto.AdminSummary;
5+
import com.involutionhell.backend.community.dto.InternalShareRequest;
6+
import com.involutionhell.backend.community.dto.SharedLinkView;
7+
import com.involutionhell.backend.community.model.SharedLink;
8+
import com.involutionhell.backend.community.service.SharedLinkService;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.dao.DuplicateKeyException;
13+
import org.springframework.http.HttpStatus;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.web.bind.annotation.GetMapping;
16+
import org.springframework.web.bind.annotation.PathVariable;
17+
import org.springframework.web.bind.annotation.PostMapping;
18+
import org.springframework.web.bind.annotation.RequestBody;
19+
import org.springframework.web.bind.annotation.RequestHeader;
20+
import org.springframework.web.bind.annotation.RequestMapping;
21+
import org.springframework.web.bind.annotation.RequestParam;
22+
import org.springframework.web.bind.annotation.RestController;
23+
24+
/**
25+
* 机器人桥接渠道的提交接口。
26+
*
27+
* 设计目标:
28+
* - 部署在 127.0.0.1:8080,不走 Caddy(Caddyfile 不代理 /internal 路径)
29+
* - 仅接受带正确 X-Internal-Key 的请求,密钥通过 env INTERNAL_API_KEY 注入
30+
* - 用 SharedLinkService.submitInternal,跳过 24h 限频,固定挂到 discord-bridge 系统账号
31+
*
32+
* 路径选 /api/community/links/internal 而非独立 /internal/... 是为了:
33+
* - 沿用已有 SharedLinkController 的业务命名,读代码更顺手
34+
* - SaTokenConfigure 放行这条 path(无登录态)
35+
*/
36+
@RestController
37+
@RequestMapping("/api/community/links/internal")
38+
public class SharedLinkInternalController {
39+
40+
private static final Logger log = LoggerFactory.getLogger(SharedLinkInternalController.class);
41+
42+
private final SharedLinkService service;
43+
private final String expectedKey;
44+
45+
public SharedLinkInternalController(
46+
SharedLinkService service,
47+
@Value("${internal.api-key:}") String expectedKey) {
48+
this.service = service;
49+
this.expectedKey = expectedKey;
50+
}
51+
52+
@PostMapping
53+
public ResponseEntity<ApiResponse<SharedLinkView>> submit(
54+
@RequestHeader(value = "X-Internal-Key", required = false) String providedKey,
55+
@RequestBody InternalShareRequest req) {
56+
57+
// 未配置 key 时拒绝所有请求(防止开发环境忘设 key 就上线导致接口裸奔)
58+
if (expectedKey == null || expectedKey.isBlank()) {
59+
log.error("internal.api-key 未配置,拒绝请求");
60+
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
61+
.body(new ApiResponse<>(false, "internal api not configured", null));
62+
}
63+
if (providedKey == null || !expectedKey.equals(providedKey)) {
64+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
65+
.body(new ApiResponse<>(false, "invalid internal key", null));
66+
}
67+
68+
if (req == null || req.url() == null || req.url().trim().isEmpty()) {
69+
return ResponseEntity.badRequest()
70+
.body(new ApiResponse<>(false, "url is required", null));
71+
}
72+
73+
try {
74+
SharedLink saved = service.submitInternal(
75+
req.submitterLabel(), req.url(), req.recommendation());
76+
return ResponseEntity.ok(ApiResponse.ok(SharedLinkView.from(saved)));
77+
} catch (IllegalArgumentException e) {
78+
return ResponseEntity.badRequest()
79+
.body(new ApiResponse<>(false, e.getMessage(), null));
80+
} catch (DuplicateKeyException e) {
81+
return ResponseEntity.status(HttpStatus.CONFLICT)
82+
.body(new ApiResponse<>(false, "url already submitted", null));
83+
} catch (IllegalStateException e) {
84+
// discord-bridge 账号不存在等运行时条件错误
85+
log.error("internal submit 运行时错误: {}", e.getMessage());
86+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
87+
.body(new ApiResponse<>(false, e.getMessage(), null));
88+
}
89+
}
90+
91+
/**
92+
* 审核队列摘要,给 ChatBot 每日 digest 推送用。
93+
* 同样走 X-Internal-Key 鉴权,不要求 sa-token 登录态。
94+
*
95+
* @param providedKey header: X-Internal-Key
96+
* @param sampleLimit 查询参数:要带几条 PENDING_MANUAL 示例链接(默认 5,最多 20)
97+
*/
98+
@GetMapping("/summary")
99+
public ResponseEntity<ApiResponse<AdminSummary>> summary(
100+
@RequestHeader(value = "X-Internal-Key", required = false) String providedKey,
101+
@RequestParam(value = "sampleLimit", defaultValue = "5") int sampleLimit) {
102+
103+
if (expectedKey == null || expectedKey.isBlank()) {
104+
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
105+
.body(new ApiResponse<>(false, "internal api not configured", null));
106+
}
107+
if (providedKey == null || !expectedKey.equals(providedKey)) {
108+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
109+
.body(new ApiResponse<>(false, "invalid internal key", null));
110+
}
111+
112+
int safeSample = Math.max(0, Math.min(sampleLimit, 20));
113+
AdminSummary s = service.buildAdminSummary(safeSample);
114+
return ResponseEntity.ok(ApiResponse.ok(s));
115+
}
116+
117+
/**
118+
* 按 id 拉取单条分享,供 Bot 轮询异步 enrichment 的最终状态。
119+
* SharedLinkView 已经屏蔽了敏感字段(不含 submitter_id 明文等),可安全给 Bot 回显。
120+
*/
121+
@GetMapping("/{id}")
122+
public ResponseEntity<ApiResponse<SharedLinkView>> getById(
123+
@RequestHeader(value = "X-Internal-Key", required = false) String providedKey,
124+
@PathVariable Long id) {
125+
126+
if (expectedKey == null || expectedKey.isBlank()) {
127+
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
128+
.body(new ApiResponse<>(false, "internal api not configured", null));
129+
}
130+
if (providedKey == null || !expectedKey.equals(providedKey)) {
131+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
132+
.body(new ApiResponse<>(false, "invalid internal key", null));
133+
}
134+
135+
return service.findById(id)
136+
.map(link -> ResponseEntity.ok(ApiResponse.ok(SharedLinkView.from(link))))
137+
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
138+
.body(new ApiResponse<>(false, "not found", null)));
139+
}
140+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.involutionhell.backend.community.dto;
2+
3+
import java.util.List;
4+
5+
/**
6+
* 审核摘要(/api/community/links/internal/summary)。
7+
*
8+
* 给 ChatBot 每日 digest 用:
9+
* - pendingManual / flagged:当前待审队列累计量
10+
* - approvedLast24h:过去 24 小时自动通过的量(看渠道吞吐)
11+
* - pendingSamples:PENDING_MANUAL 队列里最早的 N 条,帮管理员快速判断该不该去后台
12+
*
13+
* 这个 DTO 不含密钥/用户名等敏感字段,纯展示用。
14+
*/
15+
public record AdminSummary(
16+
int pendingManual,
17+
int flagged,
18+
int approvedLast24h,
19+
List<Sample> pendingSamples
20+
) {
21+
public record Sample(Long id, String host, String url) {}
22+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.involutionhell.backend.community.dto;
2+
3+
/**
4+
* 机器人桥接渠道的提交请求体(/api/community/links/internal)。
5+
*
6+
* submitterLabel: 原始分享人名(Discord 昵称),由桥接服务填写;展示在 recommendation 里
7+
* url: 原始 URL(规范化交由后端 UrlNormalizer)
8+
* recommendation: 可选的推荐语(机器人自动渠道通常为空)
9+
*/
10+
public record InternalShareRequest(String url, String recommendation, String submitterLabel) {
11+
}

src/main/java/com/involutionhell/backend/community/repository/JdbcSharedLinkRepository.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,22 @@ public List<SharedLink> findPendingForAdmin() {
129129
SharedLinkStatus.PENDING_MANUAL, SharedLinkStatus.FLAGGED);
130130
}
131131

132+
@Override
133+
public int countByStatus(String status) {
134+
Integer c = jdbc.queryForObject(
135+
"SELECT COUNT(*) FROM shared_links WHERE status = ?",
136+
Integer.class, status);
137+
return c == null ? 0 : c;
138+
}
139+
140+
@Override
141+
public int countByStatusSince(String status, Instant since) {
142+
Integer c = jdbc.queryForObject(
143+
"SELECT COUNT(*) FROM shared_links WHERE status = ? AND created_at >= ?",
144+
Integer.class, status, Timestamp.from(since));
145+
return c == null ? 0 : c;
146+
}
147+
132148
@Override
133149
public void updateEnrichment(Long id,
134150
String ogTitle, String ogDescription,

src/main/java/com/involutionhell/backend/community/repository/SharedLinkRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ public interface SharedLinkRepository {
2727
/** 管理员待审:PENDING_MANUAL + FLAGGED。 */
2828
List<SharedLink> findPendingForAdmin();
2929

30+
/** 按 status 计数。用于审核摘要接口。 */
31+
int countByStatus(String status);
32+
33+
/** 给定 status 在 since 之后的条数(审核摘要里统计"今日新增 APPROVED"等)。 */
34+
int countByStatusSince(String status, java.time.Instant since);
35+
3036
/** 更新 OG + 分类 + flags + status(异步 worker 跑完调)。 */
3137
void updateEnrichment(Long id,
3238
String ogTitle, String ogDescription,

src/main/java/com/involutionhell/backend/community/service/SharedLinkService.java

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.involutionhell.backend.community.service;
22

3+
import com.involutionhell.backend.community.dto.AdminSummary;
34
import com.involutionhell.backend.community.model.LinkReport;
45
import com.involutionhell.backend.community.model.SharedLink;
56
import com.involutionhell.backend.community.model.SharedLinkStatus;
67
import com.involutionhell.backend.community.repository.LinkReportRepository;
78
import com.involutionhell.backend.community.repository.SharedLinkRepository;
89
import com.involutionhell.backend.community.util.UrlNormalizer;
10+
import com.involutionhell.backend.usercenter.model.UserAccount;
11+
import com.involutionhell.backend.usercenter.repository.UserAccountRepository;
912
import org.slf4j.Logger;
1013
import org.slf4j.LoggerFactory;
1114
import org.springframework.beans.factory.annotation.Autowired;
@@ -40,18 +43,25 @@ public class SharedLinkService {
4043
/** 3 条独立举报自动下架。 */
4144
static final int REPORT_THRESHOLD = 3;
4245

46+
/** 桥接账号 username;见 schema.sql 里的 seed。submitInternal 走这个账号下单。 */
47+
private static final String BRIDGE_USERNAME = "discord-bridge";
48+
4349
private final SharedLinkRepository linkRepo;
4450
private final LinkReportRepository reportRepo;
51+
private final UserAccountRepository userRepo;
4552

4653
/**
4754
* 用 @Lazy 打破循环依赖:Worker → Service → Worker。
4855
* Worker 在 submit() 成功后被调用,@Lazy 确保 Spring 容器初始化顺序无冲突。
4956
*/
5057
private SharedLinkEnrichmentWorker enrichmentWorker;
5158

52-
public SharedLinkService(SharedLinkRepository linkRepo, LinkReportRepository reportRepo) {
59+
public SharedLinkService(SharedLinkRepository linkRepo,
60+
LinkReportRepository reportRepo,
61+
UserAccountRepository userRepo) {
5362
this.linkRepo = linkRepo;
5463
this.reportRepo = reportRepo;
64+
this.userRepo = userRepo;
5565
}
5666

5767
@Autowired
@@ -115,10 +125,105 @@ public SharedLink submit(Long submitterId, String rawUrl, String recommendation)
115125
return saved;
116126
}
117127

128+
/**
129+
* 内部桥接路径:给 Discord Bot / 未来其它机器人渠道用。
130+
*
131+
* 与 submit() 的差异:
132+
* - 不做 24h 限频(机器人是整群一把提,限频会把合法流量卡死)
133+
* - 不关心前端登录态:submitter 固定取 seed 的 discord-bridge 账号
134+
* - recommendation 前缀自动打「来自 Discord @{label}」,前端展示能看到真实分享人
135+
*
136+
* 与 submit() 一致的:
137+
* - 同样走 UrlNormalizer、去重、OG + DeepSeek 异步富化
138+
* - 同样的状态机(PENDING → APPROVED/PENDING_MANUAL/FLAGGED)
139+
* - 同样的 DuplicateKeyException 语义
140+
*/
141+
public SharedLink submitInternal(String submitterLabel, String rawUrl, String recommendation) {
142+
UrlNormalizer.Normalized norm = UrlNormalizer.normalize(rawUrl);
143+
144+
Long bridgeId = userRepo.findByUsername(BRIDGE_USERNAME)
145+
.map(UserAccount::id)
146+
.orElseThrow(() -> new IllegalStateException(
147+
"discord-bridge 账号不存在,检查 schema.sql 是否已执行 seed"));
148+
149+
String urlHash = UrlNormalizer.sha256Hex(norm.canonicalUrl());
150+
151+
// 同样的去重:同一 URL 全站只留一条
152+
Optional<SharedLink> existing = linkRepo.findByUrlHash(urlHash);
153+
if (existing.isPresent()) {
154+
throw new DuplicateKeyException(
155+
"url already submitted: id=" + existing.get().id());
156+
}
157+
158+
String combinedRec = buildBridgeRecommendation(submitterLabel, recommendation);
159+
160+
SharedLink draft = new SharedLink(
161+
null,
162+
bridgeId,
163+
norm.canonicalUrl(),
164+
urlHash,
165+
norm.host(),
166+
combinedRec,
167+
null, null, null, null,
168+
null,
169+
null,
170+
new HashMap<>(),
171+
SharedLinkStatus.PENDING,
172+
0,
173+
null, null,
174+
null, null
175+
);
176+
SharedLink saved = linkRepo.insert(draft);
177+
log.info("shared-link submitted via bridge: id={} label={} host={}",
178+
saved.id(), submitterLabel, saved.host());
179+
180+
if (enrichmentWorker != null) {
181+
enrichmentWorker.enrich(saved.id());
182+
}
183+
return saved;
184+
}
185+
186+
/**
187+
* 把原 recommendation 前面拼上「来自 Discord @label:」。
188+
* label 为空时降级为「来自 Discord:」;原 rec 为空时只留前缀。
189+
*/
190+
private static String buildBridgeRecommendation(String submitterLabel, String original) {
191+
String prefix = (submitterLabel == null || submitterLabel.isBlank())
192+
? "来自 Discord"
193+
: "来自 Discord @" + submitterLabel;
194+
if (original == null || original.isBlank()) {
195+
return prefix;
196+
}
197+
return prefix + ":" + original;
198+
}
199+
118200
public Optional<SharedLink> findById(Long id) {
119201
return linkRepo.findById(id);
120202
}
121203

204+
/**
205+
* 审核摘要:给 ChatBot 每日 digest 用。
206+
*
207+
* @param sampleLimit PENDING_MANUAL 采样条数(展示最早 N 条),传 <=0 时不采样
208+
*/
209+
public AdminSummary buildAdminSummary(int sampleLimit) {
210+
int pendingManual = linkRepo.countByStatus(SharedLinkStatus.PENDING_MANUAL);
211+
int flagged = linkRepo.countByStatus(SharedLinkStatus.FLAGGED);
212+
int approvedLast24h = linkRepo.countByStatusSince(
213+
SharedLinkStatus.APPROVED,
214+
Instant.now().minus(1, ChronoUnit.DAYS));
215+
216+
List<AdminSummary.Sample> samples = List.of();
217+
if (sampleLimit > 0 && pendingManual > 0) {
218+
samples = linkRepo.findPendingForAdmin().stream()
219+
.filter(l -> SharedLinkStatus.PENDING_MANUAL.equals(l.status()))
220+
.limit(sampleLimit)
221+
.map(l -> new AdminSummary.Sample(l.id(), l.host(), l.url()))
222+
.toList();
223+
}
224+
return new AdminSummary(pendingManual, flagged, approvedLast24h, samples);
225+
}
226+
122227
public List<SharedLink> listApproved(String category, int limit, int offset) {
123228
return linkRepo.findApproved(category, limit, offset);
124229
}

src/main/resources/application.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ management.endpoints.web.exposure.include=${MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_IN
4242
# when-authorized:未认证请求只看到 UP/DOWN,不暴露数据库、磁盘等细节
4343
management.endpoint.health.show-details=${MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS:when-authorized}
4444

45+
# 内部桥接 API 共享密钥(Discord ChatBot 等机器人进程调 /api/community/links/internal 时用)
46+
# 留空时接口整体返回 503,强制部署者显式配置
47+
internal.api-key=${INTERNAL_API_KEY:}
48+
4549
# AI 配置 (还原为您之前的结构,取消我擅自建议的默认值)
4650
openai.api-key=${OPENAI_API_KEY:}
4751
openai.api-url=${OPENAI_API_URL:https://api.openai.com/v1}

src/main/resources/schema.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ VALUES ('admin', 'ad89b64d66caa8e30e5d5ce4a9763f4ecc205814c412175f3e2c50027471
2727
('auditor', 'ccabaaba054fb98905b5b9ee47174f57cb6088e04b1526f08b872dc06eaa6bb9', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read')
2828
ON CONFLICT (username) DO NOTHING;
2929

30+
-- Discord 桥接系统账号(不可登录)。
31+
-- password_hash 故意塞无法匹配的占位符('!' 不是 sha256 的合法十六进制),确保即使
32+
-- 有人知道 username 也无法走 /auth/login 通过口令登录;该账号仅被 SharedLinkService.submitInternal
33+
-- 取 submitter_id 用,真实提交人名放在 recommendation 里("来自 Discord @xxx")。
34+
INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions)
35+
VALUES ('discord-bridge', '!', 'Discord Bridge', TRUE, 'bridge', '')
36+
ON CONFLICT (username) DO NOTHING;
37+
3038
-- 站点超管(superadmin)升级:
3139
-- 原先尝试按 GitHub handle 做 seed,但 AuthService.loginByGithub 创建的 username 是
3240
-- "github_{githubId}" 格式,seed 成其他 username 会插一批永远没人用的死账号。

0 commit comments

Comments
 (0)