Skip to content

Commit f40e1e0

Browse files
committed
chore(security): 收紧用户中心 / chat / 密码 / compose 多处姿态
按 SECURITY.md 登记五条不变量并加 SecurityInvariantsTests 回归网: - INV-001 UserCenterService.updateAuthorization 加 RESTRICTED_ROLES 黑名单, 阻断走 PUT /users/{id}/authorization 整集替换 roles 绕过 AdminUserController superadmin 保护边界的路径 - INV-002 ChatHistoryController 加 chat 归属校验:已绑定 owner 的 chat 不再 允许他人写入;同时新增 AccessDeniedBusinessException 让业务归属类 403 与 Sa-Token 缺权限/缺角色场景的 message 解耦 - INV-003 PasswordService 切到 BCryptPasswordEncoder(cost=10),保留 dual-mode 让历史 SHA-256 用户能登录,AuthService 在登录成功后做 lazy upgrade 就地 迁移;schema.sql / init.sql / test-schema.sql 三处 seed 同步重写为 bcrypt - INV-004 user_follows 表与 idx_user_follows_followee 索引加进 schema.sql / init.sql / test-schema.sql,FollowService 引用的 relation 之前在新部署上 全部 500 - INV-005 docker-compose.yml 把 :-change_me 弱密码默认值改成 :? 强制 .env 提供, 避免漏配 env 时仍以弱密码起服务 顺手修:discord-bridge seed 自 PR #18 起就在 test-schema.sql 里,但 JdbcUserAccountRepositoryTests / UserCenterControllerIntegrationTests 的 hasSize(3) 没跟着更新,main 上一直红——这次一并改成 4。 测试:./mvnw test 全套 212 / 212;新增 SecurityInvariantsTests 12 条覆盖 五条不变量的"攻击成功就 fail"探测器。
1 parent 208f78e commit f40e1e0

22 files changed

Lines changed: 858 additions & 36 deletions

‎SECURITY.md‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# 后端安全不变量(Security Invariants)
2+
3+
本文档登记后端代码中**不可变更的安全保护点**。
4+
每一条不变量都对应 `SecurityInvariantsTests` 中的回归测试,CI 自动跑。
5+
6+
## 维护规则
7+
8+
修改本文件涉及的代码时,**必须同时更新对应测试**。
9+
删除任何一条不变量需在 PR 描述写明理由并 CC superadmin review。
10+
11+
每条不变量包含四个字段:
12+
- **保护点**:被这条规则保护的代码位置(file path + 类/方法名)
13+
- **测试**:验证这条规则的测试方法名(直接 `./mvnw test -Dtest=SecurityInvariantsTests` 跑得到)
14+
- **为什么**:规则存在的理由——攻击场景 / 历史 / 业务约束
15+
- **历史**:规则诞生时间与背景(commit、CR 报告、incident)
16+
17+
## 编号约定
18+
19+
`INV-XXX` 三位流水号,永不复用。删除条目时保留编号 + 标 `(retired)`,
20+
新条目用下一个未占用号。这样 PR diff / 历史回溯永远稳定。
21+
22+
---
23+
24+
## INV-001 · superadmin 角色不能通过 API 授予
25+
26+
- **保护点**:
27+
- `usercenter/service/UserCenterService#updateAuthorization`
28+
- `events/controller/AdminUserController#setAdminRole`
29+
- **测试**:
30+
- `SecurityInvariantsTests#admin不能通过PUT_users_authorization给自己加superadmin角色`
31+
- `SecurityInvariantsTests#admin不能通过PUT_users_authorization给他人加superadmin角色`
32+
- `SecurityInvariantsTests#admin可以通过PUT_users_authorization设置非superadmin的合法角色`(反向 happy path)
33+
- **为什么**:superadmin 是 admin 的上位角色(能管理其他 admin 的角色绑定)。
34+
如果 admin 能通过任何 API 拿到 superadmin,整套 RBAC 边界失效。
35+
唯一合法的 superadmin 授予路径:DB 直接 `UPDATE user_accounts SET roles = ...`。
36+
- **历史**:2026-05-07 三方 CR 的 attack chain A 起点(详见内部报告)。
37+
38+
---
39+
40+
## INV-002 · 不允许往他人的 chat 历史写消息
41+
42+
- **保护点**:`chat/controller/ChatHistoryController#save`
43+
- **测试**:
44+
- `SecurityInvariantsTests#匿名调用不能往他人chatId写消息`
45+
- `SecurityInvariantsTests#登录用户不能往他人chatId写消息`
46+
- `SecurityInvariantsTests#chat的owner可以继续写自己的chat历史`(反向)
47+
- `SecurityInvariantsTests#匿名chat允许匿名继续写`(反向,保留匿名 → 登录迁移语义)
48+
- **为什么**:chat 历史既是用户隐私也是 AI 训练 / 行为审计的输入。
49+
如果攻击者拿到他人 chatId(前端 log / share URL leak),匿名 POST 即可往
50+
victim 历史里塞污染消息。COALESCE 语义不会改 ownerId,但 INSERT INTO
51+
"Message" 已经发生——这是数据完整性 + 内容污染。
52+
匿名 chat 允许继续匿名写,保留"匿名 → 登录迁移"业务语义;
53+
一旦 chat 已绑定 ownerId,则只允许 owner 本人写。
54+
- **历史**:2026-05-07 三方 CR 的 attack chain B 起点。
55+
56+
---
57+
58+
## INV-003 · 密码哈希必须用 bcrypt(legacy SHA-256 仅兼容路径)
59+
60+
- **保护点**:
61+
- `usercenter/service/PasswordService#hash`(始终输出 bcrypt)
62+
- `usercenter/service/PasswordService#matches`(dual-mode:bcrypt + legacy SHA-256)
63+
- `usercenter/service/AuthService#login`(lazy upgrade:登录成功后把 legacy hash 就地升级)
64+
- `src/main/resources/schema.sql`(seed bcrypt 哈希)
65+
- `docker/init-db/init.sql`(seed bcrypt 哈希)
66+
- `src/test/resources/test-schema.sql`(seed bcrypt 哈希)
67+
- **测试**:
68+
- `PasswordServiceTests#hashGeneratesBcryptOutput`
69+
- `PasswordServiceTests#hashSamePasswordTwiceProducesDifferentOutput`(salt 随机性证据)
70+
- `PasswordServiceTests#matchesAcceptsLegacySha256Hash`(向后兼容)
71+
- `PasswordServiceTests#isLegacyHashIdentifiesPlainSha256`
72+
- `SecurityInvariantsTests#seed账号能用明文密码登录验证bcrypt端到端`
73+
- **为什么**:原实现用单轮 SHA-256,无 salt 无 cost factor。
74+
DB 一旦泄露(备份、容器逃逸、pgAdmin 误导出),所有口令账号瞬间失守——
75+
GPU 一秒可跑数十亿哈希,rainbow table 已收录绝大部分常见明文。
76+
迁移策略:新写入一律 bcrypt;老 SHA-256 hash 通过 dual-mode matches 让用户能登录,
77+
登录成功时由 AuthService 就地升级。所有 seed 同步重写为 bcrypt 形式。
78+
- **历史**:2026-05-07 三方 CR 的 attack chain B 加固项。
79+
80+
---
81+
82+
## INV-004 · user_follows 表必须随 schema 一并建立
83+
84+
- **保护点**:
85+
- `src/main/resources/schema.sql`(user_follows DDL + idx_user_follows_followee 索引)
86+
- `docker/init-db/init.sql`(同步 DDL)
87+
- `src/test/resources/test-schema.sql`(测试同步)
88+
- `usercenter/follows/FollowService`(SQL 调用方)
89+
- **测试**:`SecurityInvariantsTests#user_follows表存在且follow_unfollow能闭环`
90+
- **为什么**:FollowService 引用的 `user_follows(follower_id, followee_id, created_at)`
91+
之前在 schema.sql 与 init.sql 中都未定义,全新部署或新 Neon 数据库一访问
92+
`/api/user-center/follows/...` 就 `relation does not exist` 报 500。
93+
这是 issue #25 之外的额外 schema drift。CI 测试存在表 + follow/unfollow 闭环
94+
作为长期回归探测器。
95+
- **历史**:2026-05-07 三方 CR 发现的 schema drift(与 issue #25 类同)。
96+
97+
---
98+
99+
## INV-005 · docker-compose 不允许暴露公网 PG 端口或保留弱密码默认值
100+
101+
- **保护点**:`docker-compose.yml`(postgres 服务 ports 段、所有密码字段)
102+
- **测试**:
103+
- `SecurityInvariantsTests#docker_compose里postgres端口必须绑127_0_0_1`
104+
- `SecurityInvariantsTests#docker_compose里不允许出现change_me弱密码默认值`
105+
- **为什么**:PostgreSQL 主端口暴露公网 = 攻击面 +1(弱密码扫库 / 0day / TLS 退化)。
106+
pgAdmin 走 docker internal network 即可访问,不需要 host port mapping。
107+
`change_me` fallback 让部署者忘配 env 时仍能起服务,但起来的就是弱密码后台
108+
——必须用 `${VAR:?...}` 形式强制部署期校验。
109+
- **历史**:2026-05-07 三方 CR 加固项(端口部分历史已修,本次加测试 + 收紧密码默认值)。

‎docker-compose.yml‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ services:
1212
# 数据库连接(使用 PG* 变量,与 application.properties 保持一致)
1313
SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL:-jdbc:postgresql://postgres:5432/involution_hell}
1414
PGUSER: ${PGUSER:-involution}
15-
PGPASSWORD: ${PGPASSWORD:-change_me}
15+
PGPASSWORD: ${PGPASSWORD:?PGPASSWORD must be set in .env}
1616
SPRING_SQL_INIT_MODE: ${SPRING_SQL_INIT_MODE:-always}
1717
# GitHub OAuth2
1818
AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-}
@@ -67,11 +67,14 @@ services:
6767
environment:
6868
POSTGRES_DB: ${POSTGRES_DB:-involution_hell}
6969
POSTGRES_USER: ${POSTGRES_USER:-involution}
70-
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me}
71-
# 只 bind 到 loopback,不再暴露公网。
70+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
71+
# 只 bind 到 loopback,不再暴露公网(INV-005)。
7272
# Spring Boot / pgAdmin / 自动备份容器都在同 docker network,走容器名 postgres:5432 即可。
7373
# 历史上是 "5432:5432" 让 Vercel build 脚本远程查 doc_contributors 生成排行榜,
7474
# 现已改走后端 /api/public/leaderboard,DB 不再需要对外开放。
75+
#
76+
# 注意:禁止把这一行改回 "5432:5432" 或 "0.0.0.0:5432:5432"。
77+
# 攻击面分析见 SECURITY.md / INV-005。
7578
ports:
7679
- "127.0.0.1:5432:5432"
7780
volumes:
@@ -95,7 +98,7 @@ services:
9598
restart: unless-stopped
9699
environment:
97100
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@involutionhell.com}
98-
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change_me}
101+
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:?PGADMIN_PASSWORD must be set in .env}
99102
# pgAdmin 保持 SERVER_MODE=False(desktop / single-user,无登录页)——
100103
# 关键前提:**外层 Caddy 做 forward_auth**,只有带合法 admin satoken
101104
# cookie 的请求才被代理到这里。直接对公网暴露 8082 绝对不行。
@@ -138,7 +141,7 @@ services:
138141
POSTGRES_HOST: postgres
139142
POSTGRES_DB: ${POSTGRES_DB:-involution_hell}
140143
POSTGRES_USER: ${POSTGRES_USER:-involution}
141-
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me}
144+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
142145
# -Fc 产出 custom 格式(.dump),pgAdmin 右键 Restore 直接一键恢复;
143146
# 镜像包装脚本默认会 gzip,这里必须显式设 BACKUP_SUFFIX=.dump 且不加 gzip
144147
# 选项,让镜像按 custom 格式直接落盘。

‎docker/init-db/init.sql‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,23 @@ CREATE TABLE IF NOT EXISTS user_accounts (
1515
-- admin / Admin@123456
1616
-- alice / Alice@123456
1717
-- auditor / Audit@123456
18+
-- 哈希格式:bcrypt(cost=10) —— INV-003 起点
1819
INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions)
19-
VALUES ('admin', 'ad89b64d66caa8e30e5d5ce4a9763f4ecc205814c412175f3e2c50027471426d', 'Admin', TRUE, 'admin', 'user:profile:read,user:center:read,user:center:manage'),
20-
('alice', 'b02bb998ecc1616148b9b4ba0405dbd4c224acd1bac059d59f0a07b3b1a68400', 'Alice', TRUE, 'user', 'user:profile:read'),
21-
('auditor', 'ccabaaba054fb98905b5b9ee47174f57cb6088e04b1526f08b872dc06eaa6bb9', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read')
20+
VALUES ('admin', '$2b$10$Bfnw8v.BsXeZVPbre94sJeokgEfKuCLsdH7ckJxzxn5nxirHJHmP.', 'Admin', TRUE, 'admin', 'user:profile:read,user:center:read,user:center:manage'),
21+
('alice', '$2b$10$BmtVOmPK8Os/xreOTImsdec1fAA8Y9iTm1D823swYucSI2NDFdk.q', 'Alice', TRUE, 'user', 'user:profile:read'),
22+
('auditor', '$2b$10$/1OfzhrA6CITrjJsDbzk.uMLq6cHa/iOP./wL2BAPo9t7QRq7Ca5W', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read')
2223
ON CONFLICT (username) DO NOTHING;
2324

25+
-- 关注关系(user_follows)—— 与 schema.sql 保持一致
26+
CREATE TABLE IF NOT EXISTS user_follows (
27+
follower_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE,
28+
followee_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE,
29+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
30+
PRIMARY KEY (follower_id, followee_id)
31+
);
32+
CREATE INDEX IF NOT EXISTS idx_user_follows_followee
33+
ON user_follows(followee_id, created_at DESC);
34+
2435
-- Prisma tables (frontend/prisma/schema.prisma)
2536

2637
-- users table

‎pom.xml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@
109109
<version>1.39.0</version>
110110
</dependency>
111111

112+
<!-- spring-security-crypto: 单独 artifact,仅提供 BCryptPasswordEncoder 等
113+
PasswordEncoder 实现,不会引入完整 Spring Security 的 Filter chain。
114+
PasswordService 用它做 bcrypt 哈希(替代之前的裸 SHA-256),同时保留
115+
legacy SHA-256 dual-mode 兼容老用户登录(见 INV-003 / SECURITY.md)。 -->
116+
<dependency>
117+
<groupId>org.springframework.security</groupId>
118+
<artifactId>spring-security-crypto</artifactId>
119+
</dependency>
120+
112121
<!-- Google Analytics Data API (GA4) -->
113122
<dependency>
114123
<groupId>com.google.analytics</groupId>

‎src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import cn.dev33.satoken.stp.StpUtil;
44
import com.involutionhell.backend.chat.dto.ChatTurnSaveRequest;
55
import com.involutionhell.backend.chat.repository.ChatHistoryRepository;
6+
import com.involutionhell.backend.chat.repository.ChatOwner;
67
import com.involutionhell.backend.common.api.ApiResponse;
8+
import com.involutionhell.backend.common.error.AccessDeniedBusinessException;
9+
import java.util.Optional;
710
import org.springframework.web.bind.annotation.PostMapping;
811
import org.springframework.web.bind.annotation.RequestBody;
912
import org.springframework.web.bind.annotation.RequestMapping;
@@ -12,13 +15,16 @@
1215
/**
1316
* /api/chat/sessions/save —— 保存一次 AI 对话回合(替代前端原 Prisma 直连)。
1417
*
15-
* 鉴权:匿名允许(SaTokenConfigure 里放行本路径)。原 Prisma 实现就是匿名也写,
16-
* chat.userId 允许 NULL;保持语义一致,避免前端切流量时未登录用户聊天历史丢失。
17-
* 如果登录了,用 sa-token 取 userId 关联;没登录就 NULL。
18+
* 鉴权策略(INV-002):匿名 + 登录都允许,但写入前必须做归属校验:
19+
* - chat 不存在:允许任意调用方创建(首次写入)
20+
* - chat 存在且 ownerId == null:允许匿名继续写、登录用户首次"接管"为 owner
21+
* - chat 存在且 ownerId != null:调用方必须登录且 userId 与 ownerId 完全匹配
1822
*
19-
* 为什么单独放一个 controller 而不是塞进 OpenAiController:OpenAiController 管
20-
* 流式代理,这里管持久化,职责不同;而且这条路径要对匿名开放,和 OpenAI 代理
21-
* 的登录要求不一致,拆开配 SaToken 拦截规则更清晰。
23+
* 攻击场景:攻击者拿到他人 chatId(前端 log / share URL leak),匿名 POST
24+
* 即可往 victim 历史里塞消息。COALESCE 语义不会改 ownerId,但 INSERT INTO
25+
* "Message" 已经发生——这是数据完整性 + 内容污染问题。
26+
*
27+
* 见 SecurityInvariantsTests INV-002 三条断言。
2228
*/
2329
@RestController
2430
@RequestMapping("/api/chat/sessions")
@@ -38,11 +44,22 @@ public ApiResponse<Void> save(@RequestBody ChatTurnSaveRequest req) {
3844

3945
// StpUtil.isLogin() 对匿名请求返回 false 而不是抛异常——配合 SaToken
4046
// 拦截器在 SaTokenConfigure 里 notMatch 放行本路径,才能真正对匿名生效。
41-
Long userId = StpUtil.isLogin() ? StpUtil.getLoginIdAsLong() : null;
47+
Long callerUserId = StpUtil.isLogin() ? StpUtil.getLoginIdAsLong() : null;
48+
49+
// INV-002:归属校验。已绑定 owner 的 chat 必须由 owner 本人写。
50+
// 这一步必须在 saveTurn 之前——saveTurn 内部用 ON CONFLICT upsert,
51+
// 一旦执行就会插入 Message 行,事后回滚得靠 @Transactional,宁可前置拦截。
52+
Optional<ChatOwner> existing = chatHistoryRepository.lookupOwner(req.chatId());
53+
if (existing.isPresent() && !existing.get().isAnonymous()) {
54+
Long ownerId = existing.get().ownerId();
55+
if (callerUserId == null || !callerUserId.equals(ownerId)) {
56+
throw new AccessDeniedBusinessException("不允许写入他人的 chat 历史");
57+
}
58+
}
4259

4360
chatHistoryRepository.saveTurn(
4461
req.chatId(),
45-
userId,
62+
callerUserId,
4663
req.userMessage(),
4764
req.assistantMessage());
4865

‎src/main/java/com/involutionhell/backend/chat/repository/ChatHistoryRepository.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.involutionhell.backend.chat.repository;
22

3+
import java.util.Optional;
4+
35
/**
46
* AI 对话历史持久化接口。
57
*
@@ -10,6 +12,16 @@
1012
*/
1113
public interface ChatHistoryRepository {
1214

15+
/**
16+
* 查询 chatId 对应行的归属。
17+
* - empty:chat 不存在
18+
* - present 且 ownerId == null:匿名 chat(保留给"匿名 → 登录迁移"语义)
19+
* - present 且 ownerId != null:已绑定到具体 user
20+
*
21+
* 用于 Controller 在写入前做 INV-002 归属校验。
22+
*/
23+
Optional<ChatOwner> lookupOwner(String chatId);
24+
1325
/**
1426
* 原子地持久化一个聊天回合:
1527
* 1. chat 不存在则创建、存在则刷新 updatedAt 和 userId(匿名 → 登录迁移场景)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.involutionhell.backend.chat.repository;
2+
3+
/**
4+
* Chat 行的归属信息——只承载本系统真正在乎的两件事:是否存在 + owner 是谁。
5+
*
6+
* 为什么不直接 Optional<Long> 表示 owner:会丢掉"chat 不存在"和"chat 存在但匿名"
7+
* 这两种状态的差别。匿名 chat 允许继续匿名写;他人 chat 必须拒绝。两态合并会让
8+
* controller 的归属校验逻辑写不清楚。
9+
*
10+
* @param ownerId user_accounts.id;匿名 chat 为 null
11+
*/
12+
public record ChatOwner(Long ownerId) {
13+
14+
public boolean isAnonymous() {
15+
return ownerId == null;
16+
}
17+
}

‎src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.involutionhell.backend.chat.repository;
22

33
import java.sql.Types;
4+
import java.util.List;
5+
import java.util.Optional;
46
import java.util.UUID;
57
import org.springframework.jdbc.core.JdbcTemplate;
68
import org.springframework.stereotype.Repository;
@@ -22,6 +24,26 @@ public JdbcChatHistoryRepository(JdbcTemplate jdbc) {
2224
this.jdbc = jdbc;
2325
}
2426

27+
/**
28+
* 查询 chatId 的归属信息。详细语义见接口文档。
29+
*
30+
* 实现要点:rs.getLong + rs.wasNull 才能区分"列值就是 0"和"列值是 NULL",
31+
* 直接 getObject 在 H2 / Postgres 之间行为不一致。
32+
*/
33+
@Override
34+
public Optional<ChatOwner> lookupOwner(String chatId) {
35+
List<ChatOwner> result = jdbc.query(
36+
"""
37+
SELECT "userId" FROM "Chat" WHERE id = ?
38+
""",
39+
(rs, rn) -> {
40+
long uid = rs.getLong("userId");
41+
return new ChatOwner(rs.wasNull() ? null : uid);
42+
},
43+
chatId);
44+
return result.stream().findFirst();
45+
}
46+
2547
/**
2648
* 为什么用 ON CONFLICT 做 upsert 而不是先 select 再 insert:
2749
* 1. 单次 round-trip,少一次 SQL 调用
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.involutionhell.backend.common.error;
2+
3+
/**
4+
* 业务规则触发的"访问被拒"——不是 Sa-Token 缺权限/缺角色,而是业务对象归属
5+
* 不匹配(例如试图写入他人的 chat 历史、修改他人的资源)。
6+
*
7+
* 用独立类型而不是复用 NotPermissionException:
8+
* 1. NotPermissionException 的 message 模板由 GlobalExceptionHandler 强制成
9+
* "拒绝访问: 缺少权限 [...]",业务语义会被吞
10+
* 2. 单独类型让调用方一眼看出是"业务归属"问题而非"角色权限"问题
11+
*
12+
* GlobalExceptionHandler 把它转成 403 + 自定义 message。
13+
*/
14+
public class AccessDeniedBusinessException extends RuntimeException {
15+
16+
public AccessDeniedBusinessException(String message) {
17+
super(message);
18+
}
19+
}

‎src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ public ResponseEntity<ApiResponse<Void>> handleNotRoleException(NotRoleException
8080
.body(ApiResponse.fail("拒绝访问: 缺少角色 [" + e.getRole() + "]"));
8181
}
8282

83+
/**
84+
* 业务归属访问被拒——例如写入他人的 chat 历史、修改他人的资源。
85+
* 与 Sa-Token 缺权限/缺角色不同,message 由抛出方决定,403 状态码。
86+
*/
87+
@ExceptionHandler(AccessDeniedBusinessException.class)
88+
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedBusiness(AccessDeniedBusinessException e) {
89+
return ResponseEntity.status(HttpStatus.FORBIDDEN)
90+
.body(ApiResponse.fail(e.getMessage()));
91+
}
92+
8393
// ==========================================
8494
// 业务与通用异常拦截
8595
// ==========================================

0 commit comments

Comments
 (0)