-
Notifications
You must be signed in to change notification settings - Fork 4
feat(events): 活动管理后端 — events 表 + 公开读 API + 管理员 CRUD + 感兴趣 #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| return ApiResponse.ok(views); | ||
|
Comment on lines
+46
to
+52
|
||
| } | ||
|
|
||
| @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
|
||||||||||||||||||
| 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))) |
| 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()); | ||||||||||||||
|
||||||||||||||
| 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()); |
There was a problem hiding this comment.
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。