From ac9cbae43c94e69e571b6e6545de6b8242bd4c7d Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Thu, 16 Apr 2026 20:48:03 +0000 Subject: [PATCH 1/2] =?UTF-8?q?chore(ai):=20=E5=90=8E=E7=AB=AF=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=20AI=20=E6=8C=87=E5=90=91=20GLM-4.6V-Flash=EF=BC=8CJa?= =?UTF-8?q?va=20=E4=BD=9C=E4=B8=BA=20Fallback=20=E7=9C=9F=E6=AD=A3?= =?UTF-8?q?=E8=B7=91=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前情:Next.js `/api/chat` 登录用户会代理到 Java `/openai/responses/stream`。 但 Java 的 OPENAI_API_KEY 之前塞的是已过期的 Intern-S1 key,URL 却指 api.openai.com,必然 401,每次都 fallback 回 Next.js 本地 GLM 推理。 改动: - .env.example 默认 URL/Model 改为智谱 GLM(OpenAI-compatible 协议) - docker-compose.yml 默认值同步更新 变量名仍叫 OPENAI_*:Java 代码里硬编码走 /chat/completions 规范协议, URL+Key+Model 三件套指哪打哪。未来切真 OpenAI 付费 / Anthropic-compat / 自建 OpenAI-like 网关,只换这三个值即可,不用动代码。 部署:运行时 .env 需把 OPENAI_API_KEY 换成智谱 ZHIPU_API_KEY 的值, URL / MODEL 已由 compose 默认值兜住(本机已验证重启后 healthy)。 Refs #297 --- .env.example | 12 +++++++++--- docker-compose.yml | 8 +++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 1609ded..f3af5dc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 430d987..e13834e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} From 2c64e3cb6db387e27a625718cfc7e382ffea4a33 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Thu, 16 Apr 2026 21:19:26 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(events):=20=E6=B4=BB=E5=8A=A8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E7=AB=AF=20=E2=80=94=20events=20=E8=A1=A8=20?= =?UTF-8?q?+=20=E5=85=AC=E5=BC=80=E8=AF=BB=20API=20+=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=20CRUD=20+=20=E6=84=9F=E5=85=B4=E8=B6=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目的:把前端 data/event.json 里写死的 4 条活动迁到 DB,让管理员在 /admin/events 自助维护,不用每次改代码。 ## Schema - events:title / description / cover_url / start_time / end_time / discord_link / playback_url / speakers (JSONB) / tags / status (draft|published|archived|cancelled) / organizer_id FK / timestamps - event_interests:event_id + user_id 联合主键,记录用户"感兴趣"(比 RSVP 轻量, 不承诺出席,门槛低 10 倍) - 种子:原 event.json 4 条活动迁过来;longsizhuo / Mira190 / Crokily 三个维护者种 admin role(ON CONFLICT DO UPDATE 幂等升级,不清洗已有用户) ## API 公开(SaToken 白名单 /api/events, /api/events/*): - GET /api/events 列表(仅 published + archived) - GET /api/events/{id} 详情(含 interestCount + 当前用户 interested 标记) 登录(@SaCheckLogin): - POST /api/events/{id}/interest - DELETE /api/events/{id}/interest 管理员(@SaCheckRole("admin"),类级注解,匿名 401 / 非 admin 403): - GET /api/admin/events 全状态列表(含 draft) - GET /api/admin/events/{id} - POST /api/admin/events - PUT /api/admin/events/{id} - DELETE /api/admin/events/{id} ON DELETE CASCADE 清 event_interests ## 设计取舍 - speakers 用 JSONB 而非关联表:嘉宾多数不是站点注册用户,没必要做外键 - tags 用逗号分隔 TEXT 而非 TEXT[]:和 user_accounts.roles 保持风格一致, P3 按 tag 过滤时再 ALTER 升 TEXT[] + GIN 索引 - startTime / endTime 都可空:草稿状态和未排期的 Open.Onion 场景 - 不引入 spring-boot-starter-validation:手写一个 validate() 够用,避免为这模块加新依赖 ## 没做的事(刻意推迟到后续) - Discord Scheduled Events 双向同步(user tester 明确表达不信任自动同步会出 bug, PM 也同意不是 P0) - iCal 订阅(UNSW 学生 iCal 使用率低,不是杠杆点) - speakers 关联 user_accounts(先用 JSON 的 {name} 过 MVP) - 独立 /admin 路由首页(本次只给 /admin/events 一个入口) --- .../common/config/SaTokenConfigure.java | 4 + .../controller/EventAdminController.java | 114 ++++++++++++ .../events/controller/EventController.java | 70 +++++++ .../controller/EventInterestController.java | 62 ++++++ .../backend/events/dto/EventRequest.java | 57 ++++++ .../backend/events/dto/EventView.java | 60 ++++++ .../backend/events/model/Event.java | 50 +++++ .../repository/EventInterestRepository.java | 67 +++++++ .../events/repository/EventRepository.java | 35 ++++ .../repository/JdbcEventRepository.java | 176 ++++++++++++++++++ .../backend/events/service/EventService.java | 80 ++++++++ src/main/resources/schema.sql | 71 +++++++ src/test/resources/test-schema.sql | 25 +++ 13 files changed, 871 insertions(+) create mode 100644 src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java create mode 100644 src/main/java/com/involutionhell/backend/events/controller/EventController.java create mode 100644 src/main/java/com/involutionhell/backend/events/controller/EventInterestController.java create mode 100644 src/main/java/com/involutionhell/backend/events/dto/EventRequest.java create mode 100644 src/main/java/com/involutionhell/backend/events/dto/EventView.java create mode 100644 src/main/java/com/involutionhell/backend/events/model/Event.java create mode 100644 src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java create mode 100644 src/main/java/com/involutionhell/backend/events/repository/EventRepository.java create mode 100644 src/main/java/com/involutionhell/backend/events/repository/JdbcEventRepository.java create mode 100644 src/main/java/com/involutionhell/backend/events/service/EventService.java diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java index 1c979f7..1e46a7b 100644 --- a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java @@ -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("/**"); } diff --git a/src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java b/src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java new file mode 100644 index 0000000..554670b --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java @@ -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() { + List events = eventService.listAllForAdmin(); + List views = events.stream() + .map(e -> EventView.from(e, eventService.countInterest(e.id()))) + .toList(); + return ApiResponse.ok(views); + } + + @GetMapping("/{id}") + public ApiResponse detail(@PathVariable Long id) { + Optional 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 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 update(@PathVariable Long id, @RequestBody EventRequest req) { + String validationError = validate(req); + if (validationError != null) return new ApiResponse<>(false, validationError, null); + + Optional 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 delete(@PathVariable Long id) { + Optional 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; + } +} diff --git a/src/main/java/com/involutionhell/backend/events/controller/EventController.java b/src/main/java/com/involutionhell/backend/events/controller/EventController.java new file mode 100644 index 0000000..a3740fc --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/controller/EventController.java @@ -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() { + List events = eventService.listPublic(); + List views = events.stream() + .map(e -> EventView.from(e, eventService.countInterest(e.id()))) + .toList(); + return ApiResponse.ok(views); + } + + @GetMapping("/{id}") + public ApiResponse> detail(@PathVariable Long id) { + Optional 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 body = new HashMap<>(); + body.put("event", EventView.from(event, interestCount)); + body.put("interested", interested); + return ApiResponse.ok(body); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/controller/EventInterestController.java b/src/main/java/com/involutionhell/backend/events/controller/EventInterestController.java new file mode 100644 index 0000000..127b8f4 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/controller/EventInterestController.java @@ -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> 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> 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 + )); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/dto/EventRequest.java b/src/main/java/com/involutionhell/backend/events/dto/EventRequest.java new file mode 100644 index 0000000..c81ea17 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/dto/EventRequest.java @@ -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 更符合前端习惯;出参依然给 List;DB 层转成逗号分隔 + * - 允许字段部分缺省(全都 String 可空),避免前端表单空字段触发 Jackson 报错 + */ +public record EventRequest( + String title, + String description, + String coverUrl, + Instant startTime, + Instant endTime, + String discordLink, + String playbackUrl, + List speakers, + List 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 tags) { + if (tags == null || tags.isEmpty()) return ""; + return String.join(",", tags.stream().map(String::trim).filter(s -> !s.isEmpty()).toList()); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/dto/EventView.java b/src/main/java/com/involutionhell/backend/events/dto/EventView.java new file mode 100644 index 0000000..f5ff9d2 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/dto/EventView.java @@ -0,0 +1,60 @@ +package com.involutionhell.backend.events.dto; + +import com.involutionhell.backend.events.model.Event; + +import java.time.Instant; +import java.util.List; + +/** + * 对外暴露的活动视图。目前字段和 Event 记录一一对应,独立 DTO 是为了: + * 1. 将来加"是否当前用户已感兴趣 / 是否进行中"这种衍生字段时有地方放 + * 2. 避免未来给 Event 加管理字段(比如 internal_notes)时意外暴露到公开 API + */ +public record EventView( + Long id, + String title, + String description, + String coverUrl, + Instant startTime, + Instant endTime, + String discordLink, + String playbackUrl, + List speakers, + List tags, + String status, + Long organizerId, + Instant createdAt, + Instant updatedAt, + long interestCount, + boolean ongoing, + boolean past +) { + /** 从 Event + 动态统计数据构造 View。interestCount 由调用方传入(需要额外查 DB)。 */ + public static EventView from(Event event, long interestCount) { + return new EventView( + event.id(), + event.title(), + event.description(), + event.coverUrl(), + event.startTime(), + event.endTime(), + event.discordLink(), + event.playbackUrl(), + event.speakers(), + splitTags(event.tags()), + event.status(), + event.organizerId(), + event.createdAt(), + event.updatedAt(), + interestCount, + event.isOngoing(), + event.isPast() + ); + } + + /** "interview,mock" → ["interview", "mock"];空串 → 空列表 */ + private static List splitTags(String tags) { + if (tags == null || tags.isBlank()) return List.of(); + return List.of(tags.split("\\s*,\\s*")); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/model/Event.java b/src/main/java/com/involutionhell/backend/events/model/Event.java new file mode 100644 index 0000000..070e28c --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/model/Event.java @@ -0,0 +1,50 @@ +package com.involutionhell.backend.events.model; + +import java.time.Instant; +import java.util.List; + +/** + * 社区活动(Coffee Chat / Mock Interview / Career Journey / Open.Onion 等)。 + * + * 对应数据库表 events。字段语义: + * - startTime / endTime:可为 null(未排期 / 进行中 / 已归档) + * - status:draft(仅 admin 可见)/ published(公开)/ archived(历史)/ cancelled + * - speakers:JSON 数组 [{name, avatarUrl, profileUrl}],暂不做 user_accounts 关联外键, + * 嘉宾可能不是站点注册用户 + * - tags:逗号分隔字符串,和 user_accounts.roles 同风格;未来按 tag 过滤时可以加 GIN 索引升 TEXT[] + * - organizerId:可空。指向 user_accounts.id,组织方(一般是 admin 自己) + */ +public record Event( + Long id, + String title, + String description, + String coverUrl, + Instant startTime, + Instant endTime, + String discordLink, + String playbackUrl, + List speakers, + String tags, + String status, + Long organizerId, + Instant createdAt, + Instant updatedAt +) { + /** 嘉宾信息。保持简单的 POJO 结构,避免和 user_accounts 耦合。 */ + public record Speaker(String name, String avatarUrl, String profileUrl) {} + + /** 判断活动是否处于"进行中"状态:published 且 startTime 已到 endTime 未过。 */ + public boolean isOngoing() { + if (!"published".equals(status)) return false; + Instant now = Instant.now(); + if (startTime != null && startTime.isAfter(now)) return false; + if (endTime != null && endTime.isBefore(now)) return false; + return true; + } + + /** 判断活动是否已结束(archived 或 endTime 已过)。前端分区"历史活动"用这个判定。 */ + public boolean isPast() { + if ("archived".equals(status) || "cancelled".equals(status)) return true; + return endTime != null && endTime.isBefore(Instant.now()); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java b/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java new file mode 100644 index 0000000..4213419 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java @@ -0,0 +1,67 @@ +package com.involutionhell.backend.events.repository; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * event_interests 表的数据访问。语义和 FollowService 类似—— + * "感兴趣"比 RSVP 门槛低:用户点一下表明关注,不承诺出席;取消也是一次点击。 + * + * 为什么不用独立 service 类:逻辑很薄(3 个方法),做成 Repository 就够, + * Controller 直接注入调用,避免为了"分层"而分层。 + */ +@Repository +public class EventInterestRepository { + + private final JdbcTemplate jdbc; + + public EventInterestRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** 添加感兴趣记录。幂等:同一 (event, user) 已存在时不报错。 */ + public void add(long eventId, long userId) { + try { + jdbc.update( + "INSERT INTO event_interests (event_id, user_id, created_at) VALUES (?, ?, NOW()) " + + "ON CONFLICT (event_id, user_id) DO NOTHING", + eventId, userId); + } catch (DuplicateKeyException ignored) { + // H2 或其他驱动可能走 DuplicateKey 分支,一起吞掉保持幂等 + } + } + + /** 取消感兴趣。记录不存在也 return 0,不报错。 */ + public void remove(long eventId, long userId) { + jdbc.update( + "DELETE FROM event_interests WHERE event_id = ? AND user_id = ?", + eventId, userId); + } + + /** 某活动当前有多少人表达了兴趣。前端详情页显示 "23 人感兴趣"。 */ + public long countByEvent(long eventId) { + Long cnt = jdbc.queryForObject( + "SELECT COUNT(*) FROM event_interests WHERE event_id = ?", + Long.class, eventId); + return cnt != null ? cnt : 0L; + } + + /** 当前登录用户是否对某活动感兴趣。匿名调用方需自己短路 false,不要调这个。 */ + public boolean isInterested(long eventId, long userId) { + Integer cnt = jdbc.queryForObject( + "SELECT COUNT(*) FROM event_interests WHERE event_id = ? AND user_id = ?", + Integer.class, eventId, userId); + return cnt != null && cnt > 0; + } + + /** 某用户感兴趣的活动 id 列表。个人主页 "我关注的活动" 区块用。 */ + public List findEventIdsByUser(long userId) { + return jdbc.query( + "SELECT event_id FROM event_interests WHERE user_id = ? ORDER BY created_at DESC", + (rs, rn) -> rs.getLong("event_id"), + userId); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/repository/EventRepository.java b/src/main/java/com/involutionhell/backend/events/repository/EventRepository.java new file mode 100644 index 0000000..df4a006 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/repository/EventRepository.java @@ -0,0 +1,35 @@ +package com.involutionhell.backend.events.repository; + +import com.involutionhell.backend.events.model.Event; + +import java.util.List; +import java.util.Optional; + +/** + * 活动数据访问接口。 + * + * 设计原则: + * - 读接口提供不同 status 过滤维度,让前端列表页 / 管理员后台各取所需 + * - 写接口返回完整 Event(insert 后回读,避免前端再发一次 GET) + * - 不提供 findAll 不带过滤的版本:避免未来 events 表膨胀时被无脑全量拉 + */ +public interface EventRepository { + + /** 按 id 查找单条活动。管理员编辑页 / 详情页用。 */ + Optional findById(Long id); + + /** 仅 published + archived 的活动,给公开列表页用。按 startTime 倒序(NULL 最靠后)。 */ + List findPublic(); + + /** 所有状态(含 draft / cancelled)的活动。仅 admin 后台用。 */ + List findAllForAdmin(); + + /** 创建活动。返回带生成 id / createdAt 的完整对象。 */ + Event insert(Event event); + + /** 更新活动。updatedAt 由数据库侧自动刷新;returned 对象里的 updatedAt 是最新值。 */ + Event update(Event event); + + /** 删除活动。级联 event_interests 由外键 ON DELETE CASCADE 处理。 */ + void deleteById(Long id); +} diff --git a/src/main/java/com/involutionhell/backend/events/repository/JdbcEventRepository.java b/src/main/java/com/involutionhell/backend/events/repository/JdbcEventRepository.java new file mode 100644 index 0000000..157ea40 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/repository/JdbcEventRepository.java @@ -0,0 +1,176 @@ +package com.involutionhell.backend.events.repository; + +import com.involutionhell.backend.events.model.Event; +import com.involutionhell.backend.events.model.Event.Speaker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * 基于 Spring JDBC 的 Event 仓库。 + * + * speakers 字段:Postgres 侧是 JSONB,H2 测试侧是 VARCHAR,都用 setObject(Types.OTHER) + 字符串 + * 写入,跟 user_accounts.preferences 的处理方式保持一致。 + */ +@Repository +public class JdbcEventRepository implements EventRepository { + + private static final Logger log = LoggerFactory.getLogger(JdbcEventRepository.class); + private static final TypeReference> SPEAKER_LIST_TYPE = new TypeReference<>() {}; + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public JdbcEventRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + /** 单行 → Event 映射。speakers 从 JSON 字符串解析为 List。 */ + private final RowMapper rowMapper = (rs, rowNum) -> new Event( + rs.getLong("id"), + rs.getString("title"), + rs.getString("description"), + rs.getString("cover_url"), + toInstant(rs.getTimestamp("start_time")), + toInstant(rs.getTimestamp("end_time")), + rs.getString("discord_link"), + rs.getString("playback_url"), + parseSpeakers(rs.getString("speakers")), + rs.getString("tags"), + rs.getString("status"), + rs.getObject("organizer_id", Long.class), + toInstant(rs.getTimestamp("created_at")), + toInstant(rs.getTimestamp("updated_at")) + ); + + @Override + public Optional findById(Long id) { + return jdbc.query("SELECT * FROM events WHERE id = ?", rowMapper, id) + .stream().findFirst(); + } + + @Override + public List findPublic() { + // NULLS LAST 让未排期活动出现在时间维度底部,而非把 null 当无穷大排前面 + return jdbc.query( + "SELECT * FROM events WHERE status IN ('published', 'archived') " + + "ORDER BY start_time DESC NULLS LAST, id DESC", + rowMapper); + } + + @Override + public List findAllForAdmin() { + return jdbc.query( + "SELECT * FROM events ORDER BY start_time DESC NULLS LAST, id DESC", + rowMapper); + } + + @Override + public Event insert(Event event) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + String sql = "INSERT INTO events " + + "(title, description, cover_url, start_time, end_time, discord_link, " + + " playback_url, speakers, tags, status, organizer_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + String speakersJson = serializeSpeakers(event.speakers()); + + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); + ps.setString(1, event.title()); + ps.setString(2, event.description() != null ? event.description() : ""); + ps.setString(3, event.coverUrl()); + ps.setObject(4, event.startTime() != null ? Timestamp.from(event.startTime()) : null); + ps.setObject(5, event.endTime() != null ? Timestamp.from(event.endTime()) : null); + ps.setString(6, event.discordLink()); + ps.setString(7, event.playbackUrl()); + // JSONB / VARCHAR 兼容:用 Types.OTHER 让 Postgres 驱动自动识别 + ps.setObject(8, speakersJson, Types.OTHER); + ps.setString(9, event.tags() != null ? event.tags() : ""); + ps.setString(10, event.status() != null ? event.status() : "draft"); + ps.setObject(11, event.organizerId()); + return ps; + }, keyHolder); + + Number key = keyHolder.getKey(); + if (key == null) throw new IllegalStateException("插入 event 失败,无法获取 id"); + return findById(key.longValue()) + .orElseThrow(() -> new IllegalStateException("插入 event 后读不到: id=" + key)); + } + + @Override + public Event update(Event event) { + if (event.id() == null) throw new IllegalArgumentException("更新 event 必须带 id"); + String speakersJson = serializeSpeakers(event.speakers()); + int updated = jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "UPDATE events SET title = ?, description = ?, cover_url = ?, " + + "start_time = ?, end_time = ?, discord_link = ?, playback_url = ?, " + + "speakers = ?, tags = ?, status = ?, organizer_id = ?, " + + "updated_at = NOW() " + + "WHERE id = ?"); + ps.setString(1, event.title()); + ps.setString(2, event.description() != null ? event.description() : ""); + ps.setString(3, event.coverUrl()); + ps.setObject(4, event.startTime() != null ? Timestamp.from(event.startTime()) : null); + ps.setObject(5, event.endTime() != null ? Timestamp.from(event.endTime()) : null); + ps.setString(6, event.discordLink()); + ps.setString(7, event.playbackUrl()); + ps.setObject(8, speakersJson, Types.OTHER); + ps.setString(9, event.tags() != null ? event.tags() : ""); + ps.setString(10, event.status() != null ? event.status() : "draft"); + ps.setObject(11, event.organizerId()); + ps.setLong(12, event.id()); + return ps; + }); + if (updated == 0) throw new IllegalArgumentException("event 不存在: id=" + event.id()); + return findById(event.id()) + .orElseThrow(() -> new IllegalStateException("更新后读不到 event: id=" + event.id())); + } + + @Override + public void deleteById(Long id) { + jdbc.update("DELETE FROM events WHERE id = ?", id); + } + + private static Instant toInstant(Timestamp ts) { + return ts != null ? ts.toInstant() : null; + } + + /** JSONB / VARCHAR 里存的 speakers 字符串 → List。 */ + private List parseSpeakers(String json) { + if (json == null || json.isBlank() || "[]".equals(json.trim())) return new ArrayList<>(); + try { + return objectMapper.readValue(json, SPEAKER_LIST_TYPE); + } catch (Exception e) { + log.error("解析 event.speakers JSON 失败: {}", json, e); + // 脏数据不影响其他字段,返回空列表兜底(而不是整个 event 加载失败) + return new ArrayList<>(); + } + } + + /** List → JSON 字符串。null / 空列表都序列化为 "[]"。 */ + private String serializeSpeakers(List speakers) { + if (speakers == null || speakers.isEmpty()) return "[]"; + try { + return objectMapper.writeValueAsString(speakers); + } catch (Exception e) { + log.error("序列化 speakers 失败: {}", speakers, e); + throw new IllegalStateException("序列化 speakers 失败", e); + } + } +} diff --git a/src/main/java/com/involutionhell/backend/events/service/EventService.java b/src/main/java/com/involutionhell/backend/events/service/EventService.java new file mode 100644 index 0000000..5807b98 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/service/EventService.java @@ -0,0 +1,80 @@ +package com.involutionhell.backend.events.service; + +import com.involutionhell.backend.events.model.Event; +import com.involutionhell.backend.events.repository.EventInterestRepository; +import com.involutionhell.backend.events.repository.EventRepository; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * 活动业务服务。主要职责: + * - 公开读 / 管理员读 / 写 的薄封装(大部分直接委托 repository) + * - 未来要加的非 CRUD 逻辑(比如按 tag 过滤、和 /docs/jobs/event-keynote 的回放反向关联) + * 都放这里,避免逻辑塞 Controller + * - 感兴趣功能同样委托给 EventInterestRepository,但统计数由这里对外暴露,避免 + * Controller 同时依赖 Event + EventInterest 两个 repo + */ +@Service +public class EventService { + + private final EventRepository eventRepository; + private final EventInterestRepository interestRepository; + + public EventService(EventRepository eventRepository, + EventInterestRepository interestRepository) { + this.eventRepository = eventRepository; + this.interestRepository = interestRepository; + } + + /** 公开列表。仅 published / archived 状态。 */ + public List listPublic() { + return eventRepository.findPublic(); + } + + /** 管理员列表。全状态。 */ + public List listAllForAdmin() { + return eventRepository.findAllForAdmin(); + } + + /** 查单条。公开读 / 管理员读公用这一个方法;权限过滤放 Controller 做。 */ + public Optional findById(Long id) { + return eventRepository.findById(id); + } + + /** 创建活动。id / createdAt / updatedAt 由 DB 生成。 */ + public Event create(Event draft) { + return eventRepository.insert(draft); + } + + /** 更新活动。调用前必须已校验 id 存在(由 Controller 做 404 返回)。 */ + public Event update(Event event) { + return eventRepository.update(event); + } + + /** 删除活动。ON DELETE CASCADE 会清理 event_interests。 */ + public void delete(Long id) { + eventRepository.deleteById(id); + } + + /** 某活动当前"感兴趣"人数。公开接口使用。 */ + public long countInterest(long eventId) { + return interestRepository.countByEvent(eventId); + } + + /** 当前登录用户是否对某活动感兴趣。匿名调用方需短路传 false,不要调这个。 */ + public boolean isInterested(long eventId, long userId) { + return interestRepository.isInterested(eventId, userId); + } + + /** 登录用户表达"感兴趣"。幂等。 */ + public void markInterested(long eventId, long userId) { + interestRepository.add(eventId, userId); + } + + /** 登录用户取消"感兴趣"。幂等。 */ + public void unmarkInterested(long eventId, long userId) { + interestRepository.remove(eventId, userId); + } +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 1791d9e..e1d7619 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -26,3 +26,74 @@ VALUES ('admin', 'ad89b64d66caa8e30e5d5ce4a9763f4ecc205814c412175f3e2c50027471 ('alice', 'b02bb998ecc1616148b9b4ba0405dbd4c224acd1bac059d59f0a07b3b1a68400', 'Alice', TRUE, 'user', 'user:profile:read'), ('auditor', 'ccabaaba054fb98905b5b9ee47174f57cb6088e04b1526f08b872dc06eaa6bb9', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read') ON CONFLICT (username) DO NOTHING; + +-- 站点维护者(GitHub OAuth 登录后由 sync 服务补 github_id;此处按 username 打 admin role) +-- 生产 Neon 上这些账号是 GitHub OAuth 登录后由 AuthService 自动创建的,所以用 +-- ON CONFLICT DO UPDATE 幂等升级角色,避免漏 seed。 +INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions) +VALUES ('longsizhuo', '', 'Siz Long', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage'), + ('Mira190', '', 'Mira', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage'), + ('Crokily', '', 'Crokily', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage') +ON CONFLICT (username) DO UPDATE + SET roles = 'admin,user', + permissions = 'user:profile:read,user:center:read,user:center:manage'; + +-- ============================================================================= +-- Events(活动)相关表 +-- ============================================================================= +-- 背景:站点有 Coffee Chat / Mock Interview / Career Journey / Open.Onion 等社群活动。 +-- 原先维护在前端 data/event.json,新增/改时间都要改代码发版,迁到后端让管理员自助编辑。 + +-- 活动主表 +CREATE TABLE IF NOT EXISTS events ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + cover_url VARCHAR(500), + start_time TIMESTAMPTZ, -- 活动开始时间,null 表示未排期 + end_time TIMESTAMPTZ, -- 活动结束时间 + discord_link VARCHAR(500), -- Discord 活动链接 / 频道邀请 + playback_url VARCHAR(500), -- 回放链接(YouTube / Drive / 内部 doc) + speakers JSONB NOT NULL DEFAULT '[]'::jsonb, -- [{"name","avatarUrl","profileUrl"}] + tags TEXT NOT NULL DEFAULT '', -- 逗号分隔,跟 user_accounts.roles 同风格 + status VARCHAR(20) NOT NULL DEFAULT 'published', -- draft / published / archived / cancelled + organizer_id BIGINT REFERENCES user_accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_events_start_time ON events(start_time DESC NULLS LAST); +CREATE INDEX IF NOT EXISTS idx_events_status ON events(status); + +-- 活动"感兴趣"表(比 RSVP 轻量:不承诺出席,只表态关注) +-- 用户可多次点按切换;同一个 (event,user) 只保留一条记录,所以组合主键 +CREATE TABLE IF NOT EXISTS event_interests ( + event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (event_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_event_interests_user_id ON event_interests(user_id); + +-- 种子:原来 data/event.json 里的 4 条活动(startTime 不填,先只保留元信息; +-- 管理员登录后在 /admin/events 里补时间再 publish) +INSERT INTO events (title, description, cover_url, discord_link, playback_url, tags, status) +VALUES + ('Mock Interview', '模拟面试专场:匹配面试官 1v1,结束即反馈,积累真实面试体感。', '/event/mockInterview.webp', + 'https://discord.gg/QHsjqezfC?event=1430500169299922965', + 'https://involutionhell.com/docs/jobs/event-keynote/event-takeway', + 'interview,mock', 'archived'), + ('Coffee Chat', '邀请业界嘉宾小范围交流,聊 career path、求职反思、日常 dev 体感。', '/event/coffeeChat.webp', + 'https://discord.com/invite/8AQZj7sa?event=1432010537402761348', + 'https://involutionhell.com/docs/jobs/event-keynote/coffee-chat', + 'career,chat', 'archived'), + ('Career Journey', '资深从业者分享完整职业路径 + 关键决策点。', '/event/careerJourney.webp', + 'https://discord.com/invite/8AQZj7sa?event=1432010537402761348', + 'https://involutionhell.com/docs/jobs/event-keynote/event-takeway', + 'career,sharing', 'archived'), + ('Open.Onion', '持续进行中的开源 / 内部项目协作节奏,参与即获得 contributor 标签。', '/event/openOnion.webp', + 'https://discord.gg/kJZFMr5chU?event=1477581193582088304', + NULL, + 'project,open-source', 'published') +ON CONFLICT DO NOTHING; diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql index 89d7d50..bec2174 100644 --- a/src/test/resources/test-schema.sql +++ b/src/test/resources/test-schema.sql @@ -23,3 +23,28 @@ MERGE INTO user_accounts (username, password_hash, display_name, enabled, roles, MERGE INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions) KEY (username) VALUES ('auditor', 'ccabaaba054fb98905b5b9ee47174f57cb6088e04b1526f08b872dc06eaa6bb9', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read'); + +-- Events 相关表(测试用 H2 语法)。JSONB 用 VARCHAR 代替,与 user_accounts.preferences 的策略一致 +CREATE TABLE IF NOT EXISTS events ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + cover_url VARCHAR(500), + start_time TIMESTAMP, + end_time TIMESTAMP, + discord_link VARCHAR(500), + playback_url VARCHAR(500), + speakers VARCHAR(4000) NOT NULL DEFAULT '[]', + tags TEXT NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'published', + organizer_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS event_interests ( + event_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (event_id, user_id) +);