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
12 changes: 9 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@ AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
AUTH_SECRET=

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

# --- 应用基本设置 ---
SPRING_APPLICATION_NAME=backend
Expand Down
8 changes: 5 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ services:
AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-}
AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET:-}
AUTH_SECRET: ${AUTH_SECRET:-}
# OpenAI
# AI 模型(默认 GLM-4.6V-Flash 免费 fallback,OpenAI 兼容协议)
# 变量名沿用 OPENAI_*:Java 用的是 /chat/completions 规范协议,URL+Key+Model
# 三件套指哪打哪,OpenAI / 智谱 / Anthropic-compat 任选,无需改代码。
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_API_URL: ${OPENAI_API_URL:-https://api.openai.com/v1}
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1}
OPENAI_API_URL: ${OPENAI_API_URL:-https://open.bigmodel.cn/api/paas/v4}
OPENAI_MODEL: ${OPENAI_MODEL:-glm-4.6v-flash}
# Actuator
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: ${MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE:-health}
MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED: ${MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED:-true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public void addInterceptors(InterceptorRegistry registry) {
.notMatch("/api/user-center/github/repos/**") // GitHub 公开 repos 代理,匿名可访问
.notMatch("/api/user-center/zotero/items") // Zotero itemKey 元信息代理,匿名可访问
.notMatch("/api/docs/history") // 文档修改历史公开读,匿名可访问
// Events 公开读接口:/api/events 列表 + /api/events/{id} 详情匿名可访问。
// /api/events/{id}/interest 感兴趣接口需要登录,由 @SaCheckLogin 在方法级别兜底。
// /api/admin/events/** 不放行,走 @SaCheckRole("admin") 校验。
.notMatch("/api/events", "/api/events/*")
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
})).addPathPatterns("/**");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.involutionhell.backend.events.controller;

import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.stp.StpUtil;
import com.involutionhell.backend.common.api.ApiResponse;
import com.involutionhell.backend.events.dto.EventRequest;
import com.involutionhell.backend.events.dto.EventView;
import com.involutionhell.backend.events.model.Event;
import com.involutionhell.backend.events.service.EventService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Optional;

/**
* 活动管理接口(需要 admin 角色)。
*
* 路由(全部需要 admin):
* - GET /api/admin/events 全量列表(含 draft / cancelled)
* - GET /api/admin/events/{id} 单条详情(管理员可看 draft)
* - POST /api/admin/events 创建
* - PUT /api/admin/events/{id} 更新
* - DELETE /api/admin/events/{id} 删除(级联删 event_interests)
*
* 使用 Sa-Token 的 @SaCheckRole("admin") 做整个类级别保护。拦截器链上会先做登录校验,
* 再做角色校验,所以匿名访问返回 401,已登录非 admin 返回 403。
*/
@RestController
@RequestMapping("/api/admin/events")
@SaCheckRole("admin")
public class EventAdminController {

private final EventService eventService;

public EventAdminController(EventService eventService) {
this.eventService = eventService;
}

@GetMapping
public ApiResponse<List<EventView>> list() {
List<Event> events = eventService.listAllForAdmin();
List<EventView> views = events.stream()
.map(e -> EventView.from(e, eventService.countInterest(e.id())))
.toList();
Comment on lines +48 to +51

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

管理员列表同样对每个活动逐条 countInterest,会造成 N+1 查询;后台活动数量增长后会明显拖慢。建议改为一次性聚合 interestCount(JOIN/GROUP BY 或批量计数)再构造 EventView。

Copilot uses AI. Check for mistakes.
return ApiResponse.ok(views);
Comment on lines +46 to +52

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新增了一整套 Events 的公开读、管理员 CRUD 与 interest 开关,但当前测试目录里已有大量基于 MockMvc 的集成测试覆盖鉴权与业务错误分支;这里缺少对应的集成测试会让权限白名单(/api/events)、admin 角色校验(/api/admin/events)以及 interest 幂等行为缺乏回归保障。建议补充类似 UserCenterControllerIntegrationTests 的 MockMvc 测试用例。

Copilot uses AI. Check for mistakes.
}

@GetMapping("/{id}")
public ApiResponse<EventView> detail(@PathVariable Long id) {
Optional<Event> maybe = eventService.findById(id);
if (maybe.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);
long interest = eventService.countInterest(id);
return ApiResponse.ok(EventView.from(maybe.get(), interest));
}

@PostMapping
public ApiResponse<EventView> create(@RequestBody EventRequest req) {
String validationError = validate(req);
if (validationError != null) return new ApiResponse<>(false, validationError, null);

long organizerId = StpUtil.getLoginIdAsLong();
Event draft = req.toEvent(null, organizerId, null, null);
Event created = eventService.create(draft);
return ApiResponse.ok("活动已创建", EventView.from(created, 0));
}

@PutMapping("/{id}")
public ApiResponse<EventView> update(@PathVariable Long id, @RequestBody EventRequest req) {
String validationError = validate(req);
if (validationError != null) return new ApiResponse<>(false, validationError, null);

Optional<Event> existing = eventService.findById(id);
if (existing.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);

// 保留原 organizerId(不允许更新时转让组织方;要转让走独立接口,避免误操作)
Long originalOrganizer = existing.get().organizerId();
Event updated = req.toEvent(id, originalOrganizer, existing.get().createdAt(), null);
Event saved = eventService.update(updated);
long interest = eventService.countInterest(id);
return ApiResponse.ok("活动已更新", EventView.from(saved, interest));
}

@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
Optional<Event> existing = eventService.findById(id);
if (existing.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);
eventService.delete(id);
return ApiResponse.okMessage("活动已删除");
}

