Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@
* 机器人桥接渠道的提交接口。
*
* 设计目标:
* - 部署在 127.0.0.1:8080,不走 Caddy(Caddyfile 不代理 /internal 路径)
* - 仅接受带正确 X-Internal-Key 的请求,密钥通过 env INTERNAL_API_KEY 注入
* - 用 SharedLinkService.submitInternal,跳过 24h 限频,固定挂到 discord-bridge 系统账号
* - ChatBot 走 127.0.0.1:8080 直连;但 api.involutionhell.com 的 Caddy 兜底和前端
* /api/community/links/:path* rewrite 都会转发到这里,公网可达,唯一的门是下面的 key
* - 仅接受带正确 X-Internal-Key 的请求,密钥通过 env INTERNAL_API_KEY 注入。
* 持 key 者能指定 discordUserId,即决定链接挂在哪个已绑定 Discord 的账号名下
* - 用 SharedLinkService.submitInternal,跳过 24h 限频;分享人 Discord 已绑定站内账号时
* 记到本人名下,否则挂 discord-bridge 系统账号
*
* 路径选 /api/community/links/internal 而非独立 /internal/... 是为了:
* - 沿用已有 SharedLinkController 的业务命名,读代码更顺手
Expand Down Expand Up @@ -105,10 +108,17 @@ public ResponseEntity<ApiResponse<SharedLinkView>> submit(
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "url is required", null));
}
// Discord 用户 id 是 snowflake(纯数字,最长 20 位);不传或空串按没有处理
String discordUserId = req.discordUserId() == null || req.discordUserId().isBlank()
? null : req.discordUserId().trim();
if (discordUserId != null && !discordUserId.matches("\\d{1,20}")) {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "invalid discordUserId", null));
}

