MyBatis Plus 拦截器实现数据权限控制

一、介绍

上篇文章介绍的MyBatis Plus 插件实际上就是用拦截器实现的,MyBatis Plus拦截器对MyBatis的拦截器进行了包装处理,操作起来更加方便

二、自定义拦截器

2.1、InnerInterceptor

MyBatis Plus提供的InnerInterceptor接口提供了如下方法,主要包括:在查询之前执行,在更新之前执行,在SQL准备之前执行
在这里插入图片描述

2.2、编写简易拦截器

package com.xx.config;

import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
import com.baomidou.mybatisplus.extension.parser.JsqlParserSupport;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.StringValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
import net.sf.jsqlparser.expression.operators.relational.ItemsList;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SelectBody;
import net.sf.jsqlparser.statement.update.Update;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.springframework.stereotype.Component;

import java.sql.Connection;

/**
 * @author aqi
 * @date 2023/5/17 15:07
 */
@Slf4j
@Component
public class TestInterceptor extends JsqlParserSupport implements InnerInterceptor {

    @Override
    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
        // 这里固定这么写就可以了
        PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
        MappedStatement ms = mpSh.mappedStatement();
        if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
            return;
        }
        PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();
        mpBs.sql(parserMulti(mpBs.sql(), null));
    }

    /**
     * 该方法由JsqlParserSupport提供,主要用于通过API的方式操作SQL
     * 思路:通过API构建出新的条件,并将新的条件和之前的条件拼接在一起
     */
    @Override
    protected void processSelect(Select select, int index, String sql, Object obj) {
        // 解析SQL
        SelectBody selectBody = select.getSelectBody();
        PlainSelect plainSelect = (PlainSelect) selectBody;

        // 构建eq对象
        EqualsTo equalsTo = new EqualsTo(new Column("name"), new StringValue("tom"));
        // 将原来的条件和新构建的条件合在一起
        AndExpression andExpression = new AndExpression(plainSelect.getWhere(), equalsTo);
        // 重新封装where条件
        plainSelect.setWhere(andExpression);
    }


    @Override
    protected void processInsert(Insert insert, int index, String sql, Object obj) {
        insert.getColumns().add(new Column("name"));
        ((ExpressionList) insert.getItemsList()).getExpressions().add(new StringValue("tom"));
    }

    @Override
    protected void processUpdate(Update update, int index, String sql, Object obj) {
        update.addUpdateSet(new Column("name"), new StringValue("tom"));
    }

    @Override
    protected void processDelete(Delete delete, int index, String sql, Object obj) {
        // 删除新增条件和查询一样,不做演示
    }
}


2.3、将拦截器添加到MyBatis Plus拦截器中

package com.xx.config;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author aqi
 * @date 2023/5/15 14:05
 */
@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        // 初始化Mybatis Plus拦截器
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new TestInterceptor());
        return interceptor;
    }
}

2.4、编写测试用例

@Test
    void save() {
        AirlinesInfo airlinesInfo = new AirlinesInfo();
        airlinesInfo.setInfo("remark");
        airlinesInfoService.save(airlinesInfo);
    }

    @Test
    void update() {
        AirlinesInfo airlinesInfo = new AirlinesInfo();
        airlinesInfo.setId(1L);
        airlinesInfo.setInfo("remark, remark");
        airlinesInfoService.updateById(airlinesInfo);
    }

    @Test
    void select() {
        airlinesInfoService.list();
    }

2.5、执行结果

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

三、自定义拦截器实现数据权限控制

3.1、编写拦截器

package com.xx.config;

import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
import com.baomidou.mybatisplus.extension.parser.JsqlParserSupport;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.xx.entity.Permission;
import com.xx.utils.ExpressionUtils;
import com.xx.utils.UserUtils;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SelectBody;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.springframework.stereotype.Component;

import java.sql.Connection;

/**
 * @author xiaxing
 */
@Slf4j
@Component
public class DataScopeInterceptor extends JsqlParserSupport implements InnerInterceptor {

