首页 / 资讯中心 / 文章详情

基于Java的物联网环境监测系统:从架构设计到生产级源码拆解

基于Java的物联网环境监测系统:从架构设计到生产级源码拆解 ★ FEATURED ARTICLE
简介一套基于Java语言的物联网环境监测系统设计源码面向物联网开发者、Java工程师、高校相关专业学生及环境监测项目人员旨在解决环境数据实时采集、无线传输、集中处理与可视化展示等核心问题。压缩包共43个文件约3.51MB其中15个Java源代码文件负责业务逻辑及数据采集、数据处理、通信传输等核心模块14个XML配置文件用于系统参数配置与组件装配3个JAR包提供数据库连接、日志管理和XML解析等第三方库支持另有properties属性文件存储关键配置项目文件与Git忽略文件辅助工程管理。整套系统采用模块化设计已有338人学习使用结构清晰便于按需替换传感器类型、扩展数据分析算法。通过阅读源码可掌握物联网监测系统从数据接入、日志记录、配置管理到交互界面的完整实现思路同时借鉴其工程目录组织方式可高效支撑毕业设计、课程实训或企业原型开发。1. 基于Java的物联网环境监测系统它不是玩具项目而是生产级方案的骨架做物联网环境监测很多开发者第一反应是用Python脚本或Node-RED快速拼一个采集链路跑通演示没问题真放到需要24小时连续运行、多设备并发上报、数据要留存分析的生产现场脚本方案往往在第三周就开始出各种玄学问题连接池耗尽、线程阻塞、内存涨上去降不下来。用Java重写一遍不是重复造轮子而是把系统的可靠性、可维护性和并发承载能力重新立起来。这套设计源码的核心价值在于它完整覆盖了从传感器数据上报、MQTT协议接入、数据清洗落库到告警触发的全链路并且把每个环节的扩展点都留好了口子。适合谁适合已经用单片机或Python调通了传感器、想往正式产品形态过渡的开发者也适合需要给团队搭一个可协作的工程化基座、而不是继续在脚本里堆逻辑的从业者。它解决的核心问题只有一个让环境监测系统从“能跑”变成“能长期稳定地跑”。2. 架构先行把采集、处理、存储、告警拆成四层再看源码拿到这套源码第一件事不是打开IDE看类而是先理解它的分层结构。环境监测系统的难点从来不在单点技术而在于数据链路长传感器每分钟上报一次温湿度、PM2.5、CO₂设备数量从几台到几百台数据格式有JSON、有自定义二进制、甚至还有老设备上报的逗号分隔文本。如果所有逻辑揉在一起改一个协议就要动全局。这套源码采用的四层架构本质上是把数据生命周期切成采集接入、消息转发、业务处理、持久化与告警四个独立阶段。2.1 四层架构与每一层的选型理由分层是这套源码最值得先读的部分。接入层配置了Netty作为TCP/UDP网关同时预留了MQTT Broker的桥接位置。选择Netty而不是直接用Tomcat的Servlet接口接收数据是因为环境监测设备大量使用长连接且上报频率高、单包体小Netty的Reactor模型在这种场景下线程利用率远高于阻塞式IO。业务处理层用了Spring Boot作为粘合框架把协议解析、数据校验、规则引擎串起来Spring Boot在这里不是性能关键点而是开发效率关键点。持久化层默认走MySQL加MyBatis-Plus告警层通过独立的调度线程扫描规则。这个选型组合在200台设备、每分钟一条上报的场景下毫无压力但如果设备量过千、上报频率提到秒级MySQL就会成为瓶颈这部分扩展路径在最后一章展开。源码里还有一个容易被忽略的设计每层之间通过消息队列接口解耦默认实现是内存队列LinkedBlockingQueue但接口签名已经按可替换成RabbitMQ或Kafka的标准来写这意味着从单机部署迁移到分布式集群时不需要改动业务代码。2.2 数据流转链路从传感器引脚到数据库行的完整路径跟踪一条数据怎么走完整个系统是读懂源码最快的方法。以最常见的DHT11温湿度传感器为例设备端MCU读取数据后通过ESP8266的WiFi模块以MQTT协议发布到主题/env/{deviceId}/report消息体是JSON格式。源码的接入层通过Paho MQTT客户端订阅这个主题收到消息后先做基础格式校验再投递到内存队列。业务处理层从队列拉取消息依次执行三个动作先做协议版本判断不同版本的消息字段名有差异源码里用策略模式按protocolVersion字段路由到对应解析器然后做合法性校验比如湿度值必须在0到100之间、温度值必须在-40到85之间超出范围直接丢弃并记录异常日志最后把解析后的数据封装成统一的数据实体EnvDataRecord批量写入MySQL。告警层是独立线程每10秒扫描一次最近5分钟的监测数据命中规则就触发告警事件。整个链路有三个精心设计的地方一是队列在这套架构里起到了削峰填谷的作用设备批量上报时不会直接打满数据库连接池二是数据实体的字段设计考虑了时序查询需求record_time单独建索引device_id和record_time组成联合索引三是所有环节都有对应的日志埋点定位问题不需要去猜数据丢在哪一环。2.3 数据库设计监测数据表、设备表、告警记录表的字段要点打开SQL脚本核心是三张表。设备表device_info不光是存设备ID和名称还记录了protocol_version、firmware_version和last_online_time后两个字段是判断设备是否需要升级、是否离线的依据。监测数据表env_data_record的表结构值得仔细看它没有为每一种传感器建一张表而是用sensor_type加value的宽表设计好处是新增传感器类型不需要改表结构坏处是查询特定类型时需要带sensor_type条件源码里给sensor_type和record_time建了联合索引来缓解这个问题。告警记录表alert_record保存了触发的规则ID、设备ID、告警级别、触发值和确认状态。这里有一个实战中经常踩的坑告警表如果无限增长两三个月后查询就会明显变慢。源码默认没有做分区这是这套代码在生产化改造时需要优先补充的事项。三张表之间的关联都通过外键逻辑维护但物理上故意不建外键约束目的是提高写入性能由业务层的DeviceService来保证数据完整性。3. 核心模块源码拆解采集、解析、存储、告警里最值得抄的六个类读完架构再进代码就能分清哪些类是核心、哪些类只是工具。这套源码里最值得精读的不是Controller层而是处理数据链路的四个关键类和两个工具类。它们解决的都是环境监测场景里反复出现的实际问题代码结构可以直接移植到其他物联网项目中。3.1 设备接入层Netty网关的初始化与消息解码设备接入的入口是NettyServerInitializer类它负责装配ChannelPipeline。源码里主线程和业务线程分离的方式值得学习BossGroup只负责接收连接WorkerGroup负责读写IO接收到的消息先经过IdleStateHandler做心跳检测再进入MessageDecoder做字节解码。看这段代码时重点注意两个参数。// NettyServerInitializer.java 核心装配逻辑 ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); // 5分钟没收到设备心跳触发IdleStateEvent pipeline.addLast(idleHandler, new IdleStateHandler(300, 0, 0, TimeUnit.SECONDS)); // 自定义解码器处理TCP粘包拆包 pipeline.addLast(messageDecoder, new MessageDecoder()); // 业务处理器负责把消息转成内部事件 pipeline.addLast(messageHandler, new MessageHandler()); } });SO_BACKLOG参数决定TCP连接等待队列的长度设备量大的时候这个值太小会导致连接被内核拒绝128只是个保守值生产环境建议根据设备并发量调到512或1024。TCP_NODELAY必须设为true否则开启Nagle算法后小包会被缓冲合并导致一条温湿度数据延迟几百毫秒才发出去。IdleStateHandler的300秒含义是如果5分钟没收到这个设备的任何字节就触发空闲事件MessageHandler里会对该事件做连接关闭处理避免僵尸连接占着文件描述符不放。这个类的第二重点是MessageDecoder。环境监测设备的上报协议不统一有的用换行符分包有的用固定长度头加长度字段分包。源码里的解码器实现了ByteToMessageDecoder先读前4个字节拿到消息总长度再按长度截取完整报文这样TCP粘包和拆包问题就同时解决了。如果设备端协议是自定义的改这个方法里的长度解析逻辑即可。3.2 数据解析层策略模式如何优雅处理多协议设备环境监测项目做久了都会遇到同一个烦恼业主方陆续买了三批不同厂家的传感器报文格式各不相同每接入一批新设备就要改一遍主解析逻辑。这套源码的处理方式是定义一个ProtocolParser接口每一种协议实现一个Parser类再通过ParserFactory根据protocolVersion字段路由。新增协议时不用动老代码这正是环境监测系统要长期演进必须具备的结构。// 策略模式解析器示例JSON格式与CSV格式各自的Parser public interface ProtocolParser { EnvDataRecord parse(String deviceId, String payload) throws ParseException; Integer supportedVersion(); } Component(jsonProtocolParser) public class JsonProtocolParser implements ProtocolParser { Override public EnvDataRecord parse(String deviceId, String payload) throws ParseException { // 只解析JSON格式报文字段映射清晰 JSONObject obj JSON.parseObject(payload); EnvDataRecord record new EnvDataRecord(); record.setDeviceId(deviceId); record.setSensorType(obj.getString(sensorType)); record.setValue(obj.getBigDecimal(value)); // 毫秒时间戳转成LocalDateTime统一存储时区 record.setRecordTime(LocalDateTime.ofInstant( Instant.ofEpochMilli(obj.getLong(ts)), ZoneId.of(Asia/Shanghai))); return record; } Override public Integer supportedVersion() { return 1; } }这个设计里有两个容易忽略但很重要的点。一是supportedVersion()返回的版本号和数据库device_info表里的protocol_version字段严格对应设备上报时接入层查一次设备表拿到版本号后续处理就按这个路由。二是时间戳转换是真实项目里的重灾区设备可能上报的是秒级时间戳、毫秒级时间戳或者带时区的ISO字符串源码统一在Parser层转成LocalDateTime并且明确指定了Asia/Shanghai时区避免部署到不同时区的服务器时数据时间漂移好几个小时。3.3 批量写入用MyBatis-Plus的saveBatch还是自写批量插入数据落库是环境监测系统最容易出现性能瓶颈的环节。逐条INSERT在设备量上来后数据库CPU会持续飙高。源码里没有直接调用MyBatis-Plus的saveBatch()而是在EnvDataService里自建了一个批量写入缓冲区这个设计的动机来自于一个真实教训saveBatch()默认每次执行时生成新的SqlSession批量大小超过阈值后SQL语句长度会触碰MySQL的max_allowed_packet限制。源码的做法是设置一个容量为500的ArrayList作为缓冲区写入方法先往缓冲区add数据满了500条才执行一次真正的批量INSERT。同时暴露了一个定时任务每3秒检查一次缓冲区即使数据量不满500条也会把积压的数据刷入数据库保证数据不会在缓冲区里滞留太久。// 批量写入核心逻辑减少了数据库交互次数 Component public class EnvDataBuffer { private final ListEnvDataRecord buffer new ArrayList(500); private final Object lock new Object(); public void add(EnvDataRecord record) { synchronized (lock) { buffer.add(record); if (buffer.size() 500) { flushToDatabase(); } } } Scheduled(fixedRate 3000) public void scheduledFlush() { synchronized (lock) { if (!buffer.isEmpty()) { flushToDatabase(); } } } }synchronized (lock)在这里是必需的因为Netty的业务线程和定时任务线程会同时操作缓冲区。这里有个细节flushToDatabase()里要先把buffer的内容取出并清空原列表再执行批量插入否则并发情况下可能出现同一批数据被重复插入或者插入丢失。源码里flushToDatabase()内部先new ArrayList(buffer)再buffer.clear()的写法是标准解法。批量插入的SQL在Mapper层通过foreach标签拼接500条数据一次插入的耗时大约在100到200毫秒比逐条插入起码快一个数量级。3.4 告警规则引擎规则配置化后的解析与触发告警模块设计的亮点是规则与执行分离。规则的配置不是写在代码里写死而是存储在数据库表alert_rule中字段包括rule_id、sensor_type、operator大于、小于、等于、threshold_value、alert_level、duration_minutes。新增一条告警规则只需要往表里插入一行数据下次扫描时就会生效不需要重新编译部署。告警扫描器是一个Scheduled定时任务每10秒执行一次。扫描逻辑是先查所有启用的规则再按规则维度查询最近5分钟的监测数据用规则里的条件做匹配。为了避免每10秒都要全表扫一次监测数据源码增加了两个优化一是按sensor_type字段分组查询二是只在监测数据表的最新分区内扫描。这个设计的代价是告警实时性做不到秒级如果需求是设备值一超标立刻电话通知还需要引入流处理框架这套源码默认的分钟级告警已经覆盖了大多数环境监测场景。// 告警规则触发判断核心逻辑 Component public class AlertScanner { Scheduled(fixedRate 10000) public void scan() { // 1. 查询所有启用的规则 ListAlertRule rules alertRuleMapper.selectList( new LambdaQueryWrapperAlertRule().eq(AlertRule::getEnabled, true)); for (AlertRule rule : rules) { // 2. 查询最近5分钟内该规则对应传感器的所有数据 LocalDateTime since LocalDateTime.now().minusMinutes(5); ListEnvDataRecord records envDataMapper.selectList( new LambdaQueryWrapperEnvDataRecord() .eq(EnvDataRecord::getSensorType, rule.getSensorType()) .ge(EnvDataRecord::getRecordTime, since)); // 3. 逐条判断是否触发阈值 for (EnvDataRecord record : records) { boolean triggered compareValue(record.getValue(), rule.getOperator(), rule.getThresholdValue()); if (triggered) { // 记录告警并且防止同一设备同一规则在10分钟内重复报警 saveAlertIfNotDuplicated(rule, record); } } } } }这段代码里最值得抄的点是防重复告警。环境监测里如果温度连续半小时超标业务方不希望每10分钟收到一条报警短信而是希望第一条报警后后续处于静默期。源码的saveAlertIfNotDuplicated方法里带了一个last_alert_time判断同一设备同一规则在10分钟内只产生一条告警记录这个逻辑在真实运营中直接决定了告警系统会不会被业务方投诉为骚扰短信轰炸机。3.5 两个工具类单位换算与数据补传对账源码里还有两个不起眼但实战价值极高的工具类。第一个是UnitConverter处理环境监测数据里各单位不一致的问题——同一个传感器有的批次上报的是帕斯卡有的上报的是千帕还有的是毫米汞柱。工具类内部维护了一张传感器类型到标准单位的映射表Parser解析后统一转成标准单位避免告警规则阈值设置混乱。第二个是DataReconcileService解决的是设备离线补传问题。部分环境监测设备在信号差的时候会把数据本地缓存恢复联网后集中上报。如果不做处理这批补传数据会直接进入告警扫描范围可能产生大量误报。源码的做法是凡是record_time与当前时间差超过10分钟的补传数据只落库不参与告警扫描同时单独打一个is_backfill标记这样既保留了完整数据链又不会干扰实时告警。4. 本地跑通最小系统从零启动到看到第一条入库数据这套源码虽然设计完整但直接放到生产环境之前必须先在本地把最小闭环跑起来。所谓最小闭环就是一台电脑上启动MySQL、MQTT Broker、后端服务再加上一个模拟设备程序完整的链路是模拟设备发送一条温湿度JSON到MQTT后端订阅、解析、落库、打印日志。这个过程走通后后续替换成真实设备只是换数据源的问题。4.1 环境准备与项目导入本地环境需要JDK 1.8以上、Maven 3.6以上、MySQL 5.7以上MQTT Broker以EMQX 4.x的本地docker方式启动最简单。如果不想用Docker也可以用源码里集成的嵌入式MQTT Broker模式application.yml里有个mqtt.embedded.enabled配置项设为true时会启动一个内嵌Broker测试阶段连EMQX都不需要单独装。导入源码的方式与常规Maven项目一致。pom.xml的依赖锁定在Spring Boot 2.7.x版本这里不要为追求新版本升级到Spring Boot 3.x因为3.x基于Jakarta EENetty和MQTT客户端的部分依赖在3.x下需要额外适配本地验证阶段用稳定版本省去不必要的排错时间。4.2 初始化数据库与基础配置数据库初始化按照源码根目录下sql/init.sql脚本执行脚本会自动创建数据库iot_env_monitor和三张核心表并插入两条示例设备数据和三条告警规则数据。执行完成后可以立即验证设备表里的数据关键在于确认protocol_version字段与源码里JsonProtocolParser的supportedVersion()返回值一致这一步错误是启动后数据不落库的头号原因。# 初始化数据库Linux / macOS 环境 mysql -uroot -p sql/init.sql # 确认三张表创建成功 mysql -uroot -p -e USE iot_env_monitor; SHOW TABLES;如果你用的是Windows环境也完全可以通过Navicat等图形化工具直接运行init.sql。执行成功后需要在application.yml里修改spring.datasource.url为自己的数据库地址并同步修改username和password为本地MySQL的账户。特别注意url参数里追加的characterEncodingutf8serverTimezoneAsia/Shanghai如果这两个参数缺失后面会出现中文乱码和时区偏差问题。4.3 修改MQTT连接参数application.yml里与MQTT相关的配置集中在mqtt:前缀下需要确认的参数有四个broker-url、client-id、topic-subscribe、qos。默认配置是tcp://127.0.0.1:1883客户端ID为env-monitor-service-01订阅主题为/env//report。主题里的通配符含义是匹配任意一级这样新设备注册时只要按/env/{deviceId}/report规则发布都不用改后端订阅逻辑。QoS级别在环境监测场景通常选1。级别0可能丢消息级别2会有两倍通信开销级别1保证消息至少送达一次虽然可能重复但配合数据库写入时的幂等校验可以解决。源码里没有做重复消息的去重这是留给大家的一个改造点。压测时把模拟设备数量加大可以观察到重复消息的比例。mqtt: broker-url: tcp://127.0.0.1:1883 client-id: env-monitor-service-01 topic-subscribe: /env//report qos: 1 embedded: enabled: false4.4 启动服务与模拟设备验证后端服务的启动入口是EnvMonitorApplication类和所有Spring Boot应用一样执行mvn spring-boot:run或者在IDE里直接运行main方法。启动完成后关注日志输出重点看三行内容Netty端口是否监听成功、MQTT订阅是否建立成功、数据库连接池是否初始化成功。模拟设备方面源码的src/test/resources目录下有一个SimulatorDevice.java它不是传统意义上的单元测试而是一个独立运行的设备模拟器。启动后它会每10秒向MQTT主题推送一条随机温湿度数据运行后返回数据库执行SELECT * FROM env_data_record ORDER BY record_time DESC LIMIT 10;应该能看到新入库的数据。// SimulatorDevice.java 关键部分简化 public class SimulatorDevice { public static void main(String[] args) throws Exception { MqttClient client new MqttClient(tcp://127.0.0.1:1883, MqttClient.generateClientId()); client.connect(); while (true) { JSONObject payload new JSONObject(); payload.put(sensorType, temperature); payload.put(value, 20 Math.random() * 10); // 随机温度 payload.put(ts, System.currentTimeMillis()); client.publish(/env/D001/report, new MqttMessage(payload.toJSONString().getBytes())); Thread.sleep(10000); } } }如果数据没有正常落库排查顺序遵循链路方向先看MQTT订阅日志有没有显示建立成功再看后端业务日志有没有Parser解析异常最后看SQL执行日志有没有报错。绝大对数初次跑通失败的原因都出在broker地址写错或者设备版本号不匹配日志里都有明确关键词可以定位。5. 避坑指南环境监测系统上线前后最常见的5个翻车点这套源码整体工程质量不低但把它从本地Demo推向真实环境时有几个典型的坑会在不同阶段绊住你。这些坑我在不同项目里都撞过有些是源码本身埋的隐患有些是部署方式导致的必然问题提前知道能省下大量排查时间。5.1 MQTT客户端断线重连后订阅丢失现象是服务启动后前几小时一切正常某天凌晨MQTT Broker重启或网络抖动后端日志显示连接断开但重连成功后再也没有新数据入库。原因是Paho MQTT客户端的默认行为在重连后不会自动恢复订阅必须手动重新subScribe。源码里MqttSubscriber类实现了MqttCallback接口重连成功的回调方法里有subscribeTopics()调用但部分早期版本源码缺少这段。解决方法是检查重连回调中是否包含订阅重建逻辑如果没有需要补上client.subscribe(topicFilter, qos)。5.2 时间戳时区不一致导致跨天数据归错日期现象是按日期统计日均值时凌晨接近0点的数据偶尔统计到前一天或者设备上报时间显示相差8小时。原因是设备端上报时间戳用的是UTC秒数而后端数据库使用北京时间转换过程如果没有指定时区就取了服务器本地时区。如果部署在Linux服务器上系统timezone恰好是UTC数据入库后看起来就慢8小时。解决方案就是在Parser层统一用ZoneId.of(Asia/Shanghai)转换同时保证MySQL连接串带serverTimezoneAsia/Shanghai并且启动参数加-Duser.timezoneAsia/Shanghai三重统一。5.3 MySQL连接池耗尽导致的服务雪崩现象是设备量增长后服务日志频繁出现Connection is not available, request timed out数据库CPU利用率并不高但连接池被打满。根因是批量写入缓冲区刷库逻辑在数据量突增时触发了大量并发INSERT每个INSERT从连接池申请连接而连接池默认配置只有10个瞬间被占满后告警扫描、设备查询等所有操作全部排队。解决方法是先按maximum-pool-size调整到50左右再配合排队限流如果还不够就需要把批量缓冲区的容量和定时刷库频率重新调节从系统上降低对连接池的争抢。5.4 Netty的ChannelHandler共享导致的并发读写异常现象是设备连接数一多日志里出现ChannelHandlerContext相关的异常或者数据错乱。原因是部分开发者使用Sharable注解Handler时在类里维护了可变的成员变量保存当前设备信息多个设备连接共用同一个Handler实例时互相覆盖。源码里没有这个坑但改造时很容易踩。正确做法是Handler只处理无状态逻辑设备相关状态写入Channel的AttributeKey每次读取时从Channel拿到当前设备上下文。5.5 数据库表无限增长导致告警扫描越来越慢现象是系统上线运行三个月后告警触发延迟从秒级变成分钟级查看SQL日志发现告警扫描的查询耗时从几十毫秒涨到几秒。原因是env_data_record表没有做任何归档或分区策略三个月累计了上千万行数据告警扫描的ge recordTime范围查询全表扫描。源码默认设计没有考虑这个问题生产化必须补上。快速解决方案是按月做RANGE分区慢SQL直接减少90%以上扫描量历史数据的清理策略按业务保留需求设置为半年或一年。6. 从Demo到生产把这套源码改造成可靠系统的几个进阶手段跑通源码只是起点真正要让这套系统扛住生产环境还需要在安全、数据可靠性、存储选型三个方面做升级。这三个方向正好对应环境监测项目交付时最难回答的三个拷问数据会不会被篡改断网补传的数据会不会丢数据量大了撑不撑得住。第一件事是设备认证。源码默认任何客户端只要知道MQTT主题就能上报数据这在隔离网络里没问题但凡是设备部署在公网就必须给每个设备分配独立的用户名密码并开启Broker的ACL权限控制限制每个设备只能往自己的主题前缀发布消息。EMQX的鉴权插件能做到设备级隔离Core侧只需要在接入时校验设备ID与MQTT ClientId的绑定关系。第二件事是数据端到端校验。源码里已经对字段合法性做了校验但没有对数据来源做真实性校验。建议在Parser阶段增加哈希校验字段设备每上报一条数据附带一条HMAC签名串密钥只保存在设备端和服务端收到消息后先验签再解析这个改动成本不高但能杜绝伪造数据导致的告警误报。第三件也是最重要的事情认真评估是否要引入时序数据库。当单日数据量超过百万行时MySQL的存储和查询效率会明显下滑这时候把sensor_data的存储迁移到TDengine或InfluxDB是常见做法MySQL保留设备表和告警表两者通过record_time对齐即可。源码里EnvDataService的接口抽象已经为这种替换预留了缝替换Mapper实现时不需要动上层逻辑。这三个改造做完这套系统才算真正具备了交付给业主方的底气。回顾整个项目我自己的深刻教训是不要因为一套源码“能跑”就直接上生产环境监测系统的运行周期是论年算的设备会升级、协议会变、数据量会涨一开始留出的扩展空间决定了系统能活多久。工程上的耐心都藏在那些看似不起眼的抽象和预留里。希望这篇拆解帮到你少踩一些我当初踩过的坑。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站