Skip to content

Commit 1fb697f

Browse files
committed
feat(zotero): 代理 /api/user-center/zotero/items?keys= + Caffeine 1h
新增 zotero 模块:按 itemKey 批量从 Zotero group API 拉元信息,给个人主页 pinned_papers 用。 - ZoteroService: 50 key 分批请求,按输入顺序返回;缓存 key 用 groupId+CSV 避免串扰 - authors 拼 "Last, First; Last, First",year 从 date regex 抽 - ZOTERO_GROUP_ID 可配置,默认 6053219 (社区 UNSW_AI group) - 公开接口 SaToken 白名单;单次最多 100 个 key 防滥用 application.properties cache-names 加 zoteroItems。
1 parent b6b48b2 commit 1fb697f

5 files changed

Lines changed: 242 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public void addInterceptors(InterceptorRegistry registry) {
3131
.notMatch("/api/user-center/follows/following/**") // 关注列表公开读
3232
.notMatch("/api/user-center/follows/is-following/**") // 匿名查询时返回 false
3333
.notMatch("/api/user-center/github/repos/**") // GitHub 公开 repos 代理,匿名可访问
34+
.notMatch("/api/user-center/zotero/items") // Zotero itemKey 元信息代理,匿名可访问
3435
.notMatch("/api/docs/history") // 文档修改历史公开读,匿名可访问
3536
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
3637
})).addPathPatterns("/**");
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.involutionhell.backend.zotero;
2+
3+
import com.involutionhell.backend.common.api.ApiResponse;
4+
import org.springframework.web.bind.annotation.GetMapping;
5+
import org.springframework.web.bind.annotation.RequestMapping;
6+
import org.springframework.web.bind.annotation.RequestParam;
7+
import org.springframework.web.bind.annotation.RestController;
8+
9+
import java.util.Arrays;
10+
import java.util.List;
11+
import java.util.stream.Stream;
12+
13+
/**
14+
* 批量按 itemKey 拉 Zotero group 里的文献元信息。
15+
* 给个人主页 pinned_papers 用(用户只存 itemKey,运行时由这个接口补齐 title/authors 等)。
16+
*/
17+
@RestController
18+
@RequestMapping("/api/user-center/zotero")
19+
public class ZoteroController {
20+
21+
private final ZoteroService zoteroService;
22+
23+
public ZoteroController(ZoteroService zoteroService) {
24+
this.zoteroService = zoteroService;
25+
}
26+
27+
/**
28+
* @param keys 逗号分隔的 itemKey 列表,例如 "ABCD1234,EFGH5678"
29+
* @param groupId 可选,覆盖默认 group(默认走 application.properties ZOTERO_GROUP_ID)
30+
*/
31+
@GetMapping("/items")
32+
public ApiResponse<List<ZoteroItemDto>> getItems(
33+
@RequestParam(name = "keys") String keys,
34+
@RequestParam(name = "groupId", required = false, defaultValue = "0") long groupId
35+
) {
36+
if (keys == null || keys.isBlank()) {
37+
return ApiResponse.ok(List.of());
38+
}
39+
List<String> parsed = Stream.of(keys.split(","))
40+
.map(String::trim)
41+
.filter(s -> !s.isEmpty())
42+
.distinct()
43+
.toList();
44+
if (parsed.isEmpty()) return ApiResponse.ok(List.of());
45+
// 限制一次最多 100 个 key,防滥用
46+
if (parsed.size() > 100) parsed = parsed.subList(0, 100);
47+
return ApiResponse.ok(zoteroService.getItems(groupId, parsed));
48+
}
49+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.involutionhell.backend.zotero;
2+
3+
/**
4+
* 用户 pinned_papers 里 itemKey 关联出来的一条 Zotero 元信息。
5+
* 字段对齐前端 UserPaperItem,便于直接塞进 pinned_papers 数据流。
6+
*
7+
* @param itemKey Zotero item key(A1B2C3D4 格式)
8+
* @param title 文章标题
9+
* @param authors 作者列表拼接字符串,"LastName, FirstName; LastName2, ..."
10+
* @param year 出版年(从 date 字段提取),可能为空
11+
* @param url 原文链接或 Zotero 详情页
12+
* @param abstractNote 摘要
13+
* @param publicationTitle 期刊/会议名
14+
*/
15+
public record ZoteroItemDto(
16+
String itemKey,
17+
String title,
18+
String authors,
19+
String year,
20+
String url,
21+
String abstractNote,
22+
String publicationTitle
23+
) {}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package com.involutionhell.backend.zotero;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.cache.annotation.Cacheable;
7+
import org.springframework.stereotype.Service;
8+
import org.springframework.web.client.HttpClientErrorException;
9+
import org.springframework.web.client.RestClient;
10+
import org.springframework.web.client.RestClientException;
11+
import tools.jackson.databind.JsonNode;
12+
import tools.jackson.databind.ObjectMapper;
13+
14+
import java.util.ArrayList;
15+
import java.util.LinkedHashMap;
16+
import java.util.List;
17+
import java.util.Map;
18+
19+
/**
20+
* 按 itemKey 批量从 Zotero group 拉 item 元信息。
21+
*
22+
* - 对应社区共享的 Zotero group(默认 6053219)
23+
* - 支持一次传多个 key,用逗号拼接走 ?itemKey=A,B,C 端点
24+
* - Caffeine 缓存 1h(用户 pinned papers 很少换,1h 足够)
25+
* - Zotero API 匿名访问公共 group 是允许的,无需 API key
26+
*/
27+
@Service
28+
public class ZoteroService {
29+
30+
private static final Logger log = LoggerFactory.getLogger(ZoteroService.class);
31+
private static final int MAX_KEYS_PER_REQUEST = 50;
32+
33+
private final RestClient zotero;
34+
private final ObjectMapper mapper;
35+
private final long defaultGroupId;
36+
37+
public ZoteroService(
38+
@Value("${ZOTERO_GROUP_ID:6053219}") long defaultGroupId,
39+
ObjectMapper mapper
40+
) {
41+
this.defaultGroupId = defaultGroupId;
42+
this.mapper = mapper;
43+
this.zotero = RestClient.builder()
44+
.baseUrl("https://api.zotero.org")
45+
.defaultHeader("User-Agent", "involutionhell-backend")
46+
.build();
47+
}
48+
49+
/**
50+
* 按 itemKey 列表批量取。返回顺序和输入顺序对齐;找不到的 key 直接丢弃。
51+
*/
52+
@Cacheable(value = "zoteroItems", key = "#groupId + ':' + T(java.lang.String).join(',', #keys)", unless = "#result.isEmpty()")
53+
public List<ZoteroItemDto> getItems(long groupId, List<String> keys) {
54+
if (keys == null || keys.isEmpty()) return List.of();
55+
long gid = groupId > 0 ? groupId : defaultGroupId;
56+
57+
// 按 50 个一批分页请求(Zotero 单次上限)
58+
Map<String, ZoteroItemDto> byKey = new LinkedHashMap<>();
59+
for (int i = 0; i < keys.size(); i += MAX_KEYS_PER_REQUEST) {
60+
List<String> batch = keys.subList(
61+
i, Math.min(i + MAX_KEYS_PER_REQUEST, keys.size()));
62+
String csv = String.join(",", batch);
63+
try {
64+
String body = zotero.get()
65+
.uri(uri -> uri
66+
.path("/groups/{groupId}/items")
67+
.queryParam("itemKey", csv)
68+
.queryParam("format", "json")
69+
.build(gid)
70+
)
71+
.retrieve()
72+
.body(String.class);
73+
parseBatch(body, byKey);
74+
} catch (HttpClientErrorException e) {
75+
log.warn("[ZoteroService] Zotero API 返回 {}: {}, keys={}",
76+
e.getStatusCode(), e.getStatusText(), csv);
77+
} catch (RestClientException e) {
78+
log.warn("[ZoteroService] Zotero API 网络异常: {}, keys={}",
79+
e.getMessage(), csv);
80+
}
81+
}
82+
83+
// 按输入顺序输出
84+
List<ZoteroItemDto> out = new ArrayList<>(byKey.size());
85+
for (String k : keys) {
86+
ZoteroItemDto dto = byKey.get(k);
87+
if (dto != null) out.add(dto);
88+
}
89+
return out;
90+
}
91+
92+
/**
93+
* 解析 Zotero /items 批量响应,把每条塞进 map。
94+
*/
95+
private void parseBatch(String body, Map<String, ZoteroItemDto> byKey) {
96+
if (body == null || body.isBlank()) return;
97+
try {
98+
JsonNode arr = mapper.readTree(body);
99+
if (!arr.isArray()) return;
100+
for (JsonNode it : arr) {
101+
String key = text(it, "key");
102+
if (key.isEmpty()) continue;
103+
JsonNode data = it.get("data");
104+
if (data == null) continue;
105+
106+
String title = text(data, "title");
107+
String date = text(data, "date");
108+
String year = extractYear(date);
109+
String url = text(data, "url");
110+
if (url.isEmpty()) {
111+
// 没 url 时退回 Zotero 详情页
112+
JsonNode links = it.get("links");
113+
JsonNode alt = links != null ? links.get("alternate") : null;
114+
if (alt != null) url = text(alt, "href");
115+
}
116+
String abstractNote = text(data, "abstractNote");
117+
String publicationTitle = text(data, "publicationTitle");
118+
String authors = extractAuthors(data.get("creators"));
119+
120+
byKey.put(key, new ZoteroItemDto(
121+
key, title, authors, year, url, abstractNote, publicationTitle));
122+
}
123+
} catch (Exception e) {
124+
log.warn("[ZoteroService] 解析 Zotero 响应失败: {}", e.getMessage());
125+
}
126+
}
127+
128+
/**
129+
* 把 creators 数组拼成 "Last1, First1; Last2, First2" 格式。
130+
*/
131+
private static String extractAuthors(JsonNode creators) {
132+
if (creators == null || !creators.isArray() || creators.isEmpty()) return "";
133+
StringBuilder sb = new StringBuilder();
134+
for (JsonNode c : creators) {
135+
if (sb.length() > 0) sb.append("; ");
136+
String name = text(c, "name"); // organization / single-name creator
137+
if (!name.isEmpty()) {
138+
sb.append(name);
139+
continue;
140+
}
141+
String last = text(c, "lastName");
142+
String first = text(c, "firstName");
143+
if (!last.isEmpty() && !first.isEmpty()) {
144+
sb.append(last).append(", ").append(first);
145+
} else if (!last.isEmpty()) {
146+
sb.append(last);
147+
} else if (!first.isEmpty()) {
148+
sb.append(first);
149+
}
150+
}
151+
return sb.toString();
152+
}
153+
154+
/**
155+
* 从 "2024-03-15" / "2024" / "March 2024" 等格式里抓 4 位年份。
156+
*/
157+
private static String extractYear(String date) {
158+
if (date == null || date.isBlank()) return "";
159+
var m = java.util.regex.Pattern.compile("(\\d{4})").matcher(date);
160+
return m.find() ? m.group(1) : "";
161+
}
162+
163+
private static String text(JsonNode node, String field) {
164+
if (node == null) return "";
165+
JsonNode v = node.get(field);
166+
return (v == null || v.isNull()) ? "" : v.asText();
167+
}
168+
}

src/main/resources/application.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,6 @@ ga4.credentials-path=${GA4_CREDENTIALS_PATH:./ga4-sa-key.json}
7777
spring.cache.type=caffeine
7878
# 注册所有需要的缓存名(之前写了两次被覆盖,eventSummary 实际没注册导致缓存失效)
7979
# docHistory 用来缓存 GitHub commits API 结果,避免给每次文档页访问都打 GitHub 限流
80-
spring.cache.cache-names=topDocs,eventSummary,docHistory,githubRepos
80+
spring.cache.cache-names=topDocs,eventSummary,docHistory,githubRepos,zoteroItems
8181
# 统一 TTL 10 分钟 / 最多 100 key(docHistory 500 不同路径也够了)
8282
spring.cache.caffeine.spec=maximumSize=200,expireAfterWrite=600s

0 commit comments

Comments
 (0)