    @Override
    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
        log.info("[DataScopeInterceptor]beforePrepare...");
        PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
        MappedStatement ms = mpSh.mappedStatement();
        SqlCommandType sct = ms.getSqlCommandType();
        if (sct == SqlCommandType.INSERT || sct == SqlCommandType.SELECT) {
            if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
                return;
            }
            PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();
            mpBs.sql(parserMulti(mpBs.sql(), null));
        }
    }

    /**
     * 查询
     */
    @Override
    protected void processSelect(Select select, int index, String sql, Object obj) {
        SelectBody selectBody = select.getSelectBody();
        PlainSelect plainSelect = (PlainSelect) selectBody;

        // 获取表名/别名(如果是关联查询是取第一个join左侧的表名/别名)
        String tableName = ExpressionUtils.getTableName(plainSelect);

        // 构建用户权限控制条件
        Expression userPermissionExpression = this.buildUserPermissionSql(tableName);
        if (null != userPermissionExpression) {
            // 将sql原本就有得where条件和新构建出来的条件拼接起来
            plainSelect.setWhere(ExpressionUtils.appendExpression(plainSelect.getWhere(), userPermissionExpression));
        }

    }

    /**
     * 构建用户权限控制条件
     * @param tableName 表名/别名(join查询左侧表名)
     */
    private Expression buildUserPermissionSql(String tableName) {
        // 获取当前用户信息(这里的数据都是模拟的,实际上可能得从缓存或者session中获取)
        Permission permission = UserUtils.getUserPermission();
        return null != permission ? ExpressionUtils.buildInSql(tableName + "."  + permission.getField(), permission.getValue()) : null;
    }

}

3.2、编写构建SQL工具类

package com.xx.utils;

import net.sf.jsqlparser.expression.Alias;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.StringValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.expression.operators.relational.ItemsList;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.select.PlainSelect;

import java.util.Set;
import java.util.stream.Collectors;

/**
 * @author aqi
 * @date 2023/5/17 10:16
 * @describe JSqlParser工具类,用于通过API的方式操作SQL语句
 */
public class ExpressionUtils {

    /**
     * 构建in sql
     * @param columnName 字段名称
     * @param params 字段值
     * @return InExpression
     */
    public static InExpression buildInSql(String columnName, Set<String> params) {
        // 把集合转变为JSQLParser需要的元素列表
        ItemsList itemsList = new ExpressionList(params.stream().map(StringValue::new).collect(Collectors.toList()));
        // 创建IN表达式对象,传入列名及IN范围列表
        return new InExpression(new Column(columnName), itemsList);
    }

    /**
     * 构建eq sql
     * @param columnName 字段名称
     * @param value 字段值
     * @return EqualsTo
     */
    public static EqualsTo buildEq(String columnName, String value) {
       return new EqualsTo(new Column(columnName), new StringValue(value));
    }

    /**
     * 获取表名/别名
     * @param plainSelect plainSelect
     * @return 表名/别名
     */
    public static String getTableName(PlainSelect plainSelect) {
        // 获取别名
        Table table= (Table) plainSelect.getFromItem();
        Alias alias = table.getAlias();
        return null == alias ? table.getName() : alias.getName();
    }

    /**
     * 将2个where条件拼接到一起
     * @param where 条件
     * @param appendExpression 待拼接条件
     * @return Expression
     */
    public static Expression appendExpression(Expression where, Expression appendExpression) {
        return null == where ? appendExpression : new AndExpression(where, appendExpression);
    }
}

3.3、模拟用户信息工具类

package com.xx.utils;

import com.xx.config.Globle;
import com.xx.entity.Permission;
import com.xx.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.*;

/**
 * @author aqi
 * @date 2023/5/17 14:20
 */
@Slf4j
public class UserUtils {

    public static User currentUser;

    static {
        // 构建测试数据
        List<Permission> permissionList = new ArrayList<>();
        // demo/test接口权限
        Permission permission = new Permission();
        permission.setField("id");
        permission.setUri("/demo/test");
        Set<String> set = new HashSet<>();
        set.add("1");
        set.add("2");
        set.add("3");
        permission.setValue(set);
        permissionList.add(permission);


        // demo/test1接口权限
        Permission permission1 = new Permission();
        permission1.setField("id");
        permission1.setUri("/demo/test1");
        Set<String> set1 = new HashSet<>();
        set1.add("4");
        set1.add("5");
        set1.add("6");
        permission1.setValue(set1);
        permissionList.add(permission1);


        User user = new User();
        user.setPermissionList(permissionList);
        user.setTenantId("1");
        currentUser = user;
    }