try {
SharedLink saved = service.submitInternal(
req.submitterLabel(), req.url(), req.recommendation());
req.submitterLabel(), req.url(), req.recommendation(), discordUserId);
return ResponseEntity.ok(ApiResponse.ok(SharedLinkView.from(saved)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* submitterLabel: 原始分享人名(Discord 昵称),由桥接服务填写;展示在 recommendation 里
* url: 原始 URL(规范化交由后端 UrlNormalizer)
* recommendation: 可选的推荐语(机器人自动渠道通常为空)
* discordUserId: 可选,分享人的 Discord 用户 id(纯数字)。已绑定到站内账号时链接记到本人名下
*/
public record InternalShareRequest(String url, String recommendation, String submitterLabel) {
public record InternalShareRequest(String url, String recommendation, String submitterLabel,
String discordUserId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* - status:见 SharedLinkStatus
* - ogFetchError:OG 抓取失败时记录原因,前端降级展示仍保留卡片
* - archivedAt / archivedReason:原文失效(HEAD 探活连续 2 次失败)后写入
* - discordUserId:机器人渠道的分享人 Discord id,网页提交为 null
*/
public record SharedLink(
Long id,
Expand All @@ -37,6 +38,7 @@ public record SharedLink(
Instant archivedAt,
String archivedReason,
Instant createdAt,
Instant updatedAt
Instant updatedAt,
String discordUserId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ public JdbcSharedLinkRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
toInstant(rs.getTimestamp("archived_at")),
rs.getString("archived_reason"),
toInstant(rs.getTimestamp("created_at")),
toInstant(rs.getTimestamp("updated_at"))
toInstant(rs.getTimestamp("updated_at")),
rs.getString("discord_user_id")
);

@Override
Expand All @@ -68,8 +69,8 @@ public SharedLink insert(SharedLink draft) {
// flags 列 Postgres 侧是 JSONB,H2 测试侧是 VARCHAR。用 setObject(Types.OTHER) + JSON 字符串
// 的方式让两种方言都能吃进去(与 JdbcEventRepository.speakers 的做法保持一致)。
String sql = "INSERT INTO shared_links "
+ "(submitter_id, url, url_hash, host, recommendation, status, flags) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)";
+ "(submitter_id, url, url_hash, host, recommendation, status, flags, discord_user_id) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
jdbc.update(conn -> {
PreparedStatement ps = conn.prepareStatement(sql, new String[]{"id"});
ps.setLong(1, draft.submitterId());
Expand All @@ -80,6 +81,7 @@ public SharedLink insert(SharedLink draft) {
else ps.setString(5, draft.recommendation());
ps.setString(6, draft.status() != null ? draft.status() : SharedLinkStatus.PENDING);
ps.setObject(7, serializeFlags(draft.flags()), Types.OTHER);
ps.setString(8, draft.discordUserId());
return ps;
}, kh);
Long id = kh.getKey() != null ? kh.getKey().longValue() : null;
Expand Down Expand Up @@ -203,8 +205,10 @@ public int incrementReportCount(Long id) {

@Override
public int countBySubmitterSince(Long submitterId, Instant since) {
// 只数网页提交:机器人渠道本身不限频,已绑定 Discord 的人在群里分享不该占网页配额
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM shared_links WHERE submitter_id = ? AND created_at >= ?",
"SELECT COUNT(*) FROM shared_links WHERE submitter_id = ? AND created_at >= ? "
+ "AND discord_user_id IS NULL",
Integer.class, submitterId, Timestamp.from(since));
return n != null ? n : 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ void updateEnrichment(Long id,
/** +1 report_count 并返回新值。3 及以上由 Service 层决定是否转 PENDING_MANUAL。 */
int incrementReportCount(Long id);

/** 限频判定:某用户在 since 之后提交了几条。 */
/** 限频判定:某用户在 since 之后从网页提交了几条(不含机器人渠道)。 */
int countBySubmitterSince(Long submitterId, java.time.Instant since);

/** M9 失效探活:拉所有 APPROVED 的 (id, url),用于 HEAD 探活。只拿需要的两列。 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.involutionhell.backend.community.util.UrlNormalizer;
import com.involutionhell.backend.usercenter.model.UserAccount;
import com.involutionhell.backend.usercenter.service.UserCenterService;
import com.involutionhell.backend.usercenter.service.UserIdentityService;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -39,7 +40,7 @@ public class SharedLinkService {

private static final Logger log = LoggerFactory.getLogger(SharedLinkService.class);

/** 每用户每 24h 最多提交 N 条。滚动时间窗,不是自然日。 */
/** 每用户每 24h 最多从网页提交 N 条(机器人渠道不计)。滚动时间窗,不是自然日。 */
static final int DAILY_SUBMIT_LIMIT = 5;

/** 3 条独立举报自动下架。 */
Expand All @@ -55,6 +56,7 @@ public class SharedLinkService {
* community 模块不应该直接摸 usercenter 的仓储层,跨模块访问只经过 service。
*/
private final UserCenterService userCenterService;
private final UserIdentityService userIdentityService;

/**
* 缓存 discord-bridge 账号 id,避免每次 submitInternal 都查一次库。
Expand All @@ -74,10 +76,12 @@ public class SharedLinkService {

public SharedLinkService(SharedLinkRepository linkRepo,
LinkReportRepository reportRepo,
UserCenterService userCenterService) {
UserCenterService userCenterService,
UserIdentityService userIdentityService) {
this.linkRepo = linkRepo;
this.reportRepo = reportRepo;
this.userCenterService = userCenterService;
this.userIdentityService = userIdentityService;
}

@Autowired
Expand Down Expand Up @@ -175,7 +179,8 @@ public SharedLink submit(Long submitterId, String rawUrl, String recommendation)
SharedLinkStatus.PENDING,
0, // report_count
null, null, // archived
null, null // created/updated
null, null, // created/updated
null // discord_user_id:网页提交没有
);
SharedLink saved = linkRepo.insert(draft);
log.info("shared-link submitted: id={} submitter={} host={}",
Expand All @@ -194,16 +199,21 @@ public SharedLink submit(Long submitterId, String rawUrl, String recommendation)
*
* 与 submit() 的差异:
* - 不做 24h 限频(机器人是整群一把提,限频会把合法流量卡死)
* - 不关心前端登录态:submitter 固定取 seed 的 discord-bridge 账号
* - 不关心前端登录态:分享人的 Discord id 已绑定到可用账号时记到本人名下,
* 否则(没传 id / 没绑定 / 账号禁用)挂 seed 的 discord-bridge 账号
* - discordUserId 原样落库,没绑定的人日后绑定了还能按 id 改挂
* - recommendation 前缀自动打「来自 Discord @{label}」,前端展示能看到真实分享人
*
* 与 submit() 一致的:
* - 同样走 UrlNormalizer、去重、OG + DeepSeek 异步富化
* - 同样的状态机(PENDING → APPROVED/PENDING_MANUAL/FLAGGED)
* - 同样的 DuplicateKeyException 语义
*
* @param discordUserId 分享人的 Discord 用户 id(纯数字),可为 null(如 MCP 渠道)
*/
@Transactional(rollbackFor = Exception.class)
public SharedLink submitInternal(String submitterLabel, String rawUrl, String recommendation) {
public SharedLink submitInternal(String submitterLabel, String rawUrl, String recommendation,
String discordUserId) {
UrlNormalizer.Normalized norm = UrlNormalizer.normalize(rawUrl);

Long resolvedBridgeId = bridgeId;
Expand All @@ -215,6 +225,9 @@ public SharedLink submitInternal(String submitterLabel, String rawUrl, String re
"discord-bridge 账号不存在,检查 schema.sql 是否已执行 seed"));
this.bridgeId = resolvedBridgeId;
}
Long submitterId = discordUserId == null
? resolvedBridgeId
: userIdentityService.findEnabledUserId("discord", discordUserId).orElse(resolvedBridgeId);

String urlHash = UrlNormalizer.sha256Hex(norm.canonicalUrl());

Expand All @@ -229,7 +242,7 @@ public SharedLink submitInternal(String submitterLabel, String rawUrl, String re

SharedLink draft = new SharedLink(
null,
resolvedBridgeId,
submitterId,
norm.canonicalUrl(),
urlHash,
norm.host(),
Expand All @@ -241,11 +254,12 @@ public SharedLink submitInternal(String submitterLabel, String rawUrl, String re
SharedLinkStatus.PENDING,
0,
null, null,
null, null
null, null,
discordUserId
);
SharedLink saved = linkRepo.insert(draft);
log.info("shared-link submitted via bridge: id={} label={} host={}",
saved.id(), submitterLabel, saved.host());
log.info("shared-link submitted via bridge: id={} label={} discordUserId={} submitter={} host={}",
saved.id(), submitterLabel, discordUserId, submitterId, saved.host());

if (enrichmentWorker != null) {
enrichmentWorker.enrich(saved.id());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.involutionhell.backend.usercenter.service;

import com.involutionhell.backend.usercenter.dto.LinkedIdentityView;
import com.involutionhell.backend.usercenter.model.UserAccount;
import com.involutionhell.backend.usercenter.model.UserIdentity;
import com.involutionhell.backend.usercenter.repository.UserAccountRepository;
import com.involutionhell.backend.usercenter.repository.UserIdentityRepository;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -30,6 +32,17 @@ public List<LinkedIdentityView> listForUser(long userId) {
.toList();
}

/**
* provider 身份 → 已启用账号的 id。没绑定或账号被禁用返回 empty。
* 给机器人渠道把 Discord 分享归到本人名下用,不涉及登录。
*/
public Optional<Long> findEnabledUserId(String provider, String providerUserId) {
return userIdentityRepository.findByProviderAndProviderUserId(provider, providerUserId)
.flatMap(i -> userAccountRepository.findById(i.userId()))
.filter(UserAccount::enabled)
.map(UserAccount::id);
}

/**
* 解绑指定 provider 身份。返回解绑后剩余身份列表。
*
Expand Down
10 changes: 8 additions & 2 deletions src/main/resources/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ ON CONFLICT (username) DO NOTHING;
-- 双重禁用:
-- 1. password_hash='!' —— 非 sha256 合法十六进制,口令校验永远失败
-- 2. enabled=FALSE —— 未来若有人重置密码 / 误改口令,enabled 仍挡住登录
-- 该账号仅被 SharedLinkService.submitInternal 取 submitter_id 用(FK 不 care enabled),
-- 真实提交人名放在 recommendation 里("来自 Discord @xxx")。
-- 该账号仅被 SharedLinkService.submitInternal 取 submitter_id 用(FK 不 care enabled):
-- 分享人的 Discord 没绑定站内账号时挂到这里,Discord id 记在 shared_links.discord_user_id,
-- 昵称放在 recommendation 里("来自 Discord @xxx")。
--
-- 注意:这里用 DO UPDATE 而非 DO NOTHING,是因为老版本 seed 落过 enabled=TRUE,
-- 每次启动 reconcile 回 FALSE,可阻断历史遗留记录再次被误启用。
Expand Down Expand Up @@ -249,6 +250,7 @@ CREATE TABLE IF NOT EXISTS shared_links (
admin_note TEXT, -- 管理员人工审核备注(reject 原因 / approve 评语)
probe_fail_count INT NOT NULL DEFAULT 0, -- M9 失效探活连续失败计数,>=2 → ARCHIVED
probe_last_at TIMESTAMPTZ, -- 最近一次 HEAD 探活时间,用于跳过刚跑过的
discord_user_id VARCHAR(32), -- 机器人渠道的分享人 Discord id;网页提交为 NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Expand All @@ -259,6 +261,10 @@ ALTER TABLE shared_links ADD COLUMN IF NOT EXISTS probe_last_at TIMESTAMPTZ;
-- admin_note:管理员 approve/reject 时的备注(reject 原因、approve 评语等)
-- 与 archived_reason 区分:后者是系统归档原因(link_dead 等),前者是人工审核原因
ALTER TABLE shared_links ADD COLUMN IF NOT EXISTS admin_note TEXT;
-- discord_user_id:机器人渠道记下分享人的 Discord id(昵称会改、会重名,id 不会)。
-- 提交时已绑定 Discord 的账号直接记为 submitter;没绑定的仍挂 discord-bridge,
-- 留着 id 以便日后对方绑定后按 id 改挂。
ALTER TABLE shared_links ADD COLUMN IF NOT EXISTS discord_user_id VARCHAR(32);

CREATE INDEX IF NOT EXISTS idx_shared_links_status_created ON shared_links(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_shared_links_category ON shared_links(category) WHERE status = 'APPROVED';
Expand Down
Loading