/**
* 基础字段校验。返回 null 表示校验通过,否则返回错误信息。
* 不用 @Valid + JSR-380 是因为项目当前没引入 spring-boot-starter-validation,
* 避免为这一个模块引进新依赖。
*/
private String validate(EventRequest req) {
if (req == null) return "请求体不能为空";
if (req.title() == null || req.title().isBlank()) return "title 不能为空";
if (req.status() != null && !List.of("draft", "published", "archived", "cancelled").contains(req.status())) {
return "status 必须是 draft / published / archived / cancelled 之一";
}
if (req.startTime() != null && req.endTime() != null && req.endTime().isBefore(req.startTime())) {
return "endTime 不能早于 startTime";
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.involutionhell.backend.events.controller;

import cn.dev33.satoken.stp.StpUtil;
import com.involutionhell.backend.common.api.ApiResponse;
import com.involutionhell.backend.events.dto.EventView;
import com.involutionhell.backend.events.model.Event;
import com.involutionhell.backend.events.service.EventService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
* 活动公开读接口(匿名可访问)。
*
* 路由:
* - GET /api/events 公开列表(published + archived)
* - GET /api/events/{id} 单条详情(含"感兴趣"统计 + 当前用户是否感兴趣)
*
* SaToken 白名单配置见 SaTokenConfigure.java。
* 单条接口里读取"当前用户"时用 StpUtil.isLogin() 短路——匿名用户时不报错,只是
* interested 字段返回 false。
*/
@RestController
@RequestMapping("/api/events")
public class EventController {

private final EventService eventService;

public EventController(EventService eventService) {
this.eventService = eventService;
}

@GetMapping
public ApiResponse<List<EventView>> list() {
List<Event> events = eventService.listPublic();
List<EventView> views = events.stream()
.map(e -> EventView.from(e, eventService.countInterest(e.id())))
Comment on lines +42 to +43

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

公开列表这里对每个活动单独调用 countInterest,会产生 N+1 次查询(活动越多越慢)。建议在 repository 层一次性聚合 interest_count(例如 LEFT JOIN + GROUP BY 或者批量 IN 查询后在内存合并),让列表接口保持单次/常数次查询。

Suggested change
List<EventView> views = events.stream()
.map(e -> EventView.from(e, eventService.countInterest(e.id())))
List<Long> eventIds = events.stream()
.map(Event::id)
.toList();
Map<Long, Long> interestCounts = eventService.countInterestByEventIds(eventIds);
List<EventView> views = events.stream()
.map(e -> EventView.from(e, interestCounts.getOrDefault(e.id(), 0L)))

Copilot uses AI. Check for mistakes.
.toList();
return ApiResponse.ok(views);
}

@GetMapping("/{id}")
public ApiResponse<Map<String, Object>> detail(@PathVariable Long id) {
Optional<Event> maybe = eventService.findById(id);
if (maybe.isEmpty()) return new ApiResponse<>(false, "活动不存在", null);
Event event = maybe.get();
// draft 状态不对外公开(即使直接访问 /api/events/{id} 也返回 404 语义)
if ("draft".equals(event.status())) {
return new ApiResponse<>(false, "活动不存在", null);
}

long interestCount = eventService.countInterest(id);
boolean interested = false;
if (StpUtil.isLogin()) {
long uid = StpUtil.getLoginIdAsLong();
interested = eventService.isInterested(id, uid);
}

Map<String, Object> body = new HashMap<>();
body.put("event", EventView.from(event, interestCount));
body.put("interested", interested);
return ApiResponse.ok(body);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.involutionhell.backend.events.controller;

import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.stp.StpUtil;
import com.involutionhell.backend.common.api.ApiResponse;
import com.involutionhell.backend.events.service.EventService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
* 活动"感兴趣"开关(登录用户)。
*
* 路由:
* - POST /api/events/{id}/interest 标记感兴趣(幂等)
* - DELETE /api/events/{id}/interest 取消感兴趣(幂等)
*
* 返回结构统一包含 count + interested,前端点完按钮可以直接用返回值刷新 UI,
* 不用再额外调一次 /api/events/{id} 详情接口。
*/
@RestController
@RequestMapping("/api/events")
public class EventInterestController {

private final EventService eventService;

public EventInterestController(EventService eventService) {
this.eventService = eventService;
}

@SaCheckLogin
@PostMapping("/{id}/interest")
public ApiResponse<Map<String, Object>> mark(@PathVariable Long id) {
long uid = StpUtil.getLoginIdAsLong();
if (eventService.findById(id).isEmpty()) {
return new ApiResponse<>(false, "活动不存在", null);
}
eventService.markInterested(id, uid);
return ApiResponse.ok(Map.of(
"count", eventService.countInterest(id),
"interested", true
));
}

@SaCheckLogin
@DeleteMapping("/{id}/interest")
public ApiResponse<Map<String, Object>> unmark(@PathVariable Long id) {
long uid = StpUtil.getLoginIdAsLong();
if (eventService.findById(id).isEmpty()) {
return new ApiResponse<>(false, "活动不存在", null);
}
eventService.unmarkInterested(id, uid);
return ApiResponse.ok(Map.of(
"count", eventService.countInterest(id),
"interested", false
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.involutionhell.backend.events.dto;

import com.involutionhell.backend.events.model.Event;
import com.involutionhell.backend.events.model.Event.Speaker;

import java.time.Instant;
import java.util.List;

/**
* Admin 创建 / 更新活动的入参。
*
* 不直接用 Event 当入参是为了:
* - 屏蔽客户端传 id / createdAt / updatedAt(这些必须由后端控制)
* - 入参 tags 是 List<String> 更符合前端习惯;出参依然给 List<String>;DB 层转成逗号分隔
* - 允许字段部分缺省(全都 String 可空),避免前端表单空字段触发 Jackson 报错
*/
public record EventRequest(
String title,
String description,
String coverUrl,
Instant startTime,
Instant endTime,
String discordLink,
String playbackUrl,
List<Speaker> speakers,
List<String> tags,
String status
) {
/** 转换成 domain Event。id / timestamps / organizerId 由 Controller 层填。 */
public Event toEvent(Long id, Long organizerId, Instant createdAt, Instant updatedAt) {
return new Event(
id,
title != null ? title.trim() : "",
description != null ? description : "",
emptyToNull(coverUrl),
startTime,
endTime,
emptyToNull(discordLink),
emptyToNull(playbackUrl),
speakers != null ? speakers : List.of(),
joinTags(tags),
status != null && !status.isBlank() ? status : "draft",
organizerId,
createdAt,
updatedAt
);
}

private static String emptyToNull(String s) {
return s != null && !s.isBlank() ? s.trim() : null;
}

private static String joinTags(List<String> tags) {
if (tags == null || tags.isEmpty()) return "";
return String.join(",", tags.stream().map(String::trim).filter(s -> !s.isEmpty()).toList());

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

joinTags 对 tags 元素直接 String::trim;如果客户端传入的数组里包含 null(JSON 允许),这里会 NPE 导致 500。建议在 stream 中先过滤 null(或在 validate() 中拒绝 null/blank tag),确保健壮性。

Suggested change
return String.join(",", tags.stream().map(String::trim).filter(s -> !s.isEmpty()).toList());
return String.join(",", tags.stream()
.filter(tag -> tag != null)
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList());

Copilot uses AI. Check for mistakes.
}
}
Loading