    public static Permission getUserPermission() {
        User currentUser = Globle.currentUser;
        String uri = UserUtils.getUri();
        List<Permission> permissionList = currentUser.getPermissionList();
        return permissionList.stream().filter(e -> Objects.equals(e.getUri(), uri)).findFirst().orElse(null);
    }

    /**
     * 获取本次请求的uri
     * @return uri
     */
    private static String getUri() {
        // 获取此次请求的uri
        String uri = "";
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (null != requestAttributes) {
            HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
            uri = request.getRequestURI();
        }
        log.info("[DataScopeInterceptor]此次请求uri:{}", uri);
        return uri;
    }
}


3.4、将拦截器添加到MyBatis Plus蓝机器中

package com.xx.config;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author aqi
 * @date 2023/5/15 14:05
 */
@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        // 初始化Mybatis Plus拦截器
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new DataScopeInterceptor());
        return interceptor;
    }
}

3.5、测试

package com.xx.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.pagehelper.PageHelper;
import com.xx.entity.AirlinesInfo;
import com.xx.service.AirlinesInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * @author aqi
 * @date 2023/5/18 11:01
 */
@Slf4j
@RestController
@RequestMapping("/demo")
public class DemoController {

    @Resource
    private AirlinesInfoService airlinesInfoService;

    @GetMapping("/test")
    public void test() {
        log.info("进入test接口,测试权限控制在基础的sql语句是否能生效");
        airlinesInfoService.list();
        // 执行结果:(SELECT * FROM airlines_info WHERE state = 0 AND airlines_info.id IN ('1', '2', '3'))
    }

    @GetMapping("/test1")
    public void test1() {
        log.info("进入test1接口,测试权限控制在使用MyBatis Plus 的分页插件之后能否生效");
        Page<AirlinesInfo> page = new Page<>(1, 5);
        airlinesInfoService.page(page, new QueryWrapper<AirlinesInfo>().eq("name", "tom"));
        // 执行结果:(SELECT * FROM airlines_info WHERE state = 0 AND (name = ?) AND airlines_info.id IN ('4', '5', '6') LIMIT ?)
    }

    @GetMapping("/test2")
    public void test2() {
        log.info("进入test2接口,测试权限控制在使用PageHelper之后能否生效");
        PageHelper.startPage(1, 5);
        airlinesInfoService.list(new LambdaQueryWrapper<AirlinesInfo>().eq(AirlinesInfo::getName, "tom"));
        // 执行结果:(SELECT * FROM airlines_info WHERE state = 0 AND (name = ?) AND airlines_info.id IN ('7', '8', '9') LIMIT ?)
    }

    @GetMapping("/test3")
    public void test3() {
        log.info("进入test3接口,测试权限控制在使用自定义复杂关联查询之后能否生效");
        airlinesInfoService.innerSql();
        // 原始SQL:(select * from airlines_info t1 INNER JOIN t_config on t1.id = t_config.id where t1.name = 'tom' and t_config.name = 'jack' limit 5)
        // 执行结果:(SELECT * FROM airlines_info t1 INNER JOIN t_config ON t1.id = t_config.id WHERE t1.name = 'tom' AND t_config.name = 'jack' AND t1.id IN ('11', '12', '10') LIMIT 5)
    }

    @GetMapping("/test4")
    public void test4() {
        log.info("进入test4接口,测试该接口没有设计权限限制是否可以不生效");
        airlinesInfoService.list();
        // 执行结果:(SELECT * FROM airlines_info WHERE state = 0)
    }


}

四、结论

通过测试可以看出不论在什么情况下都可以正常的对权限进行控制

注意:上面部分代码使用的是MyBatis Plus 3.5.3版本,并且使用的JSqlParser部分API已经不推荐使用,但是我没有找到最新的API应该怎么写

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/20644.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

