news 2026/9/10 13:50:13

Java汽车租赁项目实战:状态机、事务与Redis防超租

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java汽车租赁项目实战:状态机、事务与Redis防超租

简介:一套基于SpringBoot的汽车租赁管理系统Java源码,面向计算机、电子信息工程等专业学习者,适合作为毕业设计、课程设计或期末大作业参考。系统采用B/S架构与MVC模式,整合MySQL、MyBatis、Vue等主流技术栈,涵盖车辆管理、订单租赁、客户管理等典型业务模块,可帮助初学者理解完整业务闭环。压缩包内共654个文件,包括155个Java源文件、110个Vue页面、20个XML配置、41个JS脚本及15个CSS样式等,资源总体积约23.26MB,目录结构清晰,便于按模块阅读与二次开发。目前已有900人学习下载过该资源。所有源码均经过严格测试,配套环境说明覆盖Windows/Mac、JDK1.8、Maven3.6及Tomcat等;同时包含运行脚本与备份文件,能帮助学习者快速搭建环境、理解业务逻辑与前后端交互流程,有效缩短开发周期,是实用性与完整度较高的课设/毕设参考项目。

1. 汽车租赁管理系统代码,先理解业务状态而不是先写类

一辆车同时被两个客户看中,一个在网页点击“下单”,另一个在门店刷卡,系统如果只在内存里判断“库存大于0”,两台机器同时读到剩余数量1,就会同时生成两张订单,门店最后发现车只有一辆。汽车租赁管理系统代码的难点从来不是CRUD,而是把“车可租/不可租”变成数据库和代码都能一致判定的状态模型,再围绕状态做并发控制。这个标题看起来是Java项目练习,落到底层其实是库存、订单状态机和事务边界的组合。适合刚学完Java集合和MySQL、准备动手做一个能写在简历上的Java项目的人,也适合要在面试时讲清楚“怎么做防超租”的求职者。我会按一个Spring Boot项目的真实搭建顺序展开,从数据库ER图一路写到Redis缓存扣减,每个环节都给能直接跑的代码和参数说明。

2. 从ER图到Java代码:汽车租赁管理系统代码的基础分层与数据库设计

2.1 汽车租赁管理系统代码的领域模型与ER图

在写任何Java代码之前,先把业务对象画清楚。一个最常见的租赁系统围绕三个核心表:车辆表、用户表、订单表。车辆表只描述车本身,包括车牌号、品牌、型号、每日租金、当前状态。用户表存客户身份和联系方式。订单表记录哪个人、在哪段时间租了哪辆车、租金和违约金。

ER关系很简单:一个用户可以有多个订单,一个车辆可以有多个订单,所以用户和车辆分别与订单是一对多关系。不要在设计阶段就加“库存表”,因为租赁系统里“库存”通常不是多辆车,而是一辆车在某个时间段是否被占用。真正的库存约束体现在订单的起止时间上,而不是一个单纯的数量字段。

下面是我会用的最小表结构,字段名用snake_case,方便MyBatis映射。

表名关键字段说明
carid, plate_no, brand, model, daily_price, statusstatus:0可租,1已租,2维修中
customerid, name, phone, id_card身份证号需要加密存储,演示可以省略
rent_orderid, order_no, customer_id, car_id, start_date, end_date, total_amount, penalty, statusstatus:0待支付,1租赁中,2已还车,3已取消

状态字段不要用魔法值散落在代码里。建议全部用Java枚举定义,后续做状态机校验也方便。订单状态比车辆状态复杂,因为订单有支付、取车、还车等多个动作,每步都可能失败,所以状态枚举要独立放在一个包下。

2.2 Spring Boot项目结构:MVC分层与依赖坐标

技术栈选Spring Boot + MyBatis Plus + MySQL,这是Java项目最常见的组合。Spring Boot负责依赖管理和自动配置,MyBatis Plus把单表CRUD代码省掉大部分,让代码量集中在业务逻辑上。如果刚配置好java环境变量,用IDEA新建Spring Initializr项目,Java版本建议8或11,稳定,老项目兼容性也最好。

目录结构这样拆:

car-rental/ src/main/java/com/example/carrental/ controller/ # HTTP入口 service/ # 业务逻辑 mapper/ # MyBatis接口 entity/ # 数据库实体 enums/ # 状态枚举 config/ # Redis、事务等配置 src/main/resources/ application.yml mapper/ # XML文件

pom.xml里要引入的关键依赖是三个:spring-boot-starter-web、mybatis-plus-boot-starter、spring-boot-starter-data-redis。MySQL驱动用mysql-connector-java,运行时靠它建立连接。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>

这些依赖的顺序无所谓,但mybatis-plus版本要显式写,因为它不随Spring Boot的BOM管理。application.yml里配置数据源和Redis连接,注意连接池参数最好显式给出,避免默认值在压力下不够用。

spring: datasource: url: jdbc:mysql://localhost:3306/car_rental?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 timeout: 3000ms

url里必须加serverTimezone,否则MySQL 8驱动会报时区错误。这是新人最常见的一个报错,错误信息里会出现The server time zone value,遇到就直接改yml。

2.3 用MyBatis Plus生成基础CRUD代码

实体类直接对应表结构。用@TableName指定表名,用@TableId指定主键策略。MyBatis Plus默认会把id作为主键,如果表名和实体名不一致,必须显式指定。

@Data @TableName("car") public class Car { @TableId(type = IdType.AUTO) private Long id; private String plateNo; private String brand; private String model; private BigDecimal dailyPrice; private Integer status; }

Mapper接口什么都不用写,继承BaseMapper<T>就有单表增删改查方法。

public interface CarMapper extends BaseMapper<Car> { }

然后CarService里注入Mapper,写业务方法。这一步的价值在于让Controller不直接碰MyBatis的查询条件,而是通过Service暴露的业务方法拿到结果。比如根据品牌查可用车辆,用QueryWrapper构造条件。

public List<Car> listAvailableCars(String brand) { LambdaQueryWrapper<Car> wrapper = Wrappers.lambdaQuery(); wrapper.eq(Car::getStatus, 0); if (StringUtils.hasText(brand)) { wrapper.like(Car::getBrand, brand); } return carMapper.selectList(wrapper); }

这段代码的逻辑是:先用状态等于0过滤掉已租和维修车辆,再判断用户是否传了品牌关键词。MyBatis Plus会把lambdaQuery翻译成WHERE status = 0 AND brand LIKE ?。注意,品牌查询用like而不是eq,因为用户可能输入“别克GL8”里的“别克”。如果拼SQL条件时不小心把字符串直接拼进in条件,会触发SQL注入风险,用Wrapper就是为了绕开手动拼接。

3. 把租赁流程写进代码:订单创建、状态流转与防超租

3.1 租赁订单表设计与状态机枚举

订单表是整个系统的核心,因为所有并发问题都发生在创建订单那一刻。设计上,每个订单必须包含租车起止日期,而不是只存一个“租几天”。这样后续做时间冲突检测时,SQL才能用start_date <= ? AND end_date >= ?去查重叠订单。订单号也要独立生成,不能依赖自增ID,因为外部用户需要拿订单号在电话里和客服核对。

用枚举管理状态,是最值得花时间的部分。Java枚举能把状态值、状态描述和状态流转规则放在同一个类里,避免各个Service里散落if (status == 1)这种魔法数字。

public enum OrderStatus { PENDING(0, "待支付"), RENTING(1, "租赁中"), RETURNED(2, "已还车"), CANCELLED(3, "已取消"); private final int value; private final String desc; OrderStatus(int value, String desc) { this.value = value; this.desc = desc; } public boolean canTransitTo(OrderStatus target) { if (this == PENDING) { return target == RENTING || target == CANCELLED; } if (this == RENTING) { return target == RETURNED; } return false; } public int getValue() { return value; } }

状态机的核心在canTransitTo方法。待支付只能转成租赁中或取消;租赁中只能转成已还车;已还车和已取消是终态。这样做的好处是,如果有人直接调Service想从已还车改回租赁中,会在入口处被拦截。面试的时候,“状态机怎么设计”就是从这个枚举开始聊的。

3.2 订单创建Service的Java实现

创建订单最少要四步:查车辆、校验车辆状态、生成订单、扣减车辆状态。最直接的问题是并发。假设一辆车status为0,两个请求同时查出status=0,同时往后走,就会生成两个订单。要防住它,不能靠Java代码里的if判断,因为两台服务器各有一个Java虚拟机,if是分散在各处的。

第一步先把事务和代码边界定清楚。

@Service public class RentService { @Resource private CarMapper carMapper; @Resource private RentOrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public RentOrder createOrder(Long customerId, Long carId, LocalDate startDate, LocalDate endDate) { Car car = carMapper.selectById(carId); if (car == null || car.getStatus() != 0) { throw new RuntimeException("车辆不可租"); } long days = ChronoUnit.DAYS.between(startDate, endDate); if (days <= 0) { throw new RuntimeException("租期必须大于0天"); } String orderNo = generateOrderNo(carId); RentOrder order = new RentOrder(); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setCarId(carId); order.setStartDate(startDate); order.setEndDate(endDate); order.setTotalAmount(car.getDailyPrice().multiply(BigDecimal.valueOf(days))); order.setStatus(OrderStatus.PENDING.getValue()); orderMapper.insert(order); return order; } private String generateOrderNo(Long carId) { return System.currentTimeMillis() + "-" + carId; } }

这段代码把订单金额放在创建时就算好,而不是还车时再算,是因为用户下单时就要看到价格确认。ChronoUnit.DAYS.between计算的是两天的差值,如果用户选了“今天到明天”,值是1,符合直觉。订单号用时间戳加车辆ID只是演示,实际项目应该用雪花算法,避免同一毫秒并发生成相同订单号。

这版代码在单机低并发下能用,但并发一高就会出超租。因为两个事务同时读到了status=0,然后都执行了insert,最后没有一个人更新车辆状态。问题出在“读状态”和“改状态”之间不是原子的。

3.3 并发防超租:用版本号还是用锁?

解决办法是让“车辆状态从可租变为已租”这个操作成为一条原子SQL,而不是先查再改。MyBatis Plus里写一个自定义Update方法,直接在更新条件里带上status = 0

public interface CarMapper extends BaseMapper<Car> { int updateStatusWithLock(@Param("carId") Long carId, @Param("oldStatus") int oldStatus, @Param("newStatus") int newStatus); }

XML里写:

<update id="updateStatusWithLock"> UPDATE car SET status = #{newStatus} WHERE id = #{carId} AND status = #{oldStatus} </update>

这个SQL的巧妙之处在于,数据库的行锁会让两个并发更新串行执行。第一个请求执行UPDATE ... WHERE status=0时,会锁住这一行,更新后status变成1;第二个请求等锁释放后执行同样的UPDATE,发现status已经不是0,匹配不到任何行,返回影响行数为0。代码里只要判断返回值大于0,才代表抢到了“可租”资格。

int rows = carMapper.updateStatusWithLock(carId, 0, 1); if (rows == 0) { throw new RuntimeException("车辆刚刚被租走"); }

把这个操作放在事务最前面,然后再去生成订单,就能保证即使两个请求同时进来,也只有一个事务影响行数大于0。这比Java的synchronized锁可靠得多,因为Update锁作用在数据库行上,天然支持多实例部署。面试时如果被问到“乐观锁和悲观锁怎么选”,这就是悲观锁的典型实际应用。

3.4 还车时如何计算违约金

还车流程不只是把车辆状态改回0,还要根据实际还车时间和订单应还时间计算延期的违约金。常见规则是超出部分按每日租金的1.5倍计算,不足一天按一天算。

@Transactional(rollbackFor = Exception.class) public RentOrder returnCar(Long orderId, LocalDate actualEndDate) { RentOrder order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.RENTING.getValue()) { throw new RuntimeException("订单状态不允许还车"); } if (actualEndDate.isAfter(order.getEndDate())) { long overdueDays = ChronoUnit.DAYS.between(order.getEndDate(), actualEndDate); Car car = carMapper.selectById(order.getCarId()); BigDecimal penalty = car.getDailyPrice() .multiply(BigDecimal.valueOf(overdueDays)) .multiply(BigDecimal.valueOf(1.5)); order.setPenalty(penalty); } order.setStatus(OrderStatus.RETURNED.getValue()); orderMapper.updateById(order); carMapper.updateStatusWithLock(order.getCarId(), 1, 0); return order; }

这里有一个细节:还车时更新车辆状态用的updateStatusWithLock,条件里的旧状态是1,正好和租出时的状态互斥。如果车辆本身在维修中,按这个逻辑就不会被还车操作误改。违约金计算用BigDecimal.multiply乘两个BigDecimal,再补一个BigDecimal.valueOf(1.5),顺序不能乱,因为dailyPrice是BigDecimal,直接multiply整数要先用valueOf包装。

这段代码至少踩过两个坑:第一个是LocalDate比较直接用isAfter,不要手动转成Date;第二个是计算逾期天数时用了BETWEEN,如果actualEndDate正好等于应还日,isAfter返回false,不会触发违约金,逻辑正确。

4. 让系统扛住高峰期:缓存热点车辆库存与分布式锁的落点

4.1 为什么需要Redis缓存车辆库存

数据库行锁能防超租,但有一个问题:锁的粒度是行,一辆热点车一天可能被几千人浏览下单,每条UPDATE都落在同一行上,MySQL再快也会成为瓶颈。常见做法是把“车辆可租状态”和“剩余库存”放到Redis里,用Redis的原子操作做扣减,数据库只保留最终结果。

注意这里要区分“库存”和“车辆状态”。对于租赁系统,如果一辆车是一个SKU,那库存只可能是0或1。但很多租赁公司实际上是同款车型有多辆车,数据库里会有多条car记录,这时Redis里的key就是“车型+租期”的组合,value是可租数量。比如car:stock:GL8:2025-01-20_2025-01-22表示GL8在1月20日到22日还剩几辆。

Redis高频操作用的是RedisTemplate<String, Object>还是StringRedisTemplate,取决于存储的数据类型。库存扣减是数值操作,直接用StringRedisTemplate最方便,不需要序列化器参与。

@Service public class CarStockService { @Resource private StringRedisTemplate stringRedisTemplate; public Long tryDeductStock(String stockKey, int count) { Long remainder = stringRedisTemplate.opsForValue() .increment(stockKey, -count); if (remainder < 0) { stringRedisTemplate.opsForValue().increment(stockKey, count); throw new RuntimeException("库存不足"); } return remainder; } }

这段逻辑是:先对stockKey执行increment(-count),Redis保证这个操作是原子的,返回值是扣减后的剩余数。如果剩余数小于0,说明扣过头了,立刻把数量加回,然后抛异常。网上很多人遇到过“increment()报错不是integer或out of range”,原因通常是stockKey里存了非数字值,或者初始化时用了set("abc", 1),但实际写入的是字符串。用increment的key必须保证初始值能被Redis解释为整数。

4.2 Java使用RedisTemplate扣减库存的代码细节

上面那段代码用的是StringRedisTemplate。如果项目里已经用了RedisTemplate<String, Object>,要特别注意序列化器。默认的JdkSerializationRedisSerializer会把数字存成二进制乱码,直接increment就会报错。常见做法是给RedisTemplate显式配置StringRedisSerializer,并设置value使用GenericJackson2JsonRedisSerializer。但既然只是扣减数字,最简单可靠的方式是在配置类里单独注册一个StringRedisTemplate的Bean,避免和其他缓存对象混用。

@Configuration public class RedisConfig { @Bean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { StringRedisTemplate template = new StringRedisTemplate(factory); template.afterPropertiesSet(); return template; } }

初始化库存时,不能用set直接写,因为如果服务重启,Redis里的key可能丢失。常见做法是在启动时从数据库加载库存,或者用setIfAbsent只在key不存在时写入。

stringRedisTemplate.opsForValue() .setIfAbsent(stockKey, String.valueOf(stockCount), 24, TimeUnit.HOURS);

setIfAbsent是关键,它只在key不存在的时候才会写入,防止多个实例同时初始化把已有库存覆盖。失效时间设置为24小时,意味着这个key最多存活一天,第二天库存会自动失效,重新从数据库加载。这样避免了“商品下架但缓存还残留”的麻烦。

4.3 用分布式锁兜底订单幂等

Redis扣减库存只能在“数量”维度防超卖,但“同一个用户同时提交两次订单”这种情况,库存可能扣了两次,订单也生成两单。用户点击“立即下单”按钮,前端虽然可以禁用按钮,但脚本可以绕过前端直接调接口。所以后端必须做幂等控制。

最简单的幂等方案是:用订单号作为锁key,在Redis里用SETNX抢占。抢到锁的请求才能继续创建订单,抢不到的直接返回“重复请求”。

public boolean tryAcquireLock(String lockKey, String requestId, long expireSeconds) { Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } public void releaseLock(String lockKey, String requestId) { String value = stringRedisTemplate.opsForValue().get(lockKey); if (requestId.equals(value)) { stringRedisTemplate.delete(lockKey); } }

requestId必须是每次请求唯一的标识,比如UUID。只有持有锁的请求才能删除锁,防止A请求超时后,B请求拿到锁,结果A反过来把B的锁删了。这里的setIfAbsent是Redis的SET key value NX EX的封装,如果key不存在就设置并返回true,key存在就返回false。业务里这样用:

String requestId = UUID.randomUUID().toString(); String lockKey = "order:create:" + orderNo; if (!tryAcquireLock(lockKey, requestId, 10)) { throw new RuntimeException("订单正在处理中,请勿重复提交"); } try { // 执行订单创建 } finally { releaseLock(lockKey, requestId); }

这套方案在面试里对应的是“分布式锁怎么实现”,说到setIfAbsent和value校验,基本就能把话题引到Redis集群的锁可靠性上。注意,锁的过期时间不能设太短,否则长事务还没执行完锁就自动过期了。10秒对于创建订单够用,但如果你在事务里做了大量短信验证码发送,建议把过期时间改成30秒。

5. 汽车租赁管理系统代码部署时的高频问题:环境变量、JVM参数与八股知识点

5.1 java环境变量配置与打包运行命令

代码写完,本地跑通后,下一步是打包部署。这一步劝退过很多人,问题大多出在java环境变量配置上。Windows环境下装完JDK,需要配置JAVA_HOMEPATHJAVA_HOME指向JDK安装目录,比如C:\Program Files\Java\jdk1.8.0_202PATH里要添加%JAVA_HOME%\bin。配置完在命令行执行java -version,只要能输出版本号就代表环境变量没问题。

Linux服务器上,推荐把JDK解压到/usr/local/java,然后在/etc/profile里加入:

export JAVA_HOME=/usr/local/java/jdk8 export PATH=$JAVA_HOME/bin:$PATH export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar

保存后执行source /etc/profile让配置生效。很多项目部署失败不是代码问题,而是服务器上默认用了openjdk但版本不对,编译好的class文件是Java 11编译的,却在Java 8的JVM上跑,直接抛UnsupportedClassVersionError

打包用Maven:

mvn clean package -DskipTests java -jar target/car-rental-1.0.0.jar

生产环境里不要直接裸启动,至少要用nohup放到后台:

nohup java -Xms512m -Xmx1024m -XX:+UseG1GC -jar car-rental.jar > app.log 2>&1 &

-Xms-Xmx设置堆内存,建议一致以避免运行中动态扩容带来的停顿。UseG1GC是JDK 8u201以上推荐的垃圾回收器,比ParallelGC更平滑,尤其适合这类有大量短生命周期订单对象的系统。日志重定向到app.log,排查问题时用tail -f app.log看实时输出。

5.2 遇到NoClassDefFoundError怎么排查

部署时有一类高频报错是uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet,通常出现在高版本JDK或JRE环境里。这个错误翻译过来是“找不到类定义”,常见原因不是你的代码缺依赖,而是运行环境的JDK版本太新,把java.applet.Applet这个老类移除了。

处理思路有两条:如果是公司强制要求用JDK 17,而项目依赖里有老版本工具包引用了Applet,就需要升级依赖版本;如果只是本地测试,最简单的方式是换回JDK 8运行环境。排查步骤也很固定:

java -verbose:class -jar car-rental.jar 2>&1 | grep Applet

-verbose:class会把JVM加载的每一个类打印出来,只要能找到Applet相关的加载记录,就能定位是哪个jar在运行时引用了它。多数情况下是某些报表工具或旧版POI库带了java.applet.Applet的引用。升级到POI 4.0以上版本一般能解决。

5.3 从这段代码反推Java面试必问的集合与锁

汽车租赁系统代码里几乎覆盖了Java面试题里的所有高频点。LambdaQueryWrapper里用到的Lambda,对应“lambda函数在哪里用了”;订单状态枚举对应“Java集合和枚举怎么设计”;分布式锁对应“synchronized和分布式锁区别”;库存扣减对应“Redis的increment是原子的吗”。

如果面试官让你讲讲这个项目,建议按“业务场景->技术方案->细节坑”三层说,不要从“我用Spring Boot写了一个系统”开始。主线可以是“车辆库存有限,所以我需要保证数据库和Redis的原子性,我用了乐观锁更新和Redis increment,最后用setIfAbsent做幂等”。这一段能把你对Java集合里的HashMap线程安全、锁的升级、Redis事务的认识串起来。

一个值得专门验证的技巧是:用压测工具模拟两个用户同时下单,观察数据库里是否只生成一个有效订单。命令很简单:

ab -n 20 -c 10 -p create_order.json -T application/json http://localhost:8080/rent/order/create

这行命令用Apache Bench发20个请求,并发10个,每个请求体是create_order.json,模拟10个人同时下单。跑完后查rent_order表,你会发现即使请求全部成功,订单也只有一条,因为Redis锁把并发放掉了。如果订单表出现两条同一车辆同一时间段的数据,说明锁的过期时间太短,或者锁的key没有包含车辆ID和日期,导致两个不同请求拿到了同一把锁。调整key的设计为order:create:car:{carId}:{startDate}:{endDate},就能把锁粒度精确到“某辆车某段时间”,同时保留同一辆车不同租期可以并行的能力。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 13:49:16

数据湖成本优化:存储与计算资源管理实践

1. 数据湖成本控制的行业背景与核心挑战 在大数据技术快速发展的今天&#xff0c;数据湖已经成为企业数据架构中不可或缺的组成部分。不同于传统数据仓库的严格模式约束&#xff0c;数据湖以其"原始存储按需处理"的灵活特性&#xff0c;能够容纳结构化、半结构化和非…

作者头像 李华
网站建设 2026/9/10 13:49:04

主数据管理定制化实践与行业解决方案

1. 主数据管理为何需要量体裁衣主数据管理&#xff08;Master Data Management&#xff0c;简称MDM&#xff09;就像企业数据体系的"身份证系统"。想象一下&#xff0c;如果一家跨国公司的客户信息在销售、财务、物流系统中各自为政&#xff0c;同一个客户可能被记录…

作者头像 李华
网站建设 2026/9/10 13:48:59

Python+BLE自制手环:从传感器数据到人机交互闭环实战

可穿戴设备的人机交互&#xff0c;听起来像是实验室里才有的课题&#xff0c;但落到代码和硬件上&#xff0c;其实就是“如何让设备知道你抬了一下手腕、心率飙到了多少&#xff0c;然后给出正确的反馈”。这篇文章来自我自己捣鼓的一个完整小项目&#xff1a;用Python做上位机…

作者头像 李华
网站建设 2026/9/10 13:47:52

如何给PDF加水印简单教程,4个实用方法包教包会

你有没有遇到过这种情况&#xff1a;辛辛苦苦做好的PDF方案发给客户&#xff0c;结果对方转手就说是自己做的&#xff1b;或者公司内部流转的合同文件&#xff0c;被人截图外传却查不到源头。其实解决这个问题并不难&#xff0c;给PDF加个水印就能搞定。今天这份PDF加水印简单教…

作者头像 李华