Skip to content

Commit 2a4477b

Browse files
authored
feat(events): 活动管理后端 — events 表 + 公开读 API + 管理员 CRUD + 感兴趣 (#9)
1 parent 1fb697f commit 2a4477b

15 files changed

Lines changed: 885 additions & 6 deletions

File tree

.env.example

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,16 @@ AUTH_GITHUB_ID=
2121
AUTH_GITHUB_SECRET=
2222
AUTH_SECRET=
2323

24-
# --- OpenAI ---
24+
# --- AI 模型(用 OpenAI 兼容协议调用 GLM-4.6V-Flash 作为默认 fallback) ---
25+
#
26+
# 变量名沿用 OPENAI_* 是因为 Java 代码里用的是 OpenAI /chat/completions 标准协议,
27+
# 只是 URL + Key + Model 指向了智谱开放平台。未来要切回真 OpenAI / Anthropic
28+
# 等任何 OpenAI-compatible 服务,只换这三个值即可。
29+
#
30+
# 免费 key 从 https://open.bigmodel.cn/ 获取。
2531
OPENAI_API_KEY=
26-
OPENAI_API_URL=https://api.openai.com/v1
27-
OPENAI_MODEL=gpt-4.1
32+
OPENAI_API_URL=https://open.bigmodel.cn/api/paas/v4
33+
OPENAI_MODEL=glm-4.6v-flash
2834

2935
# --- 应用基本设置 ---
3036
SPRING_APPLICATION_NAME=backend

docker-compose.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ services:
1818
AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-}
1919
AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET:-}
2020
AUTH_SECRET: ${AUTH_SECRET:-}
21-
# OpenAI
21+
# AI 模型(默认 GLM-4.6V-Flash 免费 fallback,OpenAI 兼容协议)
22+
# 变量名沿用 OPENAI_*:Java 用的是 /chat/completions 规范协议,URL+Key+Model
23+
# 三件套指哪打哪,OpenAI / 智谱 / Anthropic-compat 任选,无需改代码。
2224
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
23-
OPENAI_API_URL: ${OPENAI_API_URL:-https://api.openai.com/v1}
24-
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1}
25+
OPENAI_API_URL: ${OPENAI_API_URL:-https://open.bigmodel.cn/api/paas/v4}
26+
OPENAI_MODEL: ${OPENAI_MODEL:-glm-4.6v-flash}
2527
# Actuator
2628
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: ${MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE:-health}
2729
MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED: ${MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED:-true}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ public void addInterceptors(InterceptorRegistry registry) {
3333
.notMatch("/api/user-center/github/repos/**") // GitHub 公开 repos 代理,匿名可访问
3434
.notMatch("/api/user-center/zotero/items") // Zotero itemKey 元信息代理,匿名可访问
3535
.notMatch("/api/docs/history") // 文档修改历史公开读,匿名可访问
36+
// Events 公开读接口:/api/events 列表 + /api/events/{id} 详情匿名可访问。
37+
// /api/events/{id}/interest 感兴趣接口需要登录,由 @SaCheckLogin 在方法级别兜底。
38+
// /api/admin/events/** 不放行,走 @SaCheckRole("admin") 校验。
39+
.notMatch("/api/events", "/api/events/*")
3640
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
3741
})).addPathPatterns("/**");
3842
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.involutionhell.backend.events.controller;
2+
3+
import cn.dev33.satoken.annotation.SaCheckRole;
4+
import cn.dev33.satoken.stp.StpUtil;
5+
import com.involutionhell.backend.common.api.ApiResponse;
6+
import com.involutionhell.backend.events.dto.EventRequest;
7+
import com.involutionhell.backend.events.dto.EventView;
8+
import com.involutionhell.backend.events.model.Event;
9+
import com.involutionhell.backend.events.service.EventService;
10+
import org.springframework.web.bind.annotation.DeleteMapping;
11+
import org.springframework.web.bind.annotation.GetMapping;
12+
import org.springframework.web.bind.annotation.PathVariable;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.PutMapping;
15+
import org.springframework.web.bind.annotation.RequestBody;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RestController;
18+
19+
import java.util.List;
20+
import java.util.Optional;
21+
22+
/**
23+
* 活动管理接口(需要 admin 角色)。
24+
*
25+
* 路由(全部需要 admin):
26+
* - GET /api/admin/events 全量列表(含 draft / cancelled)
27+
* - GET /api/admin/events/{id} 单条详情(管理员可看 draft)
28+
* - POST /api/admin/events 创建
29+
* - PUT /api/admin/events/{id} 更新
30+
* - DELETE /api/admin/events/{id} 删除(级联删 event_interests)
31+
*
32+
* 使用 Sa-Token 的 @SaCheckRole("admin") 做整个类级别保护。拦截器链上会先做登录校验,
33+
* 再做角色校验,所以匿名访问返回 401,已登录非 admin 返回 403。
34+
*/
35+
@RestController
36+
@RequestMapping("/api/admin/events")
37+
@SaCheckRole("admin")
38+
public class EventAdminController {
39+
40+
private final EventService eventService;
41+
42+
public EventAdminController(EventService eventService) {
43+
this.eventService = eventService;
44+
}
45+
46+
@GetMapping
47+
public ApiResponse<List<EventView>> list() {
48+
List<Event> events = eventService.listAllForAdmin();
49+
List<EventView> views = events.stream()
50+
.map(e -> EventView.from(e, eventService.countInterest(e.id())))
51+
.toList();
52+
return ApiResponse.ok(views);
53+
}
54+
55+
@GetMapping("/{id}")
56+
public ApiResponse<EventView> detail(@PathVariable Long id) {
57+
Optional<Event> maybe = eventService.findById(id);
58+
if (maybe.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);
59+
long interest = eventService.countInterest(id);
60+
return ApiResponse.ok(EventView.from(maybe.get(), interest));
61+
}
62+
63+
@PostMapping
64+
public ApiResponse<EventView> create(@RequestBody EventRequest req) {
65+
String validationError = validate(req);
66+
if (validationError != null) return new ApiResponse<>(false, validationError, null);
67+
68+
long organizerId = StpUtil.getLoginIdAsLong();
69+
Event draft = req.toEvent(null, organizerId, null, null);
70+
Event created = eventService.create(draft);
71+
return ApiResponse.ok("活动已创建", EventView.from(created, 0));
72+
}
73+
74+
@PutMapping("/{id}")
75+
public ApiResponse<EventView> update(@PathVariable Long id, @RequestBody EventRequest req) {
76+
String validationError = validate(req);
77+
if (validationError != null) return new ApiResponse<>(false, validationError, null);
78+
79+
Optional<Event> existing = eventService.findById(id);
80+
if (existing.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);
81+
82+
// 保留原 organizerId(不允许更新时转让组织方;要转让走独立接口,避免误操作)
83+
Long originalOrganizer = existing.get().organizerId();
84+
Event updated = req.toEvent(id, originalOrganizer, existing.get().createdAt(), null);
85+
Event saved = eventService.update(updated);
86+
long interest = eventService.countInterest(id);
87+
return ApiResponse.ok("活动已更新", EventView.from(saved, interest));
88+
}
89+
90+
@DeleteMapping("/{id}")
91+
public ApiResponse<Void> delete(@PathVariable Long id) {
92+
Optional<Event> existing = eventService.findById(id);
93+
if (existing.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);
94+
eventService.delete(id);
95+
return ApiResponse.okMessage("活动已删除");
96+
}
97+
98+
/**
99+
* 基础字段校验。返回 null 表示校验通过,否则返回错误信息。
100+
* 不用 @Valid + JSR-380 是因为项目当前没引入 spring-boot-starter-validation,
101+
* 避免为这一个模块引进新依赖。
102+
*/
103+
private String validate(EventRequest req) {
104+
if (req == null) return "请求体不能为空";
105+
if (req.title() == null || req.title().isBlank()) return "title 不能为空";
106+
if (req.status() != null && !List.of("draft", "published", "archived", "cancelled").contains(req.status())) {
107+
return "status 必须是 draft / published / archived / cancelled 之一";
108+
}
109+
if (req.startTime() != null && req.endTime() != null && req.endTime().isBefore(req.startTime())) {
110+
return "endTime 不能早于 startTime";
111+
}
112+
return null;
113+
}
114+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.involutionhell.backend.events.controller;
2+
3+
import cn.dev33.satoken.stp.StpUtil;
4+
import com.involutionhell.backend.common.api.ApiResponse;
5+
import com.involutionhell.backend.events.dto.EventView;
6+
import com.involutionhell.backend.events.model.Event;
7+
import com.involutionhell.backend.events.service.EventService;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.PathVariable;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RestController;
12+
13+
import java.util.HashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.Optional;
17+
18+
/**
19+
* 活动公开读接口(匿名可访问)。
20+
*
21+
* 路由:
22+
* - GET /api/events 公开列表(published + archived)
23+
* - GET /api/events/{id} 单条详情(含"感兴趣"统计 + 当前用户是否感兴趣)
24+
*
25+
* SaToken 白名单配置见 SaTokenConfigure.java。
26+
* 单条接口里读取"当前用户"时用 StpUtil.isLogin() 短路——匿名用户时不报错,只是
27+
* interested 字段返回 false。
28+
*/
29+
@RestController
30+
@RequestMapping("/api/events")
31+
public class EventController {
32+
33+
private final EventService eventService;
34+
35+
public EventController(EventService eventService) {
36+
this.eventService = eventService;
37+
}
38+
39+
@GetMapping
40+
public ApiResponse<List<EventView>> list() {
41+
List<Event> events = eventService.listPublic();
42+
List<EventView> views = events.stream()
43+
.map(e -> EventView.from(e, eventService.countInterest(e.id())))
44+
.toList();
45+
return ApiResponse.ok(views);
46+
}
47+
48+
@GetMapping("/{id}")
49+
public ApiResponse<Map<String, Object>> detail(@PathVariable Long id) {
50+
Optional<Event> maybe = eventService.findById(id);
51+
if (maybe.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);
52+
Event event = maybe.get();
53+
// draft 状态不对外公开(即使直接访问 /api/events/{id} 也返回 404 语义)
54+
if ("draft".equals(event.status())) {
55+
return new ApiResponse<>(false, "活动不存在", null);
56+
}
57+
58+
long interestCount = eventService.countInterest(id);
59+
boolean interested = false;
60+
if (StpUtil.isLogin()) {
61+
long uid = StpUtil.getLoginIdAsLong();
62+
interested = eventService.isInterested(id, uid);
63+
}
64+
65+
Map<String, Object> body = new HashMap<>();
66+
body.put("event", EventView.from(event, interestCount));
67+
body.put("interested", interested);
68+
return ApiResponse.ok(body);
69+
}
70+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.involutionhell.backend.events.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.events.service.EventService;
7+
import org.springframework.web.bind.annotation.DeleteMapping;
8+
import org.springframework.web.bind.annotation.PathVariable;
9+
import org.springframework.web.bind.annotation.PostMapping;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RestController;
12+
13+
import java.util.Map;
14+
15+
/**
16+
* 活动"感兴趣"开关(登录用户)。
17+
*
18+
* 路由:
19+
* - POST /api/events/{id}/interest 标记感兴趣(幂等)
20+
* - DELETE /api/events/{id}/interest 取消感兴趣(幂等)
21+
*
22+
* 返回结构统一包含 count + interested,前端点完按钮可以直接用返回值刷新 UI,
23+
* 不用再额外调一次 /api/events/{id} 详情接口。
24+
*/
25+
@RestController
26+
@RequestMapping("/api/events")
27+
public class EventInterestController {
28+
29+
private final EventService eventService;
30+
31+
public EventInterestController(EventService eventService) {
32+
this.eventService = eventService;
33+
}
34+
35+
@SaCheckLogin
36+
@PostMapping("/{id}/interest")
37+
public ApiResponse<Map<String, Object>> mark(@PathVariable Long id) {
38+
long uid = StpUtil.getLoginIdAsLong();
39+
if (eventService.findById(id).isEmpty()) {
40+
return new ApiResponse<>(false, "活动不存在", null);
41+
}
42+
eventService.markInterested(id, uid);
43+
return ApiResponse.ok(Map.of(
44+
"count", eventService.countInterest(id),
45+
"interested", true
46+
));
47+
}
48+
49+
@SaCheckLogin
50+
@DeleteMapping("/{id}/interest")
51+
public ApiResponse<Map<String, Object>> unmark(@PathVariable Long id) {
52+
long uid = StpUtil.getLoginIdAsLong();
53+
if (eventService.findById(id).isEmpty()) {
54+
return new ApiResponse<>(false, "活动不存在", null);
55+
}
56+
eventService.unmarkInterested(id, uid);
57+
return ApiResponse.ok(Map.of(
58+
"count", eventService.countInterest(id),
59+
"interested", false
60+
));
61+
}
62+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.involutionhell.backend.events.dto;
2+
3+
import com.involutionhell.backend.events.model.Event;
4+
import com.involutionhell.backend.events.model.Event.Speaker;
5+
6+
import java.time.Instant;
7+
import java.util.List;
8+
9+
/**
10+
* Admin 创建 / 更新活动的入参。
11+
*
12+
* 不直接用 Event 当入参是为了:
13+
* - 屏蔽客户端传 id / createdAt / updatedAt(这些必须由后端控制)
14+
* - 入参 tags 是 List<String> 更符合前端习惯;出参依然给 List<String>;DB 层转成逗号分隔
15+
* - 允许字段部分缺省(全都 String 可空),避免前端表单空字段触发 Jackson 报错
16+
*/
17+
public record EventRequest(
18+
String title,
19+
String description,
20+
String coverUrl,
21+
Instant startTime,
22+
Instant endTime,
23+
String discordLink,
24+
String playbackUrl,
25+
List<Speaker> speakers,
26+
List<String> tags,
27+
String status
28+
) {
29+
/** 转换成 domain Event。id / timestamps / organizerId 由 Controller 层填。 */
30+
public Event toEvent(Long id, Long organizerId, Instant createdAt, Instant updatedAt) {
31+
return new Event(
32+
id,
33+
title != null ? title.trim() : "",
34+
description != null ? description : "",
35+
emptyToNull(coverUrl),
36+
startTime,
37+
endTime,
38+
emptyToNull(discordLink),
39+
emptyToNull(playbackUrl),
40+
speakers != null ? speakers : List.of(),
41+
joinTags(tags),
42+
status != null && !status.isBlank() ? status : "draft",
43+
organizerId,
44+
createdAt,
45+
updatedAt
46+
);
47+
}
48+
49+
private static String emptyToNull(String s) {
50+
return s != null && !s.isBlank() ? s.trim() : null;
51+
}
52+
53+
private static String joinTags(List<String> tags) {
54+
if (tags == null || tags.isEmpty()) return "";
55+
return String.join(",", tags.stream().map(String::trim).filter(s -> !s.isEmpty()).toList());
56+
}
57+
}

0 commit comments

Comments
 (0)