基于AI技术的API开发工具,自动化和智能化快速高效开发API

一、开源项目简介 ApiCat 是一款基于 AI 技术的 API 开发工具&#xff0c;它旨在通过自动化和智能化的方式&#xff0c;帮助开发人员更快速、更高效地开发 API。ApiCat 支持 OpenAPI 和 Swagger 的数据文件导入和导出&#xff0c;并可以对用户输入的 API 需求进行分析和识别&a…

真题详解(补码转换)-软件设计(七十四)

真题详解(索引查询)-软件设计&#xff08;七十三)https://blog.csdn.net/ke1ying/article/details/130659024 Composite模式&#xff1a; 以树形结构来表示”整体-部分”的关系&#xff0c;使得单个和团体的使用都具有一致性。 对一个基本有序的数组进行排序&#xff0c;适合…

精彩回顾 | Fortinet Accelerate 2023·中国区巡展厦门站

Fortinet Accelerate 2023中国区 5月16日&#xff0c;Fortinet Accelerate 2023中国区巡展来到魅力“鹭岛”——厦门&#xff0c;技术、产品和业务专家&#xff0c;携手亚马逊云科技、唯一网络等云、网、安合作伙伴&#xff0c;与交通、物流、金融等各行业典型代表客户&#x…

HR真的会嫌弃跳槽频繁的测试人员吗?

我们都知道&#xff1a;“跳槽≠涨薪”&#xff0c;但是对于测试人来说&#xff0c;跳槽绝哔能和升职加薪画上等号啊。 所以&#xff0c;有很多测试人在一家公司常常待不到1年&#xff0c;就另觅新东家&#xff0c;来借此达成升职加薪的目的。 有人提出质疑&#xff1a;为什么测…

嚯——ChatGPT是很强,但也会胡说八道。。。

现在的ChatGPT确实强&#xff0c;但是也会一本正经的胡说八道&#xff0c;例如它回答“nineteen”中有12个字母、或是旗鱼是哺乳动物…… 尽管ChatGPT可以生成流畅甚至优雅的散文&#xff0c;轻松通过困扰了AI领域超过70年的图灵测试基准&#xff0c;但它也可能看起来非常愚蠢…

探讨接口测试颗粒度

偶然间在论坛上看到一个帖子&#xff0c;帖子内容如下&#xff1a; 假设现在有一个新增商品的接口&#xff0c;返回的参数中有新增商品的 id&#xff08;每次返回的 id 都不一样&#xff09;、success&#xff08;判断是否成功&#xff0c;0 失败 1 成功&#xff09; 1. 接口…

Elasticsearch painless脚本教程(包含Java API和SpringDataElasticsearch调用脚本)

Elasticsearch painless脚本 1.什么是painless2.painless的特性3.使用kibana进行准备操作3.1 使用kibana创建索引和映射3.2 使用kibana添加测试数据 4.使用painless执行查询操作关于脚本查询须知4.1 字段查询脚本4.1 排序查询脚本 5.如何使用painless执行更新操作关于脚本查询须…

【机器学习】第二节:线性回归和线性分类

作者&#x1f575;️‍♂️&#xff1a;让机器理解语言か 专栏&#x1f387;&#xff1a;机器学习sklearn 描述&#x1f3a8;&#xff1a;本专栏主要分享博主学习机器学习的笔记和一些心得体会。 寄语&#x1f493;&#xff1a;&#x1f43e;没有白走的路&#xff0c;每一步都算…

Text-to-SQL提示工程【Prompt Engineering】

我们刚刚启动了一个开源项目pg-text-query&#xff0c;目标是为文本到 SQL 制作生产就绪的大型语言模型 (LLM) 提示。 我们的目标是 利用 LLM、我们自己对 PostgreSQL 数据库的深入了解以及严格的测试来开发一流的文本到 SQL 的翻译。 推荐&#xff1a;用 NSDT设计器 快速搭建…

【MySQL】C语言连接数据库

在使用数据库的时候&#xff0c;我们是操作客户端方的&#xff0c;向服务器请求数据。MySQL的端口号-3306 连接mysql需要头文件&#xff1a;#include<mysql/mysql.h> 基础连接步骤&#xff1a; 1.创建连接句柄 MYSQL mysql_con;//连接句柄 2.初始化连接句柄mysql_i…

