来吧,整起,又一新功能,通用数据权限,注意是通用,通用的东西,反正挺烦的。
我还是第一次搞这玩意儿,因为之前做细节的数据权限都是直接写在代码里面的
好,开整,这篇文章我会写得详细一点,并且提供开源源码,全靠我自己设计,编码,一步步的敲出来的,很少的地方借鉴到了别人的东西,切看切珍惜,动动你的小手点个赞,点个收藏吧。
嗯,数据权限?有些朋友可能会问了,“嗯,数据还有权限?”
没错,简单来讲:数据权限无非就是某人只能看到某些数据。
举个例子:张三登录了A系统,那么根据系统查询出来的张三所拥有的权限,比如张三有一个A部门的数据权限,
那么,在A系统中,张三只能看到A部门相关的数据。
那好了,啷个才能实现这个功能呢?
别慌,我们先回忆一下我们在不做通用的情况下是怎么做数据权限的呢?
比如张三有A部门的权限:
String deptId = servie.getDeptByUser("张三");
//在xml里
select * from test_table
where dept_id = #{deptId}
如上面的代码所示,我们一般是通过直接写sql带条件的方式实现的,写起来非常的方便,但是代码多了就求了,万一有1W个mapper都需要这样做,那写到吐,好吧,给你搞个通用的。
好,上面已经说到了痛点,那么我们只需要拦截住我们需要的sql,能根据获取到的用户数据权限,动态的拼接出我们想要的sql,再给他装回去,那这个问题就能解决了。
ok,按照这个步骤:用户登录→登录验证通过后获取到用户的所有权限信息→放入到redis中→做数据查询时拦截对应的sql→详细封装处理→执行新sql返回权限后数据


选择spring初始化,下一步下一步就可以了,名字随便取,先不用选依赖。
建完了在详细下面找到pom.xml,添加如下依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.kbplus.demo.data</groupId>
<artifactId>permission</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>permission</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.12</version>
</dependency>
<!-- 数据库-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.24</version>
</dependency>
<!-- mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
<!--mybaits-plus生成代码的依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<!-- 工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.16</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
<scope>provided</scope>
</dependency>
<!-- swagger -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
<!--fastjson-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.68</version>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- Token生成与解析-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
添加数据库,并导入提供好的sql

名字随便取,字符集和排序规则选择我选择的就ok,比较通用的utf规则和排序规则,
简单介绍一下,utf8mb4是mysql的一种拓展字符集,可以存储一些特殊字符,utf8mb4_general_ci是兼容大多主流语言并同时比较高效的排序规则,如果你的项目有使用到少见的语言,比如俄语,可以使用utf8mb4_unicode_ci来提高精准性

导入完sql后,大概是这么一个结构。
接着添加项目所需类包(源码里会提供),
大致结构如下:

核心代码如下:
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @return 结果
*/
@Override
public String login(String username, String password)
{
User user1 = baseMapper.selectOne(new QueryWrapper<User>().eq("username", username));
if(user1==null){
throw new CustomException("用户不存在");
}
if(!password.equals(user1.getPassword())){
throw new CustomException("密码不正确");
}
//这里使用mybaitis特性collection之类的好像会更快,各位有兴趣可以尝试
Set<Role> allRoleByUserId = roleMapper.getAllRoleByUserId(user1.getId());
UserDept userDept = userDeptMapper.selectOne(new QueryWrapper<UserDept>().eq("user_id", user1.getId()));
Set<String> allPositionByUserId = positionMapper.getAllPositionByUserId(user1.getId());
List<String> deptChildren = departmentService.getDeptChildren(userDept.getDeptId());
Set<String> allDataPermissionByUserId = dataPermissionMapper.getAllDataPermissionByUserId(user1.getId());
LoginUser loginUser = new LoginUser();
loginUser.setUser(user1);
loginUser.setTenantId(user1.getTenantId());
loginUser.setRoles(allRoleByUserId);
loginUser.setUserId(user1.getId());
loginUser.setDeptId(userDept.getDeptId());
loginUser.setPostIds(allPositionByUserId);
loginUser.setDeptChildren(deptChildren);
loginUser.setDataPermissions(allDataPermissionByUserId);
String token = UUID.randomUUID().toString();
loginUser.setToken(token);
//存入redis
tokenService.setLoginUser(loginUser);
// 生成token
return tokenService.createToken(loginUser);
}
在创建token的时候有这一步,以便可以通过token直接拿到想要的信息,不用去redis再查。
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, loginUser.getToken());
claims.put("tenantId",loginUser.getTenantId());
claims.put("id",loginUser.getUserId());
claims.put("name",loginUser.getUser().getName());
claims.put("postIds",loginUser.getPostIds());
claims.put("organizationId",loginUser.getDeptId());
return createToken(claims);
}
1.添加用户数据,角色数据,权限数据等并关联:
大家可以使用sql一键导入,详情大家可以参考我的权限认证文章:待完善
我们使用user1账号登录后创建2条测试数据,再用user2登录创建2条测试数据,步骤如下:
2.使用user1模拟登录:

3.拿到返回的token去生成测试数据:


使用user2登录做同样的操作,让后可以看到表里有6条数据,null值的是我之前添加的不用在意~~,主要可以看到有4条是部门1的,2条是部门2的 我设置的权限分别是user1是部门1,user2是部门2,且部门2是部门1的子部门

4.测试效果:
使用user1成功返回6条数据,因为我现在给他的数据权限是拥有部门及子部门,所有能查到所有

使用user2登录,user2和user1拥有一样的角色,即拥有一样的权限,请求接口,发现只返回了2条数据,且都是dept2的,因为部门2是部门1的子集

目前系统支持以下类型的数据权限
ALL("1","拥有所有数据权限"),
NONE("2","未拥有数据权限"),
DEPT("3","拥有部门权限"),
DEPT_CHILDREN("4","拥有部门权限及子权限"),
POST("5","拥有职位权限"),
// POST_CHILDREN("6","拥有职位权限及子权限"),
OWN("7","拥有自身权限"),
大家可以根据自己的需要进行测试
新建拦截机继承 InnerInterceptor (mybatis-plus的一个拦截器)
public class DataPermissionInterceptor implements InnerInterceptor
把拦截器注入进配置,这里注意添加拦截器的编写顺序,会影响到拦截器执行的先后顺序
package com.kbplus.demo.data.permission.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public DataPermissionInterceptor dataPermissionInterceptor() {
return new DataPermissionInterceptor();
}
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
paginationInnerInterceptor.setDbType(DbType.MYSQL);
interceptor.addInnerInterceptor(dataPermissionInterceptor());
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
}
核心拦截类:大致原理是先做有效性判断,包括是否属于管理员等,这里只拦截需要分页的查询,然后根据权限匹配,生成相对应的代码
package com.kbplus.demo.data.permission.config;
import cn.hutool.core.collection.CollectionUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.kbplus.demo.data.permission.entity.LoginUser;
import com.kbplus.demo.data.permission.entity.Role;
import com.kbplus.demo.data.permission.mapper.CommonMapper;
import com.kbplus.demo.data.permission.utils.SpringUtils;
import com.kbplus.demo.data.permission.utils.TokenService;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.select.*;
import net.sf.jsqlparser.util.TablesNamesFinder;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.beans.factory.annotation.Autowired;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* @author kbplus
* @version v1.0
* @date 2022-05-13 17:49:18
*/
public class DataPermissionInterceptor implements InnerInterceptor {
@Autowired
private TokenService tokenService;
@Autowired
private HttpServletRequest httpServletRequest;
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql){
String firstSql = boundSql.getSql();
Field field = null;
try {
Select statement = (Select) CCJSqlParserUtil.parse(boundSql.getSql());
if(!ifPage(parameter)){
return;
}
LoginUser loginUser = tokenService.getLoginUser(httpServletRequest);
if(loginUser==null){
return;
}
Set<String> dataPermissions = loginUser.getDataPermissions();
Set<Role> roles = loginUser.getRoles();
for (Role role : roles) {
if("admin".equals(role.getCode())){
return;
}
}
List<String> mainTables = getMainTable(statement);
field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
if(dataPermissions==null || dataPermissions.contains(DataPermissionEnum.NONE.getCode())){
String newSql = addWhereCondition(boundSql.getSql(), "1=2",DataPermissionEnum.NONE);
//通过反射修改sql语句
field.set(boundSql, newSql);
System.out.println(newSql);
return;
}
//获取公共mapper取字段是否存在
CommonMapper commonMapper = SpringUtils.getBean(CommonMapper.class);
String tenantTable = getFirstTableOnField("tenant_id", mainTables,commonMapper);
if(tenantTable!=null) {
//初始默认匹配租户id查询
String tenantSql = addWhereCondition(boundSql.getSql(), tenantTable + ".tenant_id=" + loginUser.getTenantId(), DataPermissionEnum.NONE);
//通过反射修改sql语句
field.set(boundSql, tenantSql);
System.out.println(tenantSql);
}
String organizationTable=null;
if(dataPermissions.contains(DataPermissionEnum.DEPT.getCode())){
organizationTable = getFirstTableOnField("create_user_organization_id", mainTables,commonMapper);
if(organizationTable!=null) {
String newSql = addWhereCondition(boundSql.getSql(), organizationTable + ".create_user_organization_id=" + loginUser.getDeptId(), DataPermissionEnum.DEPT);
//通过反射修改sql语句
field.set(boundSql, newSql);
System.out.println(newSql);
}
}
if(dataPermissions.contains(DataPermissionEnum.DEPT_CHILDREN.getCode())){
if(organizationTable==null){
organizationTable = getFirstTableOnField("create_user_organization_id", mainTables,commonMapper);
}
if(organizationTable!=null) {
String newSql = addWhereCondition(boundSql.getSql(), getCondition(organizationTable, "create_user_organization_id", loginUser.getDeptChildren()), DataPermissionEnum.DEPT_CHILDREN);
//通过反射修改sql语句
field.set(boundSql, newSql);
System.out.println(newSql);
}
}
if(dataPermissions.contains(DataPermissionEnum.POST.getCode())){
String postTable = getFirstTableOnField("tenant_id", mainTables,commonMapper);
if(postTable!=null) {
String sql = boundSql.getSql();
if (sql.contains("where") || sql.contains("WHERE")) {
sql = sql + " and ";
} else {
sql = sql + " where ";
}
String newSql = sql + getPositionCondition(postTable, "create_user_post_id", loginUser.getPostIds());
//通过反射修改sql语句
field.set(boundSql, newSql);
System.out.println(newSql);
}
}
if(dataPermissions.contains(DataPermissionEnum.OWN.getCode())){
String userTable = getFirstTableOnField("create_user", mainTables,commonMapper);
if(userTable!=null) {
String newSql = addWhereCondition(boundSql.getSql(), userTable + ".create_user=" + loginUser.getUserId(), DataPermissionEnum.OWN);
//通过反射修改sql语句
field.set(boundSql, newSql);
System.out.println(newSql);
}
}
} catch (JSQLParserException | NoSuchFieldException | IllegalAccessException e) {
if(StringUtils.isNotEmpty(firstSql)&& ObjectUtils.isNotEmpty(field)){
try {
field.set(boundSql, firstSql);
} catch (IllegalAccessException illegalAccessException) {
illegalAccessException.printStackTrace();
}
}
e.printStackTrace();
}
}
/**
* 获取拥有字段的指定第一张表
*
* @param field
* @return
*/
private String getFirstTableOnField(String field,List<String> tables,CommonMapper commonMapper) {
if(CollectionUtil.isEmpty(tables))
return null;
for (String table : tables) {
if(commonMapper.getFieldExists(table,field)>0)
return table;
}
return null;
}
/**
* 获取查询字段
*
* @param selectBody
* @return
*/
private List<SelectItem> getSelectItems(SelectBody selectBody) {
if (selectBody instanceof PlainSelect) {
return ((PlainSelect) selectBody).getSelectItems();
}
return null;
}
/**
* 特殊处理 创建职业sql
*
* @param tableName 表名
* @param fieldName 字段名
* @param ids 值
* @return
*/
private String getPositionCondition(String tableName, String fieldName, Collection<String> ids) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("(");
for (String id : ids) {
stringBuilder.append(tableName).append(".").append(fieldName).append(" like '%").append(id).append("%' or ");
}
stringBuilder.delete(stringBuilder.length()-3,stringBuilder.length()).append(")");
return stringBuilder.toString();
}
/**
* 生成where 条件字符串
*
* @param tableName 表名
* @param fieldName 字段名
* @param ids 值
* @return
*/
private String getCondition(String tableName, String fieldName, Collection<String> ids) {
return tableName + "." + fieldName + " in (" + StringUtils.join(ids, ",") + ")";
}
/**
* 获取tables的表名
*
* @param statement
* @return
*/
private List<String> getMainTable(Select statement) {
TablesNamesFinder tablesNamesFinder = new TablesNamesFinder();
return tablesNamesFinder.getTableList(statement);
}
/**
* 判断是否分页
*
* @param selectBody
* @return
*/
private Limit ifPage(SelectBody selectBody) {
if (selectBody instanceof PlainSelect) {
return ((PlainSelect) selectBody).getLimit();
}
return null;
}
/**
* 判断是否分页
*
* @param parameter
* @return
*/
private boolean ifPage(Object parameter) {
if(parameter instanceof String) return false;
JSONObject jsonObject = JSON.parseObject(JSON.toJSONString(parameter));
return jsonObject.containsKey("page") ||
jsonObject.containsKey("size") ||
jsonObject.containsKey("current");
}
/**
* 在原有的sql中增加新的where条件
*
* @param sql 原sql
* @param condition 新的and条件
* @return 新的sql
*/
private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum, List<String> detps,Set<String> posts) {
try {
Select select = (Select) CCJSqlParserUtil.parse(sql);
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
final Expression expression = plainSelect.getWhere();
final Expression envCondition = CCJSqlParserUtil.parseCondExpression(condition);
if (Objects.isNull(expression)) {
plainSelect.setWhere(envCondition);
} else {
if(DataPermissionEnum.NONE==dataPermissionEnum){
AndExpression andExpression = new AndExpression(expression, envCondition);
plainSelect.setWhere(andExpression);
}else {
OrExpression orExpression = new OrExpression(expression, envCondition);
plainSelect.setWhere(orExpression);
}
}
return plainSelect.toString();
} catch (JSQLParserException e) {
throw new RuntimeException(e);
}
}
private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum) {
return addWhereCondition(sql,condition,dataPermissionEnum,null,null);
}
private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum, List<String> detps) {
return addWhereCondition(sql,condition,dataPermissionEnum,detps,null);
}
private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum, Set<String> posts) {
return addWhereCondition(sql,condition,dataPermissionEnum,null,posts);
}
}
这里给大家提供开源源码:java-springboot-mybatis-数据权限详细实现
编写不易,点个赞再走!
我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i
我真的很习惯使用Ruby编写以下代码:my_hash={}my_hash['test']=1Java中对应的数据结构是什么? 最佳答案 HashMapmap=newHashMap();map.put("test",1);我假设? 关于java-等价于Java中的RubyHash,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/22737685/
我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden
有时我需要处理键/值数据。我不喜欢使用数组,因为它们在大小上没有限制(很容易不小心添加超过2个项目,而且您最终需要稍后验证大小)。此外,0和1的索引变成了魔数(MagicNumber),并且在传达含义方面做得很差(“当我说0时,我的意思是head...”)。散列也不合适,因为可能会不小心添加额外的条目。我写了下面的类来解决这个问题:classPairattr_accessor:head,:taildefinitialize(h,t)@head,@tail=h,tendend它工作得很好并且解决了问题,但我很想知道:Ruby标准库是否已经带有这样一个类? 最佳
我正在尝试使用boilerpipe来自JRuby。我看过guide从JRuby调用Java,并成功地将它与另一个Java包一起使用,但无法弄清楚为什么同样的东西不能用于boilerpipe。我正在尝试基本上从JRuby中执行与此Java等效的操作:URLurl=newURL("http://www.example.com/some-location/index.html");Stringtext=ArticleExtractor.INSTANCE.getText(url);在JRuby中试过这个:require'java'url=java.net.URL.new("http://www
我只想对我一直在思考的这个问题有其他意见,例如我有classuser_controller和classuserclassUserattr_accessor:name,:usernameendclassUserController//dosomethingaboutanythingaboutusersend问题是我的User类中是否应该有逻辑user=User.newuser.do_something(user1)oritshouldbeuser_controller=UserController.newuser_controller.do_something(user1,user2)我
什么是ruby的rack或python的Java的wsgi?还有一个路由库。 最佳答案 来自Python标准PEP333:Bycontrast,althoughJavahasjustasmanywebapplicationframeworksavailable,Java's"servlet"APImakesitpossibleforapplicationswrittenwithanyJavawebapplicationframeworktoruninanywebserverthatsupportstheservletAPI.ht
我正在尝试使用Curbgem执行以下POST以解析云curl-XPOST\-H"X-Parse-Application-Id:PARSE_APP_ID"\-H"X-Parse-REST-API-Key:PARSE_API_KEY"\-H"Content-Type:image/jpeg"\--data-binary'@myPicture.jpg'\https://api.parse.com/1/files/pic.jpg用这个:curl=Curl::Easy.new("https://api.parse.com/1/files/lion.jpg")curl.multipart_form_
无论您是想搭建桌面端、WEB端或者移动端APP应用,HOOPSPlatform组件都可以为您提供弹性的3D集成架构,同时,由工业领域3D技术专家组成的HOOPS技术团队也能为您提供技术支持服务。如果您的客户期望有一种在多个平台(桌面/WEB/APP,而且某些客户端是“瘦”客户端)快速、方便地将数据接入到3D应用系统的解决方案,并且当访问数据时,在各个平台上的性能和用户体验保持一致,HOOPSPlatform将帮助您完成。利用HOOPSPlatform,您可以开发在任何环境下的3D基础应用架构。HOOPSPlatform可以帮您打造3D创新型产品,HOOPSSDK包含的技术有:快速且准确的CAD
华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o