
之前维护过的一个后台管理系统上线第三周突然被运营负责人找上订单列表不同角色进去看到的数据必须不一样。运营专员只能看自己创建的工单运营主管能看本小组的部门负责人能看整个部门财务要能看到全公司。菜单权限、按钮权限当时都做完了唯独这个“行级数据可见性”是从零开始。当时我就意识到SpringBoot 数据权限这件事如果处理不好后面每个查询接口都要背上巨大的维护成本。后来用“注解 动态SQL”的组合把这个问题收口了业务代码里看不到任何权限判断Mapper层依然写的是一句干干净净的查询真正改SQL的工作全部由拦截器和切面接管。效果非常明显代码量、维护难度、排查成本都降了一个量级用东北话讲真香。这篇文章不打算从 RBAC、权限模型论文讲起直接聊方案和落地。我会把为什么要动态拼接、注解里到底放什么信息、AOP怎么算权限范围、MyBatis 插件怎么改 SQL以及我上线后踩过的几个坑全部写出来。适合那些项目里数据权限已经失控、或者正准备给系统加行级隔离的读者尤其适合 SpringBoot MyBatis 这个技术栈的团队。1. 同样一个列表接口不同账号看到的行为什么不一样1.1 菜单权限管“能不能点”数据权限管“能看到哪几行”很多团队一开始会把数据权限和登录权限混在一起总觉得把菜单一藏、按钮一灰权限就“天然”隔离了。但用户永远是从业务角度理解权限的他能看到一个页面不代表他该看到这个页面里的所有数据。举一个很常见的例子。订单列表这个接口后端逻辑一模一样但不同角色进去SQL 里的 WHERE 条件必须不一样销售只能看到create_by 当前用户ID的订单销售经理要看到部门下的所有订单财务则不需要任何额外过滤。菜单权限解决的是“他能不能访问/order/list这个URL”数据权限解决的是“同一个/order/listSQL 执行时给他限多大的范围”。这两者的实现层级完全不同。菜单权限挂在路由、Controller、按钮API上是功能级别的数据权限挂在查询执行的底层是行级别的。你可以在 Controller 前做拦截但绝对不能在每个 Service 方法里到处写if (user.getRole().equals(xx))来判断该拼什么条件。那样等于把权限规则散落到业务代码的每一个角落后面任何一个查询漏写了判断就是一次实打实的数据越权。这也是为什么后来大家倾向于做一个独立的权限注入层所有数据权限相关的代码收敛到一个地方业务方只负责在需要过滤的查询方法上声明一下剩下的由框架统一处理。1.2 硬编码权限条件的三种土办法为什么后面都想删掉我自己的项目里最原始的写法是这样Service 层拿到用户角色然后分叉成两个 Mapper 方法。if (user.hasRole(SALES)) { orderMapper.selectByUserId(user.getId(), page); } else if (user.hasRole(SALES_MANAGER)) { orderMapper.selectByDeptId(user.getDeptId(), page); } else { orderMapper.selectAll(page); }这种写法最大的问题是“接口爆炸”。角色一变多Mapper 方法成倍增长。今天多一个“区域经理”你就要再加一个selectByRegionId明天说销售也要看自己产品的数据又要改。而且权限规则一旦和业务查询耦合在一起代码Review的时候根本没人敢动因为一个判断分支错了数据就看到别人那里去了。第二种土办法更离谱查询不分权限查出全量数据后在内存里用stream().filter()过滤。小数据量的时候看起来挺“优雅”但表一超过十万行接口直接被打回原形数据库的压力全变成了应用内存的压力。这种方案在真实业务里撑不过三个月。第三种是写在 MyBatis XML 里。每个查询下面挂一大段if test...代码里可能看着还算集中但 XML 文件一旦膨胀起来权限条件会和各种业务条件混在一起缩进、嵌套、括号改错一处就是事故。更关键的是你没有办法跨 Mapper 复用同一套权限规则同一条“只看本部门”的逻辑在每个 XML 里都要复制一遍。后来才发现缺的不是更优雅的 if 写法而是缺少一个能在 SQL 执行前统一改写 WHERE 的机制。2. 方案分工注解声明范围AOP解析身份拦截器改写SQL2.1 一次带权限过滤的查询经过了哪几个加工环节先说清楚这套方案的完整链路。业务代码里写的是GetMapping(/list) public PageResultOrderVO list(OrderQuery query) { return orderMapper.selectPage(query); }Mapper 接口上标注一行注解DataScope(type DataScopeType.DEPT_AND_CHILD, alias o) PageOrderVO selectPage(OrderQuery query);这里说的“动态SQL”并不是 MyBatis XML 里那个if、choose的动态标签而是指一条 SQL 在构造完成后、执行之前被拦截器在运行期追加上了权限条件片段。整条加工链路大概是这样的请求进入 Service调用被DataScope标注的 Mapper 方法。Spring AOP 切面拦截到这次调用从登录态拿到当前用户。切面根据注解里的type算出用户当前可见的数据范围比如“本部门及子部门”落到一个具体的部门ID集合。切面把集合拼成一个 SQL 片段例如o.dept_id IN (1001, 1002)写入一个临时上下文。Mapper 方法真正执行MyBatis 拦截器或者 MyBatis-Plus 的插件在 SQL 执行前拦截它。插件把上下文里的权限片段和原 SQL 的 WHERE 条件合并最终得到一条带完整权限过滤的 SQL。整个过程里Service 方法本身不知道发生了什么业务代码完全不用感知权限规则的存在。这也是我推荐它的核心原因数据权限被抽象成了一条流水线每个环节只干一件事。2.2 数据范围抽象成四种枚举就够了别在业务里到处写判断聊权限范围时最怕的是业务方说“我们的权限很复杂要支持任意组合”。真做了才知道90% 的系统需要的范围就四种全员可见不需要过滤仅本人只看自己创建/负责的数据本部门部门内的人互相可见本部门及子部门适合组织架构有层级的管理者。我把它们定义成一个枚举public enum DataScopeType { ALL, SELF_ONLY, DEPT_ONLY, DEPT_AND_CHILD }为什么不要支持“任意树节点的可见范围”因为用户能看到的组织树范围和数据表里的部门归属往往不是同一个模型。比如有个角色能看到“华东大区”的数据而这个大区在组织架构里是“上海公司”“杭州公司”“南京公司”的父节点。你如果在每个业务查询里去判断“他属于哪个节点、这个节点下面挂哪些叶子”那这个逻辑就得在几十个 Service 里复制。把它抽象成枚举以后切面统一处理根据当前用户所属部门向上找角色配置的范围类型向下收集子部门ID集合。组织架构变了只改公共服务业务代码一行不动。面试的时候有人问我“那如果就是有角色需要看华东大区加上苏州团队怎么抽象”我的答案是把“可见范围”本身做成可配置的数据放在角色表里。AOP 解析的不是枚举值而是从角色配置表里读出的一段可见范围描述切换枚举只是默认实现。这个我放到后面第五节细说。2.3 为什么是三件套而不是直接在 Service 里拼 SQL有人可能觉得搞注解、搞切面、搞拦截器是不是过度设计直接在 Service 里手动拼一个queryWrapper或者拼接 SQL 不好吗不好。手拼 SQL 的问题在于“分散”。今天在订单查询里拼一段dept_id in (...)明天在客户查询里又拼一遍权限规则就没有单一可信来源了后面做审计、做权限回收都无从下手。而注解的特点是“声明式”它把一个需求精确地表达在方法上这个查询需要做行级过滤范围类型是什么主表别名是什么。IDE 能搜代码评审能看运行期也能通过反射拿到。AOP 切面解决的是“身份解析”。数据权限最关键的一步是把当前用户变成一组可见的ID集合这涉及角色、部门、组织架构的查询逻辑不算短。放在切面里它就只被处理一次放在业务代码里每个 Service 都要引用一遍。拦截器才是真正“改写SQL”的地方。MyBatis 在执行前会生成 BoundSql拦截器拿到它把权限片段合并进 WHERE再执行。这一步的好处是 Mapper XML 里不需要有任何权限判断SQL 的主体逻辑保持原样权限条件只是被“附加”进去的。天然适配所有的查询方法包括 XML 里写死的复杂 SQL只要条件里用到的字段存在于结果集查询中都能覆盖。3. 动手落地枚举、注解、切面与MyBatis插件的完整实现3.1 先定义清楚范围枚举和注解参数的含义结合上面的分析我把注解设计成这样Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataScope { /** * 查询的主表别名多表关联时必须写否则条件不知道挂在哪个表上 */ String alias() default ; /** * 数据范围类型 */ DataScopeType type() default DataScopeType.DEPT_AND_CHILD; /** * 部门字段名默认 dept_id */ String deptField() default dept_id; /** * 用户字段名默认 user_id */ String userField() default user_id; }为什么必须传alias因为你不知道 Mapper 里写的 SQL 是什么结构。有可能这条查询只查一张表字段名没有歧义但真实项目里几乎每个列表都是select o.* from t_order o left join t_order_item i ...。如果不带别名拼出来的条件可能被数据库理解成用了别的表的字段直接报“ambiguous column”更危险的是没报错但条件挂错了表权限过滤形同虚设。为了方便演示我这里定一个简化的登录用户对象public class LoginUser { private Long userId; private Long deptId; private SetLong roleIds; // getter/setter 省略 }3.2 AOP切面把当前账号能看的数据算成一个SQL片段切面是“权限计算”的核心。先说一个业务细节即使范围是“本部门及子部门”也一定要叠加“本人创建的数据永远可见”。这是我在真实项目里吃过亏的地方。一个人从A部门调到B部门之后他以前创建的工单仍然归属A部门如果新部门下看不到他本人创建的历史数据业务上会认为“系统把工单弄丢了”。所以拼条件时部门范围必须和本人条件用 OR 连接。代码如下Aspect Component public class DataScopeAspect { Around(annotation(dataScope)) public Object handle(ProceedingJoinPoint point, DataScope dataScope) throws Throwable { try { LoginUser loginUser LoginHelper.getCurrentUser(); if (loginUser null) { return point.proceed(); } String segment buildSegment(dataScope, loginUser); DataScopeContext.setSegment(segment); return point.proceed(); } finally { DataScopeContext.clear(); } } private String buildSegment(DataScope dataScope, LoginUser loginUser) { switch (dataScope.type()) { case ALL: return null; case SELF_ONLY: return ( dataScope.alias() . dataScope.userField() loginUser.getUserId() ); case DEPT_ONLY: return ( dataScope.alias() . dataScope.deptField() IN ( String.join(,, VisibleDeptService.getVisibleDeptIds(loginUser, dataScope.type()).stream() .map(String::valueOf).toList()) )); case DEPT_AND_CHILD: default: ListLong deptIds VisibleDeptService.getVisibleDeptIds(loginUser, DataScopeType.DEPT_AND_CHILD); String deptCondition dataScope.alias() . dataScope.deptField() IN ( deptIds.stream().map(String::valueOf).collect(Collectors.joining(,)) ); String userCondition dataScope.alias() . dataScope.userField() loginUser.getUserId(); return ( deptCondition OR userCondition ); } } }这里要注意几个点。第一权限片段里的值都是根据登录用户算出来的后端数据不是前端传入的字符串所以拼接不会有 SQL 注入问题。第二片段一定要整体加括号后面讲到被 OR 支配的事故时你会理解为什么。第三VisibleDeptService是专门负责组织架构查询的公共服务它要做的事很简单拿着当前用户的部门ID递归或者用树表查询把所有子部门ID收集起来。部门层级深的话这个查询本身要做缓存否则每个接口请求都会多一次数据库IO。ThreadLocal 上下文是我自己写的非常轻public class DataScopeContext { private static final ThreadLocalString SEGMENT new ThreadLocal(); private DataScopeContext() {} public static void setSegment(String segment) { SEGMENT.set(segment); } public static String getSegment() { return SEGMENT.get(); } public static void clear() { SEGMENT.remove(); } }注意finally里的clear()是必须的不是可选项。Spring MVC 的请求线程会被线程池复用ThreadLocal 的值不清掉下一个请求就会拿到上一个用户的权限片段。这个坑我单独放在第四节讲。3.3 MyBatis拦截器和MyBatis-Plus插件的落地写法如果项目用的是原生 MyBatis需要自己写一个 MyBatis 拦截器。拦截的目标是StatementHandler的prepare方法在 SQL 真正发送到数据库前把BoundSql里的 SQL 字符串改掉。Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) Component public class DataScopeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); String sql boundSql.getSql(); String segment DataScopeContext.getSegment(); if (StringUtils.hasText(segment) sql.trim().toLowerCase().startsWith(select)) { String finalSql InjectPermissionUtil.inject(sql, segment); Field sqlField BoundSql.class.getDeclaredField(sql); sqlField.setAccessible(true); sqlField.set(boundSql, finalSql); } return invocation.proceed(); } }核心难点在inject方法。最简版本是这样public static String inject(String sql, String segment) { String lower sql.toLowerCase(); int whereIndex lower.lastIndexOf(where); int orderByIndex lower.lastIndexOf(order by); String permission AND segment; if (whereIndex 0) { if (orderByIndex 0) { return sql WHERE segment; } return sql.substring(0, orderByIndex) WHERE segment sql.substring(orderByIndex); } return sql.substring(0, whereIndex 5) permission sql.substring(whereIndex 5); }这段代码只能演示原理。真实的 SQL 里可能有GROUP BY、HAVING、子查询、嵌套 JOIN字符串替换稍不留心就会改坏位置。所以如果是生产项目我强烈建议直接用 SQL 解析器来做或者直接上 MyBatis-Plus 的DataPermissionInterceptor它底层用的是 JSqlParser能把 SQL 解析成语法树在正确的位置挂载条件。MyBatis-Plus 的写法要优雅得多。配置一个拦截器Configuration public class DataScopeMybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new DataPermissionInterceptor(new DataScopeHandler())); return interceptor; } static class DataScopeHandler implements DataPermissionHandler { Override public Expression getSqlSegment(Expression where, String mappedStatementId) { String segment DataScopeContext.getSegment(); if (!StringUtils.hasText(segment)) { return where; } try { Expression permission CCJSqlParserUtil.parseCondExpression(segment); if (where null) { return permission; } return new AndExpression(where, permission); } catch (JSQLParserException e) { throw new RuntimeException(数据权限SQL解析失败: segment, e); } } } }这段代码我有必要做几个补充说明。getSqlSegment接收两个参数第一个是 MyBatis-Plus 自己解析好的 WHERE 条件第二个是当前执行的 Mapper 方法ID。理论上你可以在方法ID上做更细粒度的控制比如某个方法不想被数据权限干预直接返回where原样放行。我项目里用过的版本是 3.5.4DataPermissionHandler接口的方法签名就是这样。新版可能会有ignoreTable之类的默认方法覆盖getSqlSegment依然是最核心的入口具体以你依赖的版本为准。只要执行到 Mapper 的这一条链路上存在DataScopeContext里的片段拦截器就会把它和业务 WHERE 条件用AND连接起来。到这里一个“注解 切面 拦截器”的数据权限组件就算完整了。4. 上线后的坑从多表别名到ThreadLocal串号的六次翻车4.1 自调用失效同一个类里this方法调用让切面失灵最早踩的坑出现在 AOP 上。我在一个 Service 里写了两个方法methodA调用methodBDataScope标在methodB上结果那段时间线上接口疯狂越权查了好久才发现切面根本没触发。原因很简单Spring AOP 用的是代理模式外部调用methodA时拿到的其实是一个代理对象但methodA内部的this.methodB()是真实对象上的直接调用根本没有经过代理注解自然失效。解决办法有几个。如果你用的是 Spring 4.3可以注入ObjectProviderService让自己代理自己或者把methodB挪到另一个 Bean 里面通过跨 Bean 的调用触发 AOP再或者用AopContext.currentProxy()显式取代理。总之记住一条带数据权限注解的方法绝对不要在同类的内部方法里直接this调用。这个坑在代码评审里非常隐蔽因为它 “看起来” 很正常只有切面和代理的底层机制才能解释。4.2 括号缺失OR条件让权限形同虚设的一次事故权限条件拼出来以后合并时括号特别容易出问题。当时我的权限片段长这样o.dept_id IN (1001, 1002) OR o.user_id 3业务 WHERE 长这样o.status 1拦截器用AND拼起来以后整条 SQL 变成了SELECT ... FROM t_order o WHERE o.status 1 AND o.dept_id IN (1001, 1002) OR o.user_id 3数据库的运算符优先级往往不是从左边按顺序读而是AND先结合。整条条件解析成了(o.status 1 AND o.dept_id IN (...)) OR o.user_id 3于是任何一个user_id等于 3 的用户无论订单状态是不是 1哪怕部门完全不在范围内都能看到数据。权限直接被这个 OR 绕开了。当时是某报表金额对不上追查了两天才定位到是拼接公式少了一对括号。所以我在切面的buildSegment里整整给整个片区包了一层括号这样无论AndExpression怎么合并它永远是一个独立单元(o.dept_id IN (1001, 1002) OR o.user_id 3)再去和前一个条件做AND的连接才真正安全。以后的评审里只要看到拼权限条件第一件事就是检查括号的完整性和边界。4.3 别名不一致多表关联时条件落错表第一次把DataScope铺到全项目时发现有些 Mapper 报“unknown column o.dept_id”有些 Mapper 根本不报错但查出来的数据过滤效果不对。逐个排查才发现问题出在别名。有的 SQL 写的是select * from t_order o join ...别名是o注解里也写的是o正常有的 SQL 直接没写别名select * from t_order join ...我拼的o.dept_id自然就报错还有的 SQL 居然给订单表起了别名a我注解里还是写的o条件挂到了另一张表的字段上权限彻底失效。这个问题的根治方案是管好约定所有需要做数据权限的表在 SQL 里必须带一个固定的主表别名并且与该表相关的查询统一用这个别名。注解里直接写死故意不让它灵活。可能有人觉得这样限制太硬但现实是“灵活”在权限场景里约等于事故。如果表结构里根本没有dept_id这个字段比如历史业务表那要么在表设计上补列要么干脆别给这个查询加注解。牢记数据权限过滤的前提是数据模型里存在被过滤的字段。4.4 分页Count不同步总数比实际可见记录大用 MyBatis-Plus 做分页时框架会自动生成一条COUNT查询。最初用纯 MyBatis 拦截器版本时我只改了原始查询 SQL没管那条COUNT查询。结果列表数据是对的但分页 total 永远是全表的总数页面上的总页数比实际能翻的多出好几页用户拼命往下翻越到后面越空白。这个坑的本质是“同一份权限条件没有应用于所有派生 SQL”。MyBatis-Plus 的DataPermissionInterceptor在设计时把 COUNT 查询和主查询放在同一条解析链路里处理所以不会再出这个问题。这也是我后来果断把团队方案迁到 MyBatis-Plus 插件上的原因不是懒而是它把这些边角逻辑都兜住了。如果你还在自己写拦截器请一定记得改写 SQL 的入口不仅要处理主查询还要处理同一次执行里所有通过BoundSql扩展生成的其他 SQL比如 COUNT、批量操作中的关联语句。4.5 ThreadLocal串号线程池复用导致A看到了B的数据这可能是所有数据权限方案里最要命的坑。最初我把DataScopeContext.clear()写在切面的afterReturning里结果有一次接口抛了异常afterReturning不执行ThreadLocal 里的权限片段就一直留在当前线程上。Spring Boot 应用的线程池会长期复用线程下一个请求碰巧被分配到这条线程时它读到的还是上一个用户留下的dept_id IN (...) OR user_id ...条件结果自然就是 A 用户看到了 B 用户的数据。这种问题非常难复现因为不是每次请求都出问题只有恰好走到那几条脏线程时才出现。修复方案是立刻改成try/finally在finally里clear()同时在 Web 层的Filter或拦截器最前端加一个兜底clear()保证即使切面因为某种奇怪的原因没有走到ThreadLocal 也不会残留。另外如果你的代码里有异步线程、线程池任务那就要特别注意ThreadLocal本身不会跨线程传递必须把片段作为参数显式传给异步任务而不是在子线程里直接get。4.6 大IN集合几百个子部门拼出的SQL把数据库拖垮“本部门及子部门”听着很合理可现实是组织架构动不动十几层子部门几百个每次拼接出来的 IN 列表能有一千多个IDSQL 字符串本身有几十 KB数据库执行的时候可能直接放弃索引走全表扫描接口响应时间从几十毫秒飙到两秒。针对这个问题我分了两层处理。如果集合数量在可控范围内比如几千以内直接在IN列表上做但要确保权限字段上有索引。如果组织规模特别大就不要在 SQL 里写大IN了改为“可见范围表”方案把“用户 可见部门/可见业务范围”提前物化成一张表查询时用EXISTS (SELECT 1 FROM visible_scope vs WHERE vs.user_id 当前用户 AND vs.biz_id o.id)来过滤。这样虽然多一次 EXISTS 判断但走唯一索引反而更稳定也避免了超长 SQL 对数据库解析器的压力。普通后台系统一般不会遇到这个瓶颈但如果你所在的业务是集团级组织架构强烈建议第一次做权限范围核算时就评估好到底是 IN 还是 EXISTS。5. 数据权限还能怎么扩展以及这套方案的适用边界5.1 把数据范围做成配置表管理员自己在界面维护上面用的枚举只是默认模型业务复杂以后一定要把它升级成配置化。最经典的建模是四张表t_user用户表t_role角色表t_role_data_scope角色数据范围表存data_scope_type和可见部门集合t_user_dept用户部门关系可能有多部门管理员在后台给角色配置“数据范围”时看到的选项就是“全部/本部门/本部门及子部门/仅本人”另外还可以勾选可额外访问的部门。AOP 切面里解析身份时先查角色配置再查可见部门集合最后拼出的片段和前面完全一样。这样做的好处是业务方改权限不再需要发版运营人员自己就能操作。唯一的代价是要把角色数据范围查询加上缓存。切面是高频入口如果每个请求都查一次角色配置表数据库压力是肉眼可见的我当时就用本地缓存加了个几分钟的过期时间角色权限生效的即时性要求比较高的话可以考虑 Redis 缓存加主动失效不过这是另一个话题了。5.2 同时按多个维度过滤注解字段怎么设计有的数据不止按部门隔离还要叠加产品线。比如某个合同表既要按部门过滤又要按产品线过滤。这种场景最简单的是在注解里再加一个字段String bizField() default ;AOP 拼接时如果bizField不为空再多拼一个条件进去。两个条件之间用AND连接。不过我建议不要搞得太多实际项目里我见过有人在注解里加了五个字段最后自己都忘了哪些查询会带哪些条件维护成本非常高。数据权限的维度控制在两个以内比较合适超过两个就应该考虑是不是把“可见性”从业务表里拆出来单独建模了。5.3 边界报表宽表、跨库与全量导出不该用这套任何方案都有边界数据权限这套也不例外。我踩过的反向教训是报表系统里有一张几十个字段的大宽表是数仓同步过来的团队天真地想用同一套注解做行级过滤结果拼接出来的条件严重干扰了执行计划明明该走索引的查询变成了全表扫描。后来改成在报表的查询服务里单独处理权限而不是让拦截器无脑注入。跨库查询也一样。MyBatis 拦截器只能改当前数据源里执行的 SQL如果你的查询是跨库联邦查询后半段在另一个数据库上执行的逻辑拦截器根本管不到。低频的管理员全量导出也不要加数据权限导出本身就是管理者行为过滤掉数据反而让管理者困惑“为什么导出来缺数据”。这套方案最适合的就是业务系统内部的普通列表查询、详情查询、分页查询以及那些需要高频执行且数据模型里有明确部门ID/用户ID字段的场景。字段级脱敏比如手机号中间四位打码、身份证号隐藏是完全不同的维度不要和数据权限混在一套拦截器里做会变得非常难维护。最后分享一个落地顺序上的体会。别一上来就全项目铺注解先挑两三个高频查询接口试点在切面里把改写前后的 SQL 打出来拿到测试库EXPLAIN一遍确认条件和索引能匹配上再逐步推广。权限片段拼上去只是起点真正要盯的是之后每个新写的 Mapper 有没有漏标注解。漏一个查询接口可能就意味着一次安静的数据越权。我的做法是在代码评审里给“涉及多用户可见性数据的查询”加了一条硬性约定必须显式标注DataScope或者确认不需要过滤后写清楚原因。这套机制跑了大半年新增接口再没出过越权事故。