「OceanBase 4.1 体验」|大厂开始接入的国产分布式数据库,不来了解了解?

OceanBase 4.1 体验 前言OCP Express在线升级功能租户级物理备库TP&#xff08;事务处理&#xff09;和AP&#xff08;分析处理&#xff09;优化TP 性能优化AP 性能优化 结尾 前言 上次我们讲了本人自己亲自上手OceanBase 4.1的初体验&#xff0c;国产的分布式数据库也太太太太…

有哪些好用的AI工具?

现在有很多好用的AI工具&#xff0c;以下是一些常用的&#xff1a; 1. TensorFlow&#xff1a;谷歌开发的深度学习框架&#xff0c;支持多种编程语言&#xff0c;包括Python、C、Java等。 2. PyTorch&#xff1a;Facebook开发的深度学习框架&#xff0c;易于使用&#xff0c;…

Redis源码分析之网络模型

Redis网络模型 阅读源码的初衷Redis源码阅读 阅读源码的初衷 很多网上解释这个Redis为啥这么块&#xff1f;都会说Redis这么快的原因会有一个Redis才用了单线程&使用了多路io复用来检查io事件&#xff0c;单线程可以避免多线程对资源的竞争。如果我们使用了多线程那么就需…

ChatGPT其实并不想让开发人员做这5件事情

前言 ChatGPT已经火爆了快半年了吧&#xff0c;紧接着国内也开始推出了各种仿制品&#xff0c;我甚至一度怀疑&#xff0c;如果人家没有推出ChatGPT&#xff0c;这些仿制品会不会出现。而很多人也嗨皮得不行&#xff0c;利用各种方法开始科学上网&#xff0c;用ChatGPT做各种觉…

[读书笔记] Variational AutoEncoders

小全读书笔记 《Variational AutoEncoders》 1. Generative Model &#xff08;生成式模型&#xff09;简述2. 简单生成模型 AutoEncoders2.1 结构2.2 不足 3. Variational AutoEncoders4. Variational AutoEncoders的数学支持 此读书笔记来自于Joseph Rocca的Understanding Va…

APP 兼容性测试是什么?8年测试老鸟告诉你

1、APP 兼容性测试认识 随着 APP 应用范围越来越广&#xff0c;用户群体越来越大&#xff0c;终端设备的型号也越来越多&#xff0c;移动终端碎片化加剧&#xff0c;使得 APP 兼容性测试成为测试质量保障必须要考虑的环节。 APP 兼容性测试通常会考虑&#xff1a;操作系统、厂…

js执行思维导图

备注&#xff1a; js执行&#xff1a; 执行分为两部分&#xff1a;预执行和执行 预执行&#xff1a;创建好执行上下文 执行&#xff1a;执行栈中执行 js引擎&#xff1a; 读取并执行js 各个浏览器的引擎如下 …

linux学习[10]磁盘与文件系统(1):查看磁盘容量指令df 评估文件系统的磁盘使用量指令 du

文章目录 前言1. df指令2. du指令 前言 TF卡制作的过程中涉及到了磁盘分区格式化等问题&#xff0c;当时对具体的指令理解不是特别深刻&#xff1b;由此引申到我对linux中的整个磁盘与文件系统没有一个全面的认识&#xff0c;这个磁盘与文件系统的系列博客章节就对这些进行记录…

MySQL的主从复制与读写分离

一、MySQL主从复制 1、mysql支持的复制类型 STATEMENT∶基于语句的复制。在服务器上执行sql语句&#xff0c;在从服务器上执行同样的语句&#xff0c;mysql默认采用基于语句的复制&#xff0c;执行效率高。 ROW∶ 基于行的复制。把改变的内容复制过去&#xff0c; 而不是把命…

下载和安装appuploader

转载&#xff1a;下载和安装appuploader IOS开发工具官网地址 Appuploader home -- A tool improve ios develop efficiency such as submit ipa to appstore and manage ios certificate 最新版本已经优化了没支付688给apple的账号登录流程&#xff0c;无需再安装其他软件。…