feat(events): 活动管理后端 — events 表 + 公开读 API + 管理员 CRUD + 感兴趣#9
feat(events): 活动管理后端 — events 表 + 公开读 API + 管理员 CRUD + 感兴趣#9longsizhuo merged 2 commits intomainfrom
Conversation
前情: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
目的:把前端 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 一个入口)
There was a problem hiding this comment.
Pull request overview
为活动管理提供后端支持:新增 events / event_interests 表结构与种子数据,并提供公开读 API、管理员 CRUD,以及登录用户“感兴趣”开关接口,替代前端 data/event.json 硬编码维护方式。
Changes:
- 新增数据库结构:
events与event_interests(含索引)并在schema.sql写入 4 条活动种子 - 新增活动模块后端实现:JDBC Repository + Service + 公共/管理/兴趣 Controller + DTO/Model
- 更新 Sa-Token 白名单放行公开活动读接口;更新本地运行的 AI 默认配置文档与 compose 默认值
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/resources/test-schema.sql | 为 H2 测试库补充 events 相关表结构 |
| src/main/resources/schema.sql | 生产库新增 events/event_interests 表、索引、维护者 seed、活动 seed |
| src/main/java/com/involutionhell/backend/events/service/EventService.java | 新增 Event 业务薄封装,统一对外提供读写与兴趣统计 |
| src/main/java/com/involutionhell/backend/events/repository/JdbcEventRepository.java | 新增基于 JdbcTemplate 的 events CRUD 与 JSON speakers 序列化/反序列化 |
| src/main/java/com/involutionhell/backend/events/repository/EventRepository.java | 定义 events 数据访问接口(public/admin 读维度 + 写接口) |
| src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java | 新增 event_interests 的 add/remove/count/isInterested 等操作 |
| src/main/java/com/involutionhell/backend/events/model/Event.java | 新增 Event 领域模型与 ongoing/past 判定 |
| src/main/java/com/involutionhell/backend/events/dto/EventView.java | 新增对外返回 DTO(含 interestCount/ongoing/past 与 tags 拆分) |
| src/main/java/com/involutionhell/backend/events/dto/EventRequest.java | 新增管理员创建/更新入参 DTO 与 tags join/字段清洗 |
| src/main/java/com/involutionhell/backend/events/controller/EventInterestController.java | 新增登录用户“感兴趣”开关 API(幂等) |
| src/main/java/com/involutionhell/backend/events/controller/EventController.java | 新增公开 events 列表与详情接口(含 interested 标记) |
| src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java | 新增管理员 events CRUD 接口与手写校验 |
| src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java | 白名单放行 /api/events 与 /api/events/* 匿名访问 |
| docker-compose.yml | 调整默认 AI 模型服务地址/模型名与注释说明 |
| .env.example | 更新 AI 环境变量示例与说明(OpenAI-compatible) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| List<Event> events = eventService.listAllForAdmin(); | ||
| List<EventView> views = events.stream() | ||
| .map(e -> EventView.from(e, eventService.countInterest(e.id()))) | ||
| .toList(); |
There was a problem hiding this comment.
管理员列表同样对每个活动逐条 countInterest,会造成 N+1 查询;后台活动数量增长后会明显拖慢。建议改为一次性聚合 interestCount(JOIN/GROUP BY 或批量计数)再构造 EventView。
|
|
||
| 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()); |
There was a problem hiding this comment.
joinTags 对 tags 元素直接 String::trim;如果客户端传入的数组里包含 null(JSON 允许),这里会 NPE 导致 500。建议在 stream 中先过滤 null(或在 validate() 中拒绝 null/blank tag),确保健壮性。
| 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()); |
| event_id BIGINT NOT NULL, | ||
| user_id BIGINT NOT NULL, | ||
| created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| PRIMARY KEY (event_id, user_id) |
There was a problem hiding this comment.
测试用 H2 schema 里 event_interests 没有外键(也就没有 ON DELETE CASCADE)。这会让测试环境无法覆盖“删 events 级联清理 interest”这一关键行为,且可能允许插入不存在的 event/user 产生脏数据。建议在 H2 schema 中补齐对 events(id) / user_accounts(id) 的外键并加 ON DELETE CASCADE,与生产 schema 行为对齐。
| PRIMARY KEY (event_id, user_id) | |
| PRIMARY KEY (event_id, user_id), | |
| FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, | |
| FOREIGN KEY (user_id) REFERENCES user_accounts(id) ON DELETE CASCADE |
| @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); |
There was a problem hiding this comment.
新增了一整套 Events 的公开读、管理员 CRUD 与 interest 开关,但当前测试目录里已有大量基于 MockMvc 的集成测试覆盖鉴权与业务错误分支;这里缺少对应的集成测试会让权限白名单(/api/events)、admin 角色校验(/api/admin/events)以及 interest 幂等行为缺乏回归保障。建议补充类似 UserCenterControllerIntegrationTests 的 MockMvc 测试用例。
| 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; |
There was a problem hiding this comment.
这里的种子 INSERT 用了 ON CONFLICT DO NOTHING,但 events 表当前只有主键 id 且该 INSERT 没有显式写入 id,因此正常情况下不会产生 conflict——当 schema.sql 在本地用 SPRING_SQL_INIT_MODE=always 重复执行时会不断插入重复活动。建议改成真正幂等的写法(例如给 seed 写固定 id 并 ON CONFLICT (id) DO NOTHING,或基于 title 做 WHERE NOT EXISTS/唯一约束再 upsert)。
| 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; | |
| SELECT seed.title, | |
| seed.description, | |
| seed.cover_url, | |
| seed.discord_link, | |
| seed.playback_url, | |
| seed.tags, | |
| seed.status | |
| FROM ( | |
| 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') | |
| ) AS seed(title, description, cover_url, discord_link, playback_url, tags, status) | |
| WHERE NOT EXISTS ( | |
| SELECT 1 | |
| FROM events e | |
| WHERE e.title = seed.title | |
| ); |
| -- 站点维护者(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'; |
There was a problem hiding this comment.
这里用 username='longsizhuo'/'Mira190'/'Crokily' 插入 admin 账号,但当前 AuthService 的 GitHub OAuth 用户名规则是 "github_{githubId}"(见 AuthService.loginByGithub),因此这些 seed 不会命中真实维护者账号,导致维护者登录后仍非 admin。建议改为按 github_id(或对应的 github_{id} username)做幂等角色升级,避免插入一批无法使用的本地 admin 账号。
| -- 站点维护者(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'; | |
| -- 站点维护者:admin 权限不能按 GitHub handle 直接 seed 到 username。 | |
| -- 当前 AuthService.loginByGithub 创建的本地用户名规则是 "github_{githubId}", | |
| -- 因此像 'longsizhuo' / 'Mira190' / 'Crokily' 这样的 username 不会命中真实 OAuth 账号, | |
| -- 反而会插入一批无法被维护者实际使用的本地 admin 账号。 | |
| -- | |
| -- 正确做法应是在已知维护者 github_id 后,按 github_id(或对应的 github_{id} username) | |
| -- 对真实 OAuth 用户做幂等角色升级;这里不再插入错误的本地维护者账号 seed。 |
| List<EventView> views = events.stream() | ||
| .map(e -> EventView.from(e, eventService.countInterest(e.id()))) |
There was a problem hiding this comment.
公开列表这里对每个活动单独调用 countInterest,会产生 N+1 次查询(活动越多越慢)。建议在 repository 层一次性聚合 interest_count(例如 LEFT JOIN + GROUP BY 或者批量 IN 查询后在内存合并),让列表接口保持单次/常数次查询。
| 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))) |
…hema
修复 5 个 Copilot 提出的问题:
1) EventInterestRepository 新增 countByEventIds 批量聚合,EventService 暴露
countInterestByEventIds;EventController 和 EventAdminController 列表接口
都改用一次 GROUP BY 拿全量 interestCount,避免每条活动单独 COUNT 的 N+1。
2) EventRequest.joinTags 先过滤 null 元素再 trim,避免 JSON 客户端在 tags
数组里放 null 时 NPE 导致 500。
3) test-schema.sql 的 event_interests 补齐对 events(id) / user_accounts(id) 的
外键与 ON DELETE CASCADE,和生产 schema 行为对齐;否则 H2 测试无法覆盖
"删 event 级联清 interest" 这条关键路径。
4) schema.sql 的 events seed 原先用 ON CONFLICT DO NOTHING 但 INSERT 不写 id,
正常根本不会产生 conflict,等于非幂等。改成 SELECT ... WHERE NOT EXISTS(title)
的 upsert 风格,重跑 schema 不会再插重复活动。
5) schema.sql 原先的 admin seed 按 GitHub handle(longsizhuo / Mira190 /
Crokily)插 user_accounts,但 AuthService.loginByGithub 实际创建的 username
是 "github_{githubId}" 格式,seed 出来的是三个永远没人用的死账号。删掉错误
seed,改用注释指引:首次 OAuth 登录后按 github_id 执行一次 UPDATE 升
roles,避免插错数据。
修两条独立但叠加的生产部署 bug:
1) docker-compose.yml 的 environment 缺 AUTH_URL 透传。
application.properties 里 justauth redirect-uri 是
${AUTH_URL:http://localhost:3000}/api/auth/callback/github,
compose 不显式透传时 .env 的 AUTH_URL=https://involutionhell.com
进不到容器,生产 /oauth/render/github 302 的 redirect_uri
变成 localhost,跳 GitHub 必报 redirect_uri_mismatch。
docker inspect 查到运行中容器 AUTH_URL=http://localhost:3010
是老容器手工 docker run 遗留。
2) deploy.yml 的 docker compose up -d 没加 --force-recreate。
CI 的 docker build 会把 :latest 标签指向新 sha,但 compose 看
image 名没变就不重建容器,:latest 指向新 sha 而运行中容器仍
绑死老 sha —— PR #9 / #10 的 SaToken /api/events 白名单、
events controller 全都躺在新镜像里没生效,/api/events 一直 401。
合起来就是"部署全 success 但生产没变化"的伪成功。
后续 merge 这个 PR 触发 deploy workflow 后,验证:
curl -I https://api.involutionhell.com/oauth/render/github | grep -i location
# → redirect_uri=https%3A%2F%2Finvolutionhell.com%2Fapi%2Fauth%2Fcallback%2Fgithub
curl https://api.involutionhell.com/api/events
# → {"success":true,"data":[...]}
⚠️ GitHub OAuth App 的 Authorization callback URLs 需要手动加
https://involutionhell.com/api/auth/callback/github(若还没加)。
修两条独立但叠加的生产部署 bug:
1) docker-compose.yml 的 environment 缺 AUTH_URL 透传。
application.properties 里 justauth redirect-uri 是
${AUTH_URL:http://localhost:3000}/api/auth/callback/github,
compose 不显式透传时 .env 的 AUTH_URL=https://involutionhell.com
进不到容器,生产 /oauth/render/github 302 的 redirect_uri
变成 localhost,跳 GitHub 必报 redirect_uri_mismatch。
docker inspect 查到运行中容器 AUTH_URL=http://localhost:3010
是老容器手工 docker run 遗留。
2) deploy.yml 的 docker compose up -d 没加 --force-recreate。
CI 的 docker build 会把 :latest 标签指向新 sha,但 compose 看
image 名没变就不重建容器,:latest 指向新 sha 而运行中容器仍
绑死老 sha —— PR #9 / #10 的 SaToken /api/events 白名单、
events controller 全都躺在新镜像里没生效,/api/events 一直 401。
合起来就是"部署全 success 但生产没变化"的伪成功。
后续 merge 这个 PR 触发 deploy workflow 后,验证:
curl -I https://api.involutionhell.com/oauth/render/github | grep -i location
# → redirect_uri=https%3A%2F%2Finvolutionhell.com%2Fapi%2Fauth%2Fcallback%2Fgithub
curl https://api.involutionhell.com/api/events
# → {"success":true,"data":[...]}
⚠️ GitHub OAuth App 的 Authorization callback URLs 需要手动加
https://involutionhell.com/api/auth/callback/github(若还没加)。
* fix(deploy): AUTH_URL 透传 + 强制重建容器
修两条独立但叠加的生产部署 bug:
1) docker-compose.yml 的 environment 缺 AUTH_URL 透传。
application.properties 里 justauth redirect-uri 是
${AUTH_URL:http://localhost:3000}/api/auth/callback/github,
compose 不显式透传时 .env 的 AUTH_URL=https://involutionhell.com
进不到容器,生产 /oauth/render/github 302 的 redirect_uri
变成 localhost,跳 GitHub 必报 redirect_uri_mismatch。
docker inspect 查到运行中容器 AUTH_URL=http://localhost:3010
是老容器手工 docker run 遗留。
2) deploy.yml 的 docker compose up -d 没加 --force-recreate。
CI 的 docker build 会把 :latest 标签指向新 sha,但 compose 看
image 名没变就不重建容器,:latest 指向新 sha 而运行中容器仍
绑死老 sha —— PR #9 / #10 的 SaToken /api/events 白名单、
events controller 全都躺在新镜像里没生效,/api/events 一直 401。
合起来就是"部署全 success 但生产没变化"的伪成功。
后续 merge 这个 PR 触发 deploy workflow 后,验证:
curl -I https://api.involutionhell.com/oauth/render/github | grep -i location
# → redirect_uri=https%3A%2F%2Finvolutionhell.com%2Fapi%2Fauth%2Fcallback%2Fgithub
curl https://api.involutionhell.com/api/events
# → {"success":true,"data":[...]}
⚠️ GitHub OAuth App 的 Authorization callback URLs 需要手动加
https://involutionhell.com/api/auth/callback/github(若还没加)。
* Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* fix(deploy): 回滚路径也加 --force-recreate + --no-deps
CR 指出:回滚分支把 rollback tag 覆盖回 latest 后 image 名依旧是 :latest,
compose 同样会判断"无需重建",导致回滚等于没回(容器还绑着失败那版的 sha)。
- --force-recreate 保证 rollback tag 切换后容器真的重建
- --no-deps 限定只重建 backend 服务,不碰 postgres 等依赖,
避免部署失败同时顺带重启 DB 扩大爆炸半径
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
配套前端 PR(同分支名 feat/events-management):https://github.com/InvolutionHell/involutionhell/tree/feat/events-management
背景
前端 `data/event.json` 里写死 4 条活动,新增 / 改时间都要改代码发版。用户(站点 owner)要求:管理员在自己个人主页点"管理员界面"进后台自助维护活动,不需要每次改代码。
Schema
```sql
events (id, title, description, cover_url, start_time, end_time,
discord_link, playback_url, speakers JSONB, tags TEXT,
status (draft|published|archived|cancelled),
organizer_id FK → user_accounts.id, created_at, updated_at)
event_interests (event_id FK CASCADE, user_id FK CASCADE, created_at, PK composite)
```
种子:把 `event.json` 4 条活动迁进来;longsizhuo / Mira190 / Crokily 三个维护者用 `ON CONFLICT DO UPDATE` 幂等升 admin role,不清洗已有用户其他字段。
API
公开(SaToken 白名单 `/api/events` + `/api/events/*`):
登录(`@SaCheckLogin`):
管理员(`@SaCheckRole("admin")` 类级注解,匿名 401 / 非 admin 403):
设计取舍
刻意不做的
本地 E2E
```
GET /api/events → 4 条 seeded 数据
admin 登录 → CRUD /api/admin/events → 全绿
alice(非 admin) → /api/admin/events → "拒绝访问: 缺少角色 [admin]"
alice → POST /api/events/4/interest → {count:1, interested:true}
```