diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 68a1e58..345dac5 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -25,7 +25,7 @@ xiaoqu-agent-service/ ├── pom.xml ├── sql/ │ ├── V1__init.sql # 自有库 xiaoqu_agent_svc_d 建表(手工执行,未接 flyway) -│ └── seed-dev.sql # dev 种子数据(测试 ak + 运营作用域授权) +│ └── seed-dev.sql # dev 种子数据(运营作用域授权,不含固定 ak) ├── src/main/java/me/iiv/agent/ │ ├── AgentServiceApplication.java │ ├── config/ # M0/M1/M2/SvcDataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig @@ -38,9 +38,9 @@ xiaoqu-agent-service/ │ ├── admin/ # P4 占位包 │ ├── entity/m0、m2、svc mapper/m0、m1、m2、svc # codegen 生成物(提交进仓库) ├── src/main/resources/ -│ ├── application.yml # MCP server: protocol=STREAMABLE, type=SYNC, 端点 /mcp;默认 dev profile -│ └── application-dev.yml # 四数据源 + Redis,密码用环境变量占位符带默认值 -└── src/test/java/me/iiv/agent/codegen/CodeGenerator.java # run-once 代码生成器 +│ ├── application.yml # MCP server: protocol=STREAMABLE, type=SYNC,端点 /mcp +│ └── application-dev.yml # 四数据源 + Redis,账号密码必须由环境变量提供 +└── src/test/java/me/iiv/agent/ # 权限回归测试 + run-once 代码生成器 ``` 数据源分包约定:`entity/m0|m1|m2|svc` 与 `mapper/...` 一一对应各自的 `SqlSessionFactory`(`@MapperScan` + `sqlSessionFactoryRef`)。m0/m1/m2 只读,svc(自有库)读写。 @@ -59,13 +59,13 @@ xiaoqu-agent-service/ | 方法 | 老系统来源 | 语义 | |---|---|---| -| `isAgentAuthority(authorityId)` | `AuthorityServiceImpl.checkAuthorityIsagent` | 权限组 isagent=1 → 代理商视角 | +| `isAgentAuthority(authorityId)` | `AuthorityServiceImpl.checkAuthorityIsagent` | isagent=1 → 代理商;isagent=0 → 总部;缺失/非法配置拒绝访问 | | `visibleAgentIds(user)` | `getByIsAgent` 主重载 | isagent!=1 返回 null(不过滤);否则 自己 + 直属下级(supervisor_id 一层)+ m2 warehouse_agent_auth 代管 manager_agent_id | -| `isSalesNonLeader(user)` | saleFlag 判定(:703-710) | Redis db5 `Dictionary` hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不含 "postId,";字典缺失按不限制处理并告警 | +| `isSalesNonLeader(user)` | saleFlag 判定(:703-710) | Redis db5 `Dictionary` hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不含 "postId,";字典缺失时拒绝访问 | | `applyPropertyRowFilter` 销售分支 | saleFlag 分支(:716-735,互斥 return) | **1:1 复刻(2026-09-03 决策)**:id IN (create_id=自己 ∪ [代管授权非空时] agent_id IN (下级+代管移除自己) 的物业),空集合则 id IN (0);命中后不再叠加 agent_id/代管物业 OR 结构 | | `isPropertyUser(user)` | enterprise_type=2 规则(§3.3 通用规则,保留备用) | 物业用户;**property.list.query 不使用本规则**(物业用户不查此列表,与老 /property/listByPage 一致,2026-09-03 决策) | -`agent.list.query` 的行权限照搬老系统 `AgentController.listByPage`:isagent=1 → `supervisor_id = 用户agentId`(只查直属下级);isagent=0 → 不过滤;入参传 `id` → 精确查询(可查到自身,不加 supervisor 限制)。 +`agent.list.query` 的行权限照搬老系统 `AgentController.listByPage`:isagent=1 默认只查直属下级;传 `id` 时只能精确查询自己或直属下级;isagent=0 可按任意合法 id 查询。 ## MCP 工具 @@ -83,10 +83,22 @@ xiaoqu-agent-service/ ## 启动 ```bash -# 首次:建自有库 + 种子数据(dev) -mysql -h192.168.1.181 -uroot -p < sql/V1__init.sql +# 首次:由数据库管理员建自有库;启动一次同步工具后,再创建 dev 作用域 +mysql -h192.168.1.181 -u -p < sql/V1__init.sql # 启动一次让 ToolSyncService 同步 tool 表后: -mysql -h192.168.1.181 -uroot -p xiaoqu_agent_svc_d < sql/seed-dev.sql +mysql -h192.168.1.181 -u -p xiaoqu_agent_svc_d < sql/seed-dev.sql + +# 必须显式指定环境及凭据;m0/m1/m2 使用数据库只读账号 +export SPRING_PROFILES_ACTIVE=dev +export M0_DB_USERNAME= +export M0_DB_PASSWORD= +export M1_DB_USERNAME= +export M1_DB_PASSWORD= +export M2_DB_USERNAME= +export M2_DB_PASSWORD= +export SVC_DB_USERNAME= +export SVC_DB_PASSWORD= +export REDIS_PASSWORD= ./mvnw clean package -DskipTests java -jar target/xiaoqu-agent-service.jar @@ -96,17 +108,17 @@ java -jar target/xiaoqu-agent-service.jar nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 & ``` -- 默认 `spring.profiles.active=dev`,端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。 -- 密码默认值仅供本地 dev,环境变量覆盖:`M0_DB_PASSWORD` / `M1_DB_PASSWORD` / `M2_DB_PASSWORD` / `SVC_DB_PASSWORD` / `REDIS_PASSWORD`。 +- 不提供默认 profile、数据库账号或密码;缺失配置时不得启动。 +- dev 端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。 -## 测试 ak(仅 dev,明文只在此处与 sql/seed-dev.sql 出现,服务端只存 SHA-256) +## 测试 ak(仅 dev) -| 用途 | 用户 | ak | -|---|---|---| -| 总部视角(isagent=0) | 46 代理商管理端 | `xqak_1d21057ad83203dbeb767eb5069701046b27debdc08a733c` | -| 代理商视角(isagent=1,agent_id=1,26 个直属下级) | 1000 斤斤计较 | `xqak_7df02ccee2133dc2cf951db12e3958557bdf2d30a91e7f08` | -| 无任何 scope(验证拒绝) | 1000 斤斤计较 | `xqak_5bdc0eac8c1cab535f22dc3e9757f72b3127de43a14997a7` | -| 销售非负责人(dept 4/post 103,代管 agent 19) | 1727 北京销售1 | `xqak_cb006faf86c0db6f2f47b3eb8872f8b5d495b0c05a6fb52c` | +仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本地环境变量中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。 + +```bash +export XQ_API_KEY="xqak_$(openssl rand -hex 24)" +printf %s "$XQ_API_KEY" | shasum -a 256 +``` ## curl 验证(Streamable HTTP 握手) @@ -138,7 +150,7 @@ property.list.query 权限隔离实测(2026-09-03,均与直连 SQL 核对一 | province=北京市 | total=38 | total=31 | | name 模糊 '%测试%' | — | total=7(与 SQL 一致) | -agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 / 代理商 9(均与 SQL 一致);id=79 精确查 → 1;rows=500 → 截断为 100 并返回 notice。 +agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 / 代理商 9(均与 SQL 一致);rows=500 → 截断为 100 并返回 notice。安全修复后,代理商精确查询仅允许自身或直属下级,权限外 id 返回空结果。 ## 客户端接入(dev 阶段裸跑 HTTP) @@ -173,6 +185,8 @@ claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \ ```bash # 生成(依赖已下载后执行;连 dev 库) +export CODEGEN_DB_USERNAME= +export CODEGEN_DB_PASSWORD= ./mvnw -q dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/cp.txt javac -cp "$(cat target/cp.txt)" -d target/codegen-classes src/test/java/me/iiv/agent/codegen/CodeGenerator.java java -cp "target/codegen-classes:$(cat target/cp.txt)" me.iiv.agent.codegen.CodeGenerator diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 77f3523..0f4dae1 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -1,37 +1,14 @@ -- ============================================================= -- dev 种子数据(仅 dev 环境使用!) --- 用户:46=代理商管理端(isagent=0 总部视角, authority_id=1) --- 1000=斤斤计较(isagent=1 代理商视角, authority_id=108, agent_id=1 有 26 个直属下级) --- token: --- HQ user_id=46 xqak_1d21057ad83203dbeb767eb5069701046b27debdc08a733c --- AGENT user_id=1000 xqak_7df02ccee2133dc2cf951db12e3958557bdf2d30a91e7f08 --- NOSCOPE user_id=1000 xqak_5bdc0eac8c1cab535f22dc3e9757f72b3127de43a14997a7 (未授权任何 scope,验证拒绝) --- SALES user_id=1727 xqak_cb006faf86c0db6f2f47b3eb8872f8b5d495b0c05a6fb52c (北京销售1,dept_id=4 命中 authDeptId, --- post_id=103 不在 authDeptPostId → 销售非负责人;warehouse_agent_auth 代管 agent 19) --- 依赖:tool 表已由应用启动时 ToolSyncService 同步(sys.ping/agent.count/agent.list.query) +-- 仅创建作用域并关联工具,不提交固定 token 或 ak。 +-- token 请使用本地随机 ak 的 SHA-256 哈希手工创建,详见 README。 +-- 依赖:tool 表已由应用启动时 ToolSyncService 同步。 -- ============================================================= USE xiaoqu_agent_svc_d; START TRANSACTION; --- token(ak 只存 SHA-256 哈希;明文只保留在本文件顶部注释,仅 dev) -INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id) -SELECT 46, 'xqak_1d21', '9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c', 'dev 测试:总部视角(isagent=0)', 1, 0 -WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c'); - -INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id) -SELECT 1000, 'xqak_7df0', '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007', 'dev 测试:代理商视角(isagent=1)', 1, 0 -WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007'); - -INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id) -SELECT 1000, 'xqak_5bdc', '85733c4eaab02e956bc555e0f25e5043629f9b8f8ad9ca39046735f5e817ca9c', 'dev 测试:无任何 scope 授权', 1, 0 -WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '85733c4eaab02e956bc555e0f25e5043629f9b8f8ad9ca39046735f5e817ca9c'); - -INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id) -SELECT 1727, 'xqak_cb00', '9fd25466ad4913b0c48e60d853b1bb27ba75bec426a117978e8c95687917bada', 'dev 测试:销售非负责人(北京销售1, dept 4/post 103, 代管 agent 19)', 1, 0 -WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '9fd25466ad4913b0c48e60d853b1bb27ba75bec426a117978e8c95687917bada'); - -- 作用域:运营 INSERT INTO agent_scope (name, code, memo, status, create_id) SELECT '运营', 'operation', 'dev 种子:运营作用域', 1, 0 @@ -43,18 +20,7 @@ SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query') AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); --- token-作用域:HQ / AGENT / SALES 三把 token 授权"运营";NOSCOPE 不授权 -INSERT INTO token_scope (token_id, scope_id, create_id) -SELECT k.id, s.id, 0 FROM agent_api_key k JOIN agent_scope s -WHERE k.key_hash IN ('9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c', - '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007', - '9fd25466ad4913b0c48e60d853b1bb27ba75bec426a117978e8c95687917bada') - AND s.code = 'operation' - AND NOT EXISTS (SELECT 1 FROM token_scope ts WHERE ts.token_id = k.id AND ts.scope_id = s.id); - COMMIT; -- 校验 -SELECT k.id, k.user_id, k.key_prefix, k.memo FROM agent_api_key k ORDER BY k.id; SELECT s.id, s.code, t.name FROM agent_scope s JOIN scope_tool st ON s.id = st.scope_id JOIN tool t ON st.tool_id = t.id WHERE s.soft_delete = 0; -SELECT ts.token_id, ts.scope_id FROM token_scope ts WHERE ts.soft_delete = 0; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java index 4e080aa..633ec9d 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java @@ -51,18 +51,18 @@ public class AgentTools implements ToolObject { /** * 查询代理商列表。行权限照搬老系统 AgentController.listByPage: * 权限组 isagent=1 时只查直属下级(supervisor_id = 用户 agentId);isagent=0 不过滤。 - * 入参传 id 时精确查询(可查到自身,此时不加 supervisor 限制)。 + * 入参传 id 时精确查询;代理商只能查询自己或直属下级。 * supervisorName 批量 IN 查询富化,无 N+1。 */ @XqTool(category = "customer", kind = "read") @Tool(name = "agent.list.query", description = "查询代理商列表(分页)。" + "行级权限:代理商身份的用户(权限组 isagent=1)只能查到自己名下的直属下级代理;总部身份(isagent=0)可看全部。" - + "传 id 时为精确查询、可查到自身,其余过滤条件失效。" + + "传 id 时为精确查询;代理商只能查询自己或直属下级,总部可查询任意合法 id,其余过滤条件失效。" + "level 含义:0=总部 1=一级 2=二级;channelNo 含义:Z=总部 F=分公司 D=代理。" + "分页:page 从 1 开始,rows 默认 20、上限 100,超出自动截断为 100 并在返回的 notice 字段提示。" + "返回 total(符合条件的总数)、records(本页记录)。") public Map agentListQuery( - @ToolParam(required = false, description = "代理商 id,精确查询(可查到自身);传入后忽略其他过滤条件") Long id, + @ToolParam(required = false, description = "代理商 id,精确查询;代理商仅限自己或直属下级;传入后忽略其他过滤条件") Long id, @ToolParam(required = false, description = "代理商名称,模糊匹配") String name, @ToolParam(required = false, description = "级别:0=总部 1=一级 2=二级") Integer level, @ToolParam(required = false, description = "渠道编号:Z=总部 F=分公司 D=代理") String channelNo, @@ -78,9 +78,7 @@ public class AgentTools implements ToolObject { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(Agent::getSoftDelete, 0); - if (id != null) { - wrapper.eq(Agent::getId, id); - } else { + if (id == null) { if (StringUtils.hasText(name)) { wrapper.like(Agent::getName, name); } @@ -99,10 +97,8 @@ public class AgentTools implements ToolObject { if (StringUtils.hasText(serialnumber)) { wrapper.eq(Agent::getSerialnumber, serialnumber); } - if (permissionService.isAgentAuthority(user.getAuthorityId())) { - wrapper.eq(Agent::getSupervisorId, user.getAgentId()); - } } + permissionService.applyAgentListRowFilter(wrapper, user, id); Long total = agentMapper.selectCount(wrapper); wrapper.select(Agent::getId, Agent::getName, Agent::getLevel, Agent::getChannelNo, diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index d59a60f..c130714 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -57,13 +57,45 @@ public class PermissionService { this.redisTemplate = redisTemplate; } - /** 权限组 isagent=1 → 代理商视角(对应 AuthorityServiceImpl.checkAuthorityIsagent) */ + /** 权限组 isagent=1 → 代理商视角;未知或已删除的权限组拒绝访问。 */ public boolean isAgentAuthority(Long authorityId) { if (authorityId == null) { - return false; + throw new IllegalStateException("用户未配置权限组,拒绝访问业务数据"); } Authority authority = authorityMapper.selectById(authorityId); - return authority != null && authority.getIsagent() != null && authority.getIsagent() == 1; + if (authority == null || !Integer.valueOf(0).equals(authority.getSoftDelete())) { + throw new IllegalStateException("用户权限组不存在或已删除,拒绝访问业务数据"); + } + if (Integer.valueOf(1).equals(authority.getIsagent())) { + return true; + } + if (Integer.valueOf(0).equals(authority.getIsagent())) { + return false; + } + throw new IllegalStateException("用户权限组缺少有效的行权限配置,拒绝访问业务数据"); + } + + /** + * 代理商列表权限:总部可按任意 id 查询;代理商只能查询自己或直属下级。 + * 未传 id 时保持老页面语义,只列直属下级。 + */ + public void applyAgentListRowFilter(LambdaQueryWrapper wrapper, User user, Long id) { + boolean agentAuthority = isAgentAuthority(user.getAuthorityId()); + if (id != null) { + wrapper.eq(Agent::getId, id); + } + if (!agentAuthority) { + return; + } + if (user.getAgentId() == null) { + throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); + } + if (id == null) { + wrapper.eq(Agent::getSupervisorId, user.getAgentId()); + return; + } + wrapper.and(w -> w.eq(Agent::getId, user.getAgentId()) + .or().eq(Agent::getSupervisorId, user.getAgentId())); } /** @@ -75,6 +107,9 @@ public class PermissionService { if (!isAgentAuthority(user.getAuthorityId())) { return null; } + if (user.getAgentId() == null) { + throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); + } Set ids = new LinkedHashSet<>(); ids.addAll(subordinateAndSelfIds(user)); ids.addAll(managedAuths(user.getId()).stream() @@ -105,7 +140,7 @@ public class PermissionService { /** * 销售部门非负责人判定(老系统 getByIsAgent 内 saleFlag 逻辑,:703-710): * Redis db5 Dictionary hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不命中 "postId,"。 - * 字典缺失时按"不限制"处理并告警(与老系统 NPE 直接报错的行为不同,属于有意收敛)。 + * 字典缺失时拒绝访问,避免权限配置异常导致数据范围放宽。 */ public boolean isSalesNonLeader(User user) { if (user.getDeptId() == null || user.getPostId() == null) { @@ -114,8 +149,8 @@ public class PermissionService { Object authDeptId = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_FIELD_AUTH_DEPT_ID); Object authDeptPostId = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_FIELD_AUTH_DEPT_POST_ID); if (authDeptId == null || authDeptPostId == null) { - log.warn("Redis Dictionary 缺少 {}/{} 字段,销售部门限制未生效", DICT_FIELD_AUTH_DEPT_ID, DICT_FIELD_AUTH_DEPT_POST_ID); - return false; + log.error("Redis Dictionary 缺少 {}/{} 字段,拒绝访问业务数据", DICT_FIELD_AUTH_DEPT_ID, DICT_FIELD_AUTH_DEPT_POST_ID); + throw new IllegalStateException("销售数据权限配置不可用,拒绝访问业务数据"); } return authDeptId.toString().contains("," + user.getDeptId() + ",") && !authDeptPostId.toString().contains(user.getPostId() + ","); @@ -167,6 +202,9 @@ public class PermissionService { if (!isAgentAuthority(user.getAuthorityId())) { return; } + if (user.getAgentId() == null) { + throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); + } // ids 组装:下级 + 自己 + 代管(老 :682-702) List ids = new ArrayList<>(subordinateAndSelfIds(user)); List agentlist = managedAuths(user.getId()); diff --git a/xiaoqu-agent-service/src/main/resources/application-dev.yml b/xiaoqu-agent-service/src/main/resources/application-dev.yml index 1f42474..8574309 100644 --- a/xiaoqu-agent-service/src/main/resources/application-dev.yml +++ b/xiaoqu-agent-service/src/main/resources/application-dev.yml @@ -1,33 +1,33 @@ # dev 环境:内网 192.168.1.181(沿用 xiaoqu-franchisee pom.xml dev profile) -# 密码走环境变量占位符,默认值仅供本地 dev 使用 +# 账号和密码必须由环境变量提供;m0/m1/m2 应使用数据库只读账号 app: datasource: m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth) driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_comples_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true - username: root - password: ${M0_DB_PASSWORD:kaixinjiuhao} + username: ${M0_DB_USERNAME} + password: ${M0_DB_PASSWORD} m1: # 商城库 xiaoqu_mall_d(按需启用) driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_mall_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true - username: root - password: ${M1_DB_PASSWORD:kaixinjiuhao} + username: ${M1_DB_USERNAME} + password: ${M1_DB_PASSWORD} m2: # 业务库 xiaoqu_franchisee_d(warehouse_agent_auth 等) driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_franchisee_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true - username: root - password: ${M2_DB_PASSWORD:kaixinjiuhao} + username: ${M2_DB_USERNAME} + password: ${M2_DB_PASSWORD} svc: # 自有库 xiaoqu_agent_svc_d(token/scope/tool/audit,读写) driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_agent_svc_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true - username: root - password: ${SVC_DB_PASSWORD:kaixinjiuhao} + username: ${SVC_DB_USERNAME} + password: ${SVC_DB_PASSWORD} spring: data: redis: host: 192.168.1.181 port: 6379 - password: ${REDIS_PASSWORD:kaixinjiuhao} + password: ${REDIS_PASSWORD} database: 5 timeout: 5s diff --git a/xiaoqu-agent-service/src/main/resources/application.yml b/xiaoqu-agent-service/src/main/resources/application.yml index 7b2b142..f063d1d 100644 --- a/xiaoqu-agent-service/src/main/resources/application.yml +++ b/xiaoqu-agent-service/src/main/resources/application.yml @@ -4,8 +4,6 @@ server: spring: application: name: xiaoqu-agent-service - profiles: - active: dev ai: mcp: server: diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java index 4b2acd6..e7499bb 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java @@ -26,8 +26,8 @@ public class CodeGenerator { private static final String BASE_URL = "jdbc:mysql://192.168.1.181:3306/"; private static final String URL_PARAMS = "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; - private static final String USERNAME = "root"; - private static final String PASSWORD = System.getenv().getOrDefault("CODEGEN_DB_PASSWORD", "kaixinjiuhao"); + private static final String USERNAME = requiredEnv("CODEGEN_DB_USERNAME"); + private static final String PASSWORD = requiredEnv("CODEGEN_DB_PASSWORD"); private static final String OUTPUT_DIR = System.getProperty("user.dir") + "/src/main/java"; public static void main(String[] args) { @@ -39,6 +39,14 @@ public class CodeGenerator { "agent_api_key", "agent_scope", "tool", "scope_tool", "token_scope", "audit_log", "admin_user"); } + private static String requiredEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("缺少环境变量 " + name); + } + return value; + } + private static void generate(String schema, String module, String... tables) { FastAutoGenerator.create(BASE_URL + schema + URL_PARAMS, USERNAME, PASSWORD) .globalConfig(builder -> builder diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceTest.java new file mode 100644 index 0000000..0e832f4 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceTest.java @@ -0,0 +1,158 @@ +package me.iiv.agent.permission; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.Properyt; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PermissionServiceTest { + + @Mock + AuthorityMapper authorityMapper; + @Mock + AgentMapper agentMapper; + @Mock + WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock + AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock + ProperytMapper properytMapper; + @Mock + StringRedisTemplate redisTemplate; + @Mock + HashOperations hashOperations; + + PermissionService permissionService; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Agent.class); + permissionService = new PermissionService(authorityMapper, agentMapper, + warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void agentExactQueryKeepsSelfOrDirectSubordinateBoundary() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyAgentListRowFilter(wrapper, user, 79L); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("id =")); + assertTrue(sql.contains("supervisor_id =")); + assertTrue(values.contains(79L)); + assertTrue(values.contains(1L)); + } + + @Test + void headquartersExactQueryDoesNotAddAgentBoundary() { + User user = user(46L, 1L, 0L); + when(authorityMapper.selectById(1L)).thenReturn(authority(1L, 0)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyAgentListRowFilter(wrapper, user, 79L); + + assertTrue(wrapper.getSqlSegment().contains("id =")); + assertFalse(wrapper.getSqlSegment().contains("supervisor_id =")); + } + + @Test + void missingAuthorityIsDeniedInsteadOfTreatedAsHeadquarters() { + when(authorityMapper.selectById(999L)).thenReturn(null); + + assertThrows(IllegalStateException.class, + () -> permissionService.isAgentAuthority(999L)); + } + + @Test + void deletedAuthorityIsDeniedInsteadOfTreatedAsHeadquarters() { + Authority authority = authority(108L, 1); + authority.setSoftDelete(1); + when(authorityMapper.selectById(108L)).thenReturn(authority); + + assertThrows(IllegalStateException.class, + () -> permissionService.isAgentAuthority(108L)); + } + + @Test + void unknownAuthorityModeIsDeniedInsteadOfTreatedAsHeadquarters() { + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 2)); + + assertThrows(IllegalStateException.class, + () -> permissionService.isAgentAuthority(108L)); + } + + @Test + void agentUserWithoutAgentIdIsDenied() { + User user = user(1000L, 108L, null); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + + assertThrows(IllegalStateException.class, + () -> permissionService.applyAgentListRowFilter(new LambdaQueryWrapper<>(), user, 79L)); + } + + @Test + void agentUserWithoutAgentIdIsDeniedForPropertyList() { + User user = user(1000L, 108L, null); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + + assertThrows(IllegalStateException.class, + () -> permissionService.applyPropertyRowFilter(new LambdaQueryWrapper(), user)); + } + + @Test + void missingSalesDictionaryIsDeniedInsteadOfDisablingFilter() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(null); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn(null); + + assertThrows(IllegalStateException.class, + () -> permissionService.isSalesNonLeader(user)); + } + + private static Authority authority(Long id, int isAgent) { + Authority authority = new Authority(); + authority.setId(id); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } + + private static User user(Long id, Long authorityId, Long agentId) { + User user = new User(); + user.setId(id); + user.setAuthorityId(authorityId); + user.setAgentId(agentId); + return user; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AkAuthFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AkAuthFilterTest.java new file mode 100644 index 0000000..2e34aa0 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AkAuthFilterTest.java @@ -0,0 +1,66 @@ +package me.iiv.agent.security; + +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AkAuthFilterTest { + + @Mock + AuthService authService; + + @AfterEach + void clearContext() { + AuthContextHolder.clear(); + } + + @Test + void missingBearerTokenReturnsUnauthorized() throws Exception { + AkAuthFilter filter = new AkAuthFilter(authService); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(401, response.getStatus()); + assertNull(chain.getRequest()); + verify(authService, never()).resolve(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void validBearerTokenSetsAndClearsContext() throws Exception { + AgentApiKey token = new AgentApiKey(); + token.setId(7L); + User user = new User(); + user.setId(1000L); + AuthContext context = new AuthContext(token, user); + when(authService.resolve("valid-ak")).thenReturn(context); + + AkAuthFilter filter = new AkAuthFilter(authService); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + request.addHeader("Authorization", "Bearer valid-ak"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertSame(request, chain.getRequest()); + assertNull(AuthContextHolder.get()); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ConfigurationSecurityTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ConfigurationSecurityTest.java new file mode 100644 index 0000000..5578da8 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ConfigurationSecurityTest.java @@ -0,0 +1,40 @@ +package me.iiv.agent.security; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class ConfigurationSecurityTest { + + private static final Pattern PASSWORD_WITH_DEFAULT = + Pattern.compile("password:\\s*\\$\\{[^}:]+:[^}]+}"); + private static final Pattern COMMITTED_AK = + Pattern.compile("xqak_[0-9a-f]{20,}"); + + @Test + void applicationDoesNotActivateDevByDefault() throws IOException { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + + assertFalse(application.contains("active: dev")); + } + + @Test + void devConfigurationHasNoRootUserOrPasswordDefaults() throws IOException { + String application = Files.readString(Path.of("src/main/resources/application-dev.yml")); + + assertFalse(application.contains("username: root")); + assertFalse(PASSWORD_WITH_DEFAULT.matcher(application).find()); + } + + @Test + void devSeedContainsNoReusablePlaintextAk() throws IOException { + String seed = Files.readString(Path.of("sql/seed-dev.sql")); + + assertFalse(COMMITTED_AK.matcher(seed).find()); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ToolGuardTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ToolGuardTest.java new file mode 100644 index 0000000..0c68486 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/ToolGuardTest.java @@ -0,0 +1,100 @@ +package me.iiv.agent.security; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.entity.svc.ScopeTool; +import me.iiv.agent.entity.svc.TokenScope; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.mapper.svc.ScopeToolMapper; +import me.iiv.agent.mapper.svc.TokenScopeMapper; +import me.iiv.agent.mapper.svc.ToolMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("unchecked") +class ToolGuardTest { + + @Mock + TokenScopeMapper tokenScopeMapper; + @Mock + ScopeToolMapper scopeToolMapper; + @Mock + ToolMapper toolMapper; + @Mock + AgentScopeMapper agentScopeMapper; + + @Test + void tokenWithoutScopeIsDenied() { + when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + ToolGuard guard = guard(); + + assertFalse(guard.isAllowed(7L, "agent.list.query")); + verify(scopeToolMapper, never()).selectList(any()); + } + + @Test + void disabledScopeIsDenied() { + when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L))); + when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + ToolGuard guard = guard(); + + assertFalse(guard.isAllowed(7L, "agent.list.query")); + verify(scopeToolMapper, never()).selectList(any()); + } + + @Test + void enabledScopeAndToolAreAllowed() { + when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L))); + when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L))); + when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L))); + when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L); + ToolGuard guard = guard(); + + assertTrue(guard.isAllowed(7L, "agent.list.query")); + } + + @Test + void disabledToolIsDenied() { + when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L))); + when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L))); + when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L))); + when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L); + ToolGuard guard = guard(); + + assertFalse(guard.isAllowed(7L, "agent.list.query")); + } + + private ToolGuard guard() { + return new ToolGuard(tokenScopeMapper, scopeToolMapper, toolMapper, agentScopeMapper); + } + + private static TokenScope tokenScope(Long scopeId) { + TokenScope tokenScope = new TokenScope(); + tokenScope.setScopeId(scopeId); + return tokenScope; + } + + private static AgentScope scope(Long id) { + AgentScope scope = new AgentScope(); + scope.setId(id); + return scope; + } + + private static ScopeTool scopeTool(Long toolId) { + ScopeTool scopeTool = new ScopeTool(); + scopeTool.setToolId(toolId); + return scopeTool; + } +}