ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于iBeacon的室内定位服务端架构设计与Java实现

基于iBeacon的室内定位服务端架构设计与Java实现 简介室内定位技术通过蓝牙、Wi-Fi等信号实现物体或人员在室内的位置感知其核心原理是利用信号强度衰减模型或指纹匹配算法进行位置解算。这项技术的工程价值在于为仓储物流、智慧楼宇、商场导览等场景提供高精度、低成本的定位能力从而优化运营效率与用户体验。本文聚焦于iBeacon定位方案针对信号波动、环境干扰等挑战深入探讨了服务端定位引擎的架构设计。通过采用微服务架构结合Netty、Redis、Kafka及InfluxDB等技术栈构建了高并发数据处理管道并实现了卡尔曼滤波与指纹匹配等关键算法以提升定位精度与系统稳定性。1. 项目缘起从“找东西”到“找位置”的工程实践几年前我在一个大型仓储物流项目中遇到了一个非常具体且头疼的问题如何在数万平米的仓库里快速、准确地定位一个关键设备或者一板特定货品传统的方案比如给每个货架装RFID读写器成本高得吓人用Wi-Fi指纹定位精度又不够而且部署和维护极其复杂。就在我们团队挠头的时候一个偶然的机会我接触到了基于蓝牙4.0的iBeacon技术。它的核心思路让我眼前一亮把定位的复杂性从终端设备转移到基础设施和环境上。简单来说iBeacon就像一个个微型的、只会“喊自己名字”的蓝牙广播站。它们以极低的功耗持续向外广播一个包含UUID、Major、Minor和信号强度RSSI的数据包。我们的手机或者专用的手持终端接收到这些广播后并不需要复杂的计算只需要将收到的信号数据主要是哪个Beacon、信号有多强上传到服务端。所有的定位算法、地图匹配、轨迹分析全部在服务端完成。这个架构上的转变意味着终端可以做得非常轻量、省电而服务端则拥有了处理海量数据、运行复杂算法的能力并且可以随时迭代升级定位引擎而无需让成千上万的终端设备更新固件。这个项目就是我当时为了验证和实现这套服务端定位引擎而做的。我用Java作为后端语言一方面是因为团队技术栈统一另一方面Java在构建高并发、稳定可靠的服务端应用方面生态成熟工具链完善。今天我就把这个项目的设计思路、核心源码实现以及踩过的那些坑完整地分享出来。无论你是想了解室内定位的原理还是正在着手实现一个类似的系统希望这篇超过五千字的“实战笔记”能给你带来实实在在的参考价值。2. iBeacon定位的核心原理与服务端角色拆解在开始敲代码之前我们必须彻底搞清楚iBeacon定位到底是怎么一回事以及为什么服务端是其中的“大脑”。很多人一提到iBeacon就想到“近场感应”或者“打卡”这其实只用了它最基础的能力。要实现真正的“定位”我们需要深入一层。2.1 iBeacon广播数据定位的“原材料”一个标准的iBeacon广播帧可以看作一个包含了四层信息的“信封”UUID一个128位的通用唯一标识符。这通常用于标识一个特定的项目、场馆或品牌。例如一个商场、一个博物馆、或者我们这个仓库项目都会有一个独立的UUID。它是最高层级的过滤条件。Major一个16位的无符号整数0-65535。这通常用于标识一个大的分组比如一栋楼里的不同楼层或者一个仓库里的不同区域。Minor另一个16位的无符号整数。它用于在Major分组内进行更细粒度的标识比如一个区域内的具体货架排。Measured Power (校准功率)这是一个1字节的有符号整数单位是dBm。它表示在距离iBeacon设备1米处接收到的信号强度RSSI的参考值。这个值由Beacon厂商在出厂时校准并写入设备是后续距离估算的关键参数。当终端设备如手机扫描到这些广播包时它能获取到上述四个值以及一个实时测量到的RSSI值。这个RSSI值就是信号在空气中传播衰减后的强度。服务端定位算法的起点就是这一组组{UUID, Major, Minor, RSSI}数据流。2.2 从RSSI到距离不完美的转换最基础的定位思想是“三点定位”。要定位首先得知道终端到每个已知位置Beacon的距离。这里就用到了RSSI。无线电波在空间传播其信号强度会随着距离增加而衰减。有一个经验模型叫做对数距离路径损耗模型公式简化后如下RSSI MeasuredPower - 10 * n * lg(d)其中RSSI是测量值。MeasuredPower是1米处的参考RSSI通常为-59dBm或-69dBm。n是路径损耗指数与环境密切相关开放空间约2.0办公室约2.7-3.5多墙环境可达4.0以上。d是待求的距离米。lg是以10为底的对数。我们可以反推出距离dd 10 ^ ((MeasuredPower - RSSI) / (10 * n))这里就是第一个大坑这个模型极其理想化。现实环境中人体遮挡、金属货架反射、其他无线电干扰都会导致RSSI剧烈波动。可能一秒钟内同一个位置测得的RSSI能从-65dBm跳到-80dBm换算出的距离能从2米飘到10米开外。所以单纯依赖单次RSSI测距进行三点定位在复杂室内环境下基本不可用。这也是为什么必须把算法放在服务端的原因之一——我们需要对数据进行滤波、融合和历史轨迹分析。2.3 服务端的核心职责理解了原始数据的局限性服务端的任务就清晰了数据接入与清洗接收来自无数终端的海量、高频的Beacon扫描数据包进行解析、校验和格式化。数据滤波与平滑对同一个终端上报的、针对同一个Beacon的连续RSSI值采用滑动平均、卡尔曼滤波等算法滤除噪声得到一个相对稳定的估值。定位解算利用滤波后的数据运用定位算法如三角定位、指纹匹配、粒子滤波等计算出终端最可能的位置坐标x, y, 可能还有z楼层。地图匹配与轨迹生成将计算出的原始坐标匹配到预设的电子地图路径上并连接连续的位置点形成平滑、合理的移动轨迹。数据存储与查询将原始数据、处理后的位置点、用户轨迹持久化存储并提供历史轨迹查询、区域告警、热力图分析等上层应用接口。接下来我们就进入实战环节看看如何用Java构建这样一个服务端系统。3. 服务端系统架构设计与技术选型一个稳定可靠的定位服务端不能是一个简单的“算法程序”它必须是一个具备高并发、高可用、可扩展特性的分布式系统。这是我当时设计的核心架构图文字描述[终端设备] --(蓝牙扫描数据)-- [API网关/负载均衡] -- [定位计算微服务集群] | v [Web管理后台] --(数据查询/配置管理)-- [配置中心/元数据服务] [消息队列 (如Kafka/RabbitMQ)] | v [数据存储层] (Redis MySQL 时序数据库)为什么选择微服务架构因为定位数据的处理流程有明显的阶段划分数据接入、实时计算、数据存储、业务查询。拆分成微服务后每个服务可以独立开发、部署和伸缩。比如在“双十一”这样的高峰期我们可以单独扩容“定位计算”服务的实例数量以应对暴涨的数据流量。核心组件技术选型与理由网络框架Netty为什么终端上报数据是典型的高频、小包、长连接场景。传统的基于Servlet的HTTP服务器如Tomcat为每个请求分配一个线程在这种场景下线程创建、销毁的开销巨大容易成为瓶颈。Netty是一个异步事件驱动的高性能网络框架它使用极少的线程就能处理大量连接非常适合作为数据接入层的服务器。我们用它来定义一个自定义的TCP或UDP协议高效接收终端上报的二进制数据包。数据缓存与实时计算Redis Spring Boot为什么定位计算中需要频繁读写两类数据一是终端的最新状态如最近上报的Beacon列表二是Beacon的元数据如位置坐标、校准功率。这些操作对延迟极其敏感。Redis作为内存数据库读写性能在微秒级是完美选择。我们用Redis的Hash结构存储终端最新数据用String或Hash存储Beacon元数据。Spring Boot用于快速构建“定位计算”这个微服务它集成了丰富的生态如Spring Data Redis能让我们专注于业务逻辑。消息队列Kafka为什么数据流需要被多个消费者处理。比如原始数据包一方面要进入实时计算管道另一方面可能需要归档到大数据平台做离线分析。Kafka的高吞吐、持久化和发布-订阅模型能很好地解耦生产者和消费者保证数据不丢失并允许我们灵活地增加新的数据处理流程。持久化存储MySQL InfluxDB (或TDengine)MySQL存储系统元数据如用户信息、Beacon设备信息UUID, Major, Minor, 物理位置坐标x,y,zMeasuredPower、电子地图信息、权限配置等。这些数据结构化强增删改查需求明确。时序数据库 (如InfluxDB)这是关键选择。终端的位置信息是典型的时序数据每个数据点都包含时间戳、终端ID、位置坐标。时序数据库针对这种写多读少、按时间范围查询的场景做了大量优化压缩比高查询速度快非常适合存储海量的轨迹点数据。相比用MySQL分表存储管理和查询效率有数量级的提升。定位算法库Apache Commons Math 或 自研JNI封装三角定位、矩阵运算、滤波算法等需要大量的数学计算。Apache Commons Math提供了丰富的数学工具类。如果算法性能要求极高如粒子滤波可以考虑用C实现核心部分然后通过JNIJava Native Interface供Java调用。4. 核心模块源码实现与关键代码解读下面我挑几个最核心的模块结合代码片段已做简化脱敏来讲解具体实现。请注意为了突出重点我省略了异常处理、日志记录等样板代码。4.1 数据接入层基于Netty的自定义协议解码器终端上报的数据包格式是我们自定义的一个简单的例子[终端ID(8字节)][Beacon数量N(1字节)][(UUID(16字节)Major(2字节)Minor(2字节)RSSI(1字节)) * N]。// 自定义协议解码器继承Netty的ByteToMessageDecoder public class BeaconDataDecoder extends ByteToMessageDecoder { // 数据包最小长度终端ID8 Beacon数量1 9字节 private static final int MIN_PACKET_LENGTH 9; Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { // 1. 可读数据小于最小包长度等待下次数据到来 if (in.readableBytes() MIN_PACKET_LENGTH) { return; } // 2. 标记当前读指针以便回溯 in.markReaderIndex(); // 3. 读取终端ID (假设为Long类型) long terminalId in.readLong(); // 4. 读取Beacon数量 short beaconCount in.readUnsignedByte(); // 5. 计算完整包长度9字节 每个Beacon 21字节 int expectedLength MIN_PACKET_LENGTH beaconCount * 21; if (in.readableBytes() expectedLength - 9) { // 已读了9字节 in.resetReaderIndex(); // 数据不够重置读指针等待 return; } // 6. 读取所有Beacon数据 ListBeaconScan scanList new ArrayList(beaconCount); for (int i 0; i beaconCount; i) { byte[] uuidBytes new byte[16]; in.readBytes(uuidBytes); String uuid bytesToHex(uuidBytes); // 转换为16进制字符串 int major in.readUnsignedShort(); int minor in.readUnsignedShort(); int rssi in.readByte(); // RSSI为有符号字节 scanList.add(new BeaconScan(uuid, major, minor, rssi)); } // 7. 构造业务对象传递给下一个Handler处理 BeaconDataPacket packet new BeaconDataPacket(terminalId, System.currentTimeMillis(), scanList); out.add(packet); } private String bytesToHex(byte[] bytes) { ... } // 字节数组转16进制字符串工具方法 } // 对应的业务数据对象 Data // 使用Lombok简化代码 public class BeaconDataPacket { private long terminalId; private long timestamp; private ListBeaconScan scans; } Data public class BeaconScan { private String uuid; private int major; private int minor; private int rssi; }关键点与踩坑记录粘包与拆包这是网络编程的经典问题。我们的协议是定长的吗不是因为Beacon数量可变。所以解码器必须能正确处理“数据半包”的情况。上面代码中的markReaderIndex()和resetReaderIndex()就是用来处理这个的。如果发现数据不够一个完整包就重置指针等待下次数据到来。Netty也提供了LengthFieldBasedFrameDecoder等更通用的解决器但对于自定义协议自己实现ByteToMessageDecoder更灵活。性能避免在解码器中进行复杂的业务逻辑或阻塞IO操作。解码器的唯一任务就是把字节流正确地转换成内存中的Java对象。后续的过滤、计算等应交给独立的业务Handler或发往消息队列。4.2 数据滤波服务RSSI的卡尔曼滤波实现如前所述RSSI波动很大。卡尔曼滤波是一种高效的递归滤波算法能从一系列包含噪声的测量值中估计出系统的最优状态。这里我们将其应用于对单个终端-单个Beacon的RSSI序列进行平滑。Component public class KalmanFilterRssi { // 过程噪声协方差表示我们对预测模型的信任程度值小则更信任模型 private double processNoiseCov; // 测量噪声协方差表示我们对测量值的信任程度值小则更信任测量 private double measurementNoiseCov; // 估计误差协方差 private double estimationErrorCov; // 当前的最优估计值滤波后的RSSI private double estimatedRssi; public KalmanFilterRssi(double initRssi) { this.processNoiseCov 1e-5; // 经验值可调 this.measurementNoiseCov 0.1; // 经验值RSSI噪声大这个值相对大一些 this.estimationErrorCov 1.0; // 初始估计误差 this.estimatedRssi initRssi; } /** * 卡尔曼滤波更新步骤 * param measuredRssi 新测量到的RSSI值 * return 滤波后的RSSI值 */ public double update(double measuredRssi) { // 1. 预测阶段我们的模型很简单认为RSSI不变 // 预测值 上一次的最优估计值 double predictedRssi this.estimatedRssi; // 预测误差协方差 上一次估计误差 过程噪声 double predictedErrorCov this.estimationErrorCov this.processNoiseCov; // 2. 更新阶段结合测量值 // 卡尔曼增益 预测误差 / (预测误差 测量噪声) // 它决定了我们是更相信预测值还是测量值 double kalmanGain predictedErrorCov / (predictedErrorCov this.measurementNoiseCov); // 3. 计算当前最优估计 // 最优估计 预测值 增益 * (测量值 - 预测值) this.estimatedRssi predictedRssi kalmanGain * (measuredRssi - predictedRssi); // 4. 更新估计误差协方差 this.estimationErrorCov (1 - kalmanGain) * predictedErrorCov; return this.estimatedRssi; } // 获取当前估计值 public double getEstimatedRssi() { return this.estimatedRssi; } }如何使用在服务端我们需要为每一个(terminalId, beaconId)对维护一个KalmanFilterRssi实例。可以使用MapString, KalmanFilterRssi来存储键可以是terminalId : beaconId。每次收到该终端上报的该Beacon的RSSI时就取出对应的滤波器进行更新并使用更新后的值进行后续计算。实操心得参数调优是玄学processNoiseCov和measurementNoiseCov这两个参数需要根据实际环境调试。如果RSSI非常不稳定可以适当增大measurementNoiseCov让滤波器更“平滑”反应变慢但更稳定。可以通过历史数据回放观察滤波效果来调整。滤波器生命周期管理终端可能移动离开某个Beacon范围后很久不再上报。内存中的滤波器实例需要设置过期时间例如用Redis的过期特性或Guava Cache定期清理防止内存泄漏。4.3 定位引擎指纹匹配算法实现三角定位对Beacon部署的几何位置要求很高且受RSSI波动影响大。在复杂室内环境指纹匹配Fingerprinting是更主流且稳健的方法。它分为两个阶段离线采集阶段在定位区域内预先布置好Beacon然后在各个参考点RP上采集来自各个Beacon的RSSI信号形成该点的“指纹”一个向量{Beacon1_RSSI, Beacon2_RSSI, ...}并记录该点的实际坐标。在线定位阶段终端实时采集一组RSSI向量服务端将其与指纹库中的所有指纹进行相似度匹配找出最相似的几个参考点通过加权平均等方法计算出终端位置。这里实现一个最简单的K最近邻KNN算法Service public class FingerprintLocatorService { Autowired private FingerprintDatabase fingerprintDatabase; // 假设这是一个访问指纹数据库的服务 /** * 使用KNN算法进行定位 * param scanMap 终端扫描到的Beacon信号MapKey为beaconIdValue为滤波后的RSSI * param k 取最近邻的个数 * return 估算的坐标(x, y) */ public Location knnLocate(MapString, Double scanMap, int k) { ListFingerprint allFps fingerprintDatabase.getAllFingerprints(); // 用于存储当前扫描与每个指纹的相似度距离和坐标 ListNeighbor neighbors new ArrayList(); for (Fingerprint fp : allFps) { double distance calculateEuclideanDistance(scanMap, fp.getRssiMap()); neighbors.add(new Neighbor(distance, fp.getX(), fp.getY())); } // 按距离从小到大排序 neighbors.sort(Comparator.comparingDouble(Neighbor::getDistance)); // 取前k个最近邻 ListNeighbor kNearest neighbors.subList(0, Math.min(k, neighbors.size())); // 加权平均计算位置 (权重为距离的倒数) double totalWeight 0.0; double weightedX 0.0; double weightedY 0.0; for (Neighbor nb : kNearest) { // 避免除零距离加一个极小值或者使用距离平方的倒数作为权重更常见 double weight 1.0 / (nb.getDistance() 1e-6); totalWeight weight; weightedX weight * nb.getX(); weightedY weight * nb.getY(); } if (totalWeight 0) { return null; // 无法定位 } return new Location(weightedX / totalWeight, weightedY / totalWeight); } /** * 计算欧几里得距离差异 * 只计算双方都有的Beacon */ private double calculateEuclideanDistance(MapString, Double scanMap, MapString, Double fpMap) { double sum 0.0; int commonBeaconCount 0; for (Map.EntryString, Double entry : scanMap.entrySet()) { String beaconId entry.getKey(); if (fpMap.containsKey(beaconId)) { double diff entry.getValue() - fpMap.get(beaconId); sum diff * diff; commonBeaconCount; } } // 如果没有共同Beacon返回一个很大的距离 if (commonBeaconCount 0) { return Double.MAX_VALUE; } // 返回平均距离避免指纹中Beacon数量不同带来的偏差 return Math.sqrt(sum / commonBeaconCount); } // 内部类存储邻居信息 Data AllArgsConstructor private static class Neighbor { private double distance; private double x; private double y; } }关键点与优化方向指纹库的构建离线采集工作量巨大且容易过时环境变化。现在更流行采用众包或SLAM同步定位与建图技术在用户使用过程中自动构建和更新指纹库。算法效率如果指纹库很大成千上万个参考点每次定位都全量计算欧氏距离会成为性能瓶颈。需要考虑使用空间索引如KD-Tree或降维技术来加速最近邻搜索。特征选择不一定使用所有Beacon的信号。可以只选择信号最强、最稳定的几个Beacon参与计算这有时能提升精度和速度。更先进的算法KNN是最基础的。实践中可能会采用加权KNNWKNN、朴素贝叶斯或深度学习方法以获得更好的精度和鲁棒性。4.4 数据存储时序数据写入InfluxDB计算出的位置点需要高效存储。这里展示如何使用InfluxDB的Java客户端进行写入。Configuration public class InfluxDbConfig { Value(${influxdb.url}) private String influxDbUrl; Value(${influxdb.token}) private String token; Value(${influxdb.org}) private String org; Value(${influxdb.bucket}) private String bucket; Bean public InfluxDBClient influxDBClient() { return InfluxDBClientFactory.create(influxDbUrl, token.toCharArray(), org, bucket); } } Service public class LocationDataService { Autowired private InfluxDBClient influxDBClient; // 使用WriteApi的异步写入性能更好 private WriteApi writeApi; PostConstruct public void init() { this.writeApi influxDBClient.getWriteApi(); } public void writeLocationPoint(String terminalId, double x, double y, long timestamp) { // 构建一个Point相当于关系型数据库中的一行数据 Point point Point.measurement(location) // 表名 .addTag(terminal_id, terminalId) // 标签用于快速过滤和分组会被索引 .addField(x, x) // 字段存储实际数值不会被索引 .addField(y, y) .time(timestamp, WritePrecision.MS) // 时间戳时序数据库的核心 .build(); writeApi.writePoint(point); // 异步写入无需等待。InfluxDB客户端会批量处理。 } PreDestroy public void close() { if (writeApi ! null) { writeApi.close(); } if (influxDBClient ! null) { influxDBClient.close(); } } }InfluxDB数据模型要点Measurement相当于关系型数据库的表名例如location。Tags标签是索引字段用于高效查询。例如terminal_id、area。标签值最好是枚举类型不要用变化无穷的值。Fields字段存储实际的数据值如x,y。支持多种数据类型。Time时间戳每个Point必须有一个。这是时序数据库组织数据的首要维度。这种存储方式对于查询“终端A在昨天下午2点到3点的轨迹”这样的需求效率极高。5. 部署、调优与真实场景下的坑把代码写完只是第一步让系统在生产环境稳定跑起来才是真正的挑战。5.1 性能调优从单机到分布式Netty参数调优调整SO_BACKLOG连接队列大小WRITE_BUFFER_WATER_MARK高低水位线防止写爆内存使用Epoll事件模型Linux下提升性能。Redis优化使用连接池如Lettuce。对于终端状态这种高频更新的数据考虑使用Pipeline批量操作减少网络往返。如果单个Redis实例成为瓶颈需要对数据进行分片Sharding例如按terminalId的哈希值分到不同的Redis实例。JVM调优根据负载调整堆内存大小-Xms,-Xmx选择合适的GC算法如G1。对于大量创建短期对象的场景如解码后的数据包对象要关注年轻代的分配和回收效率。5.2 定位精度提升算法之外的功夫代码里的算法再精妙也抵不过物理世界的复杂性。提升精度往往需要“软硬结合”Beacon部署策略这不是均匀分布就好的。在走廊、门口、拐角等关键路径点需要加密部署。要避免Beacon之间距离太近导致信号互相干扰也要避免距离太远出现信号盲区。经验上间隔5-10米是一个常见的参考值但必须现场测试。环境校准路径损耗指数n不是理论值必须现场校准。选择一个已知距离如1米、5米测量多个RSSI值取平均反推出当前环境的n值。不同材质区域如空旷区、货架区的n值可能不同。多维度数据融合单纯靠蓝牙定位在静止或慢速移动时由于RSSI波动位置可能“跳舞”。可以融合手机自带的传感器数据如加速度计、陀螺仪、气压计。通过惯性导航PDR推算短距离位移再用蓝牙定位进行周期性校正能极大提升轨迹的平滑度和连续性。这部分逻辑也可以在服务端实现终端只需上报传感器原始数据。5.3 遇到的那些“坑”与解决方案“幽灵”定位点终端明明在A区却偶尔在遥远的B区出现一个定位点。原因某个B区的Beacon信号穿透力强被A区的终端收到且信号强度不弱。在指纹匹配时如果B区指纹中该Beacon信号很强而A区指纹中缺失该Beacon就可能错误匹配到B区。解决在指纹匹配算法中引入信号缺失惩罚机制。对于在线扫描中收到但指纹中没有的Beacon以及指纹中有但在线扫描中没收到的Beacon都视为不匹配证据增加距离值。或者采用对信号缺失更鲁棒的算法如朴素贝叶斯。终端时间不同步终端上报的数据包带有时间戳但如果终端时间不准会导致服务端轨迹混乱。解决不以终端时间为准。服务端在Netty接入层在收到数据包的那一刻立即打上服务器的时间戳System.currentTimeMillis()。所有后续处理都以这个时间为准。终端时间仅作为参考或用于终端本地去重。Beacon电量衰减Beacon的电池电量下降会导致其广播功率降低Measured Power值实际上变了但我们在服务端存储的仍是出厂值。解决建立Beacon设备管理系统定期如每月进行现场巡检和信号重校准更新服务端数据库中的MeasuredPower值。更智能的做法是通过分析大量终端上报的该Beacon的RSSI值的变化趋势自动预警电量不足。冷启动问题终端刚进入定位区域或者滤波器实例刚创建时没有历史数据最初的几次定位会非常不准。解决对于KNN可以设置一个置信度阈值。如果最近邻的距离大于某个阈值则认为本次定位不可信不输出结果或输出一个低精度的大范围区域。同时在客户端UI上给予“定位中”的提示等待数据积累。这个基于蓝牙4.0 iBeacon的室内定位服务端项目从技术上看是网络编程、实时计算、算法工程和数据存储的有机结合。它没有用到多么高深莫测的黑科技但每一个环节都需要对业务场景的深刻理解和细致的工程化处理。从最初的简单三角定位demo到后来支持指纹匹配、多算法切换、轨迹分析的完整系统整个过程就是一个不断踩坑、填坑、优化的典型迭代。希望这篇详细的拆解能为你打开室内定位服务端开发的大门。剩下的就是在你自己的项目场景中去实践、调整和优化了。记住没有放之四海而皆准的参数和算法最好的系统永远是那个最适合你现场环境的系统。本文还有配套的精品资源点击获取
返回列表