news 2026/9/12 22:22:45

Java实现民宿预订平台:库存扣减与并发控制实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java实现民宿预订平台:库存扣减与并发控制实战

简介:这是基于SpringBoot与Vue的民宿在线预定平台Java源码包,适合有JavaWeb基础、需要开发或毕业设计参考的开发者。资源实现完整的民宿预订闭环,涵盖民宿信息展示、在线选房、订单提交、后台管理等模块,采用SpringBoot、MyBatisPlus、MySQL、Vue、ElementUI及Ajax技术栈,具备清晰的前后端分离结构。压缩包共780个文件,包括122个Java后端源文件、46个Vue前端组件、153个JS脚本、44个CSS样式及大量SVG图标素材,另含数据库相关配置与说明文档,包体总大小18.63MB,目录按功能划分便于查找。已有98人学习下载,既能帮助理解SpringBoot与Vue的整合方式,也能直接参考用户登录、订单状态流转等典型业务场景,适合快速改造或扩展为其他预定类系统。

1. 民宿在线预定平台的核心不是页面,而是房间状态机

一个 Java 民宿在线预定平台,很容易被做成“小型电商 + 日历”:房东传照片、用户收藏、评价晒单,界面花哨但订单逻辑薄弱。真正投入运营后,最先暴露问题的一定是预订环节:同一间房在同一天被两个用户同时下单,取消订单后房量没有回补,价格日历把退房当天也算进房费。整件事的核心,是在 Java 代码里表达清楚“某房间某晚的占用状态”。这个状态由空闲、锁定、占用、释放四种迁移构成,落到实现上就是价格日历表、库存扣减 SQL、事务边界和并发控制。下面按这个顺序,把民宿平台的表结构、价格日历、库存扣减和下单接口逐个落地,给正在用 Java 项目练手的工程师,以及想拿民宿项目应对 java 面试题追问的同学一条完整可复现的路径。

2. 用 Java 落地民宿预订平台的数据层:表结构、实体与批量查询

2.1 技术选型:Spring Boot + MyBatis Plus,不选 SSM 的理由

民宿平台的访问量集中在节假日和周末,平峰期并不高,技术选型不必为“高并发”提前引入复杂组件。我常用的基础组合是 Spring Boot 3 + JDK 17 + MySQL 8 + Redis,工程结构按 controller/service/mapper 三层展开。Spring Boot 通过自动装配解决数据源、事务管理器、JSON 序列化等大量胶水配置,这恰好符合民宿项目“业务简单、快速交付”的特点。持久层选 MyBatis Plus,理由有两个:列表页筛选条件经常变化,LambdaQueryWrapper 能在 Java 代码里动态构造查询;后台管理需要的多表关联查询可以显式写 SQL,SQL 对最终执行计划完全可控。相比之下,SSM 的手动配置在陌生同事接手时会增加沟通成本,而 Spring Data JPA 在复杂关联查询时 SQL 生成不够透明,排查慢查询要先翻日志。

JDK 版本建议锁定 17 这类 LTS。环境变量配置这里有一个高频排查点:JAVA_HOME 要指向 JDK 安装目录,PATH 里追加 %JAVA_HOME%\bin。很多 IDE 自带 JDK,运行时和编译时版本不一致会出现 java.lang.UnsupportedClassVersionError,优先检查三个地方:Maven 的 compiler 插件版本、IDE 的 Project SDK、环境变量 JAVA_HOME。这个知识点常见于 java 面试基础题,属于不背但必须能说清来龙去脉的范畴。

2.2 四张核心表:房源、房间、价格日历、订单

民宿业务和酒店的最大区别是房型粒度。酒店标准间可以批量卖,民宿更强调单间的独特属性。表结构设计上我把它拆成四层:house 保存房源归属地,room 描述单间容量和默认价格,price_calendar 记录每晚价格与实际剩余可售量,booking 保存订单。下面这组 SQL 可以直接作为初始化脚本,也是整套民宿在线预定平台代码里最先需要定下来的部分:

CREATE TABLE house ( id BIGINT PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL COMMENT '房东ID', city_code VARCHAR(16) NOT NULL COMMENT '城市编码', address_detail VARCHAR(255), status TINYINT DEFAULT 1 COMMENT '1-上架 0-下架', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, house_id BIGINT NOT NULL, room_name VARCHAR(64) NOT NULL, max_guests TINYINT NOT NULL DEFAULT 2, base_price DECIMAL(10,2) NOT NULL COMMENT '默认价', inventory INT NOT NULL DEFAULT 1 COMMENT '同配置可售数量' ); CREATE TABLE price_calendar ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, date DATE NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT '当日售价', stock_remaining INT NOT NULL DEFAULT 1 COMMENT '剩余可订量', UNIQUE KEY uk_room_date (room_id, date) ); CREATE TABLE booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, room_id BIGINT NOT NULL, user_id BIGINT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL COMMENT '0待支付 1已确认 2已取消 3已完成', created_at DATETIME NOT NULL );

price_calendar 是整个模型的核心。它把“某房间某晚是否可订”收敛到一行记录,查询时只看 stock_remaining 是否大于 0,扣减时用条件更新完成原子操作。如果只在 booking 表里判断时间区间是否重叠,每次下单都扫描订单表,代价会随订单量快速上升,并发场景还需要额外加锁。用日历表替代区间判断,是民宿预订并发方案最常见的做法,核心思路是把区间冲突转成单点扣减。

Java 实体与表字段的映射要注意类型选择。金额用 BigDecimal 而不用 Double,日期用 LocalDate 而不用 java.util.Date。下面是常用映射对照表,写 mapper 时可以直接照这个标准:

Java 字段类型MySQL 列类型使用场景
BigDecimalDECIMAL(10,2)价格、金额,运算保留 4 位后归一化
LocalDateDATEcheck_in、check_out、价格日期
LocalDateTimeDATETIME创建时间、更新时间
IntegerTINYINT订单状态等小范围状态码
LongBIGINT主键与关联外键,避免 int 溢出

2.3 列表查询的 Java 集合组装:一次 IN 查询替代 N 次数据库调用

列表页的核心查询是:城市、入住日期、离店日期、人数,返回可订房源及其每晚价格。最差的实现是外层循环房间,对每个房间单独查价格日历,房源多时数据库压力很大。我用一次 IN 查询把所有候选房间的价格日历取回来,在 Java 内存里用集合做聚合:

public List<HouseSearchVO> searchAvailable(String cityCode, LocalDate checkIn, LocalDate checkOut, int guests) { // 1. 先按城市和人数筛房间 List<Room> rooms = roomMapper.selectList(new LambdaQueryWrapper<Room>() .eq(Room::getCityCode, cityCode) .ge(Room::getMaxGuests, guests)); if (rooms.isEmpty()) return Collections.emptyList(); // 2. 批量拉价格日历,入住当天至退房前一天 List<Long> roomIds = rooms.stream().map(Room::getId).collect(Collectors.toList()); List<PriceCalendar> calendars = priceCalendarMapper.selectList( new LambdaQueryWrapper<PriceCalendar>() .in(PriceCalendar::getRoomId, roomIds) .ge(PriceCalendar::getDate, checkIn) .lt(PriceCalendar::getDate, checkOut) .gt(PriceCalendar::getStockRemaining, 0)); // 3. 按房间分组,在内存里判断是否覆盖完整入住区间 Map<Long, List<PriceCalendar>> roomCalendarMap = calendars.stream() .collect(Collectors.groupingBy(PriceCalendar::getRoomId)); return rooms.stream() .filter(room -> { List<PriceCalendar> list = roomCalendarMap.get(room.getId()); long nights = ChronoUnit.DAYS.between(checkIn, checkOut); return list != null && list.size() == nights; }) .map(room -> buildVO(room, roomCalendarMap.get(room.getId()), checkIn, checkOut)) .collect(Collectors.toList()); }

这里的两个关键参数:.lt(PriceCalendar::getDate, checkOut)是“退房当天不计费”,.gt(PriceCalendar::getStockRemaining, 0)是“只保留有房日期”。分组后的 Map 用于判断“该房间每日都有余房”且“天数与入住晚数一致”。库里已经过滤掉无房日期,返回的列表长度等于晚数时,说明整段区间都可订。条件过滤在 SQL 层完成,内存中只做完整性校验,不要在分页之后才判断剩余房量,否则总条数与可用房源数会对不上。

用 Map<Long, List > 做分组聚合是 java 集合在真实业务里很典型的使用方式。数据量在 1 万到 10 万这个级别,一次 IN 查询替代 N+1 次循环查询,响应时间通常从秒级降到几十毫秒。parallelStream 在这里不一定快,因为数据库连接池连接有限,并行查询会因等待连接而退化为排队。

3. 库存扣减、价格叠加与 Redis 计数:预订核心业务的三个关键点

3.1 库存扣减必须用条件 UPDATE,不能“先查再改”

超卖问题的根源,多半是“先 SELECT 剩余量,Java 判断大于 0,再 UPDATE”。两个请求同时读到剩余 1,都判断通过,最后两个订单都成功。这个问题在 java 基础面试题里叫“丢失更新”,正确做法是把判断和修改合并进同一条带条件的 SQL:

UPDATE price_calendar SET stock_remaining = stock_remaining - 1 WHERE room_id = ? AND date = ? AND stock_remaining > 0;

MyBatis 的 update 返回值是影响行数,返回 1 说明扣减成功,0 说明库存已被抢完,这个返回值是业务逻辑的输入,不能忽略。在 InnoDB 的 REPEATABLE READ 隔离级别下,多个事务更新同一行时靠行锁排队,WHERE 条件会在锁释放后重新评估,所以连续两个请求只会有一个拿到 1。

多晚预订必须把多个晚上的扣减放进同一个事务,任何一晚失败则整体回滚。执行顺序有门道:先扣库存,后插订单。如果先插订单再扣库存,扣减失败时订单行还在,要靠额外动作才能清理,状态机会多出一个无意义的中间状态。处理顺序在 java 面试题的“如何保证库存不超卖”中常被追问,要把它当成状态机的一部分来回答,而不是单纯背一条 SQL。

3.2 平日价、周末价、节假日价:三级规则如何叠加

民宿价格很少只有单一数值。房东希望在周末上浮、节假日倍增、淡季打折,运营又可能临时修改某一天的价格。把这些规则放在一个组件里,按优先级顺序判断,是最容易维护的方式:

public BigDecimal dailyPrice(Long roomId, LocalDate date) { // 第一优先级:价格日历中的运营覆盖价 PriceCalendar cal = priceCalendarMapper.selectOne( new LambdaQueryWrapper<PriceCalendar>() .eq(PriceCalendar::getRoomId, roomId) .eq(PriceCalendar::getDate, date)); if (cal != null && cal.getPrice() != null) { return cal.getPrice(); } // 第二优先级:节假日倍率 Holiday holiday = holidayMapper.selectByDate(date); Room room = roomMapper.selectById(roomId); if (holiday != null) { return multiPrice(room.getBasePrice(), holiday.getMultiplier()); } // 第三优先级:周末上浮 10% DayOfWeek dow = date.getDayOfWeek(); if (dow == DayOfWeek.FRIDAY || dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) { return multiPrice(room.getBasePrice(), new BigDecimal("1.1")); } return room.getBasePrice(); } private BigDecimal multiPrice(BigDecimal base, BigDecimal ratio) { return base.multiply(ratio).setScale(2, RoundingMode.HALF_UP); }

优先级顺序直接决定业务正确性。把节假日判断放在价格日历之前,运营在后台改了某天的特价,最终结算却按节假日倍率算,这就是线上矛盾。BigDecimal 的入参写new BigDecimal("1.1")而不是1.1,后者会先把 double 值转为 BigDecimal,带来二进制浮点误差。多晚总价建议先累加每晚价格,再乘以连住折扣率,最后统一保留两位小数;如果每晚单独打折再求和,总额会出现多余的小数位,入库时被 DECIMAL(10,2) 截断,和前端显示对不上。

价格日历通常会有一个定时任务,为未来 90 天批量生成默认价格,生成时用 base_price 配合周末规则,通过INSERT ... ON DUPLICATE KEY UPDATE保证重复执行不会产生脏数据。规则层级之间的覆盖能力可以按下面的表来理解:

规则层级触发条件覆盖能力
价格日历显式价格日期在日历中有配置覆盖其余全部规则
节假日倍率命中节假日表覆盖默认价与周末上浮
周末上浮周五、周六、周日无节假日时生效
默认价其余所有日期兜底

3.3 Redis 扣库存:“not integer or out of range”报错的真正原因

热点日期把剩余量预热到 Redis 后,用 increment 扣减是最直接的方案。常见报错 ERR value is not an integer or out of range,问题通常不在 Redis 本身,而在序列化器。写入键时使用 StringRedisTemplate,扣减时换成另一个配置了 JSON 序列化器的 RedisTemplate,Redis 拿到的 value 是带引号的字符串,increment 自然返回 not integer or out of range。

处理办法是让计数字段的写入和扣减共用同一个序列化器。我通常在项目中单独分配一个 bean:

@Bean("countRedisTemplate") public RedisTemplate<String, String> countRedisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); return template; }

所有用 Redis 做计数、限流的场景统一使用 countRedisTemplate,JSON 序列化留给对象缓存,比在每次调用前手动指定 ValueSerializer 更不容易出错。还要给 Redis 扣库存加降级:Redis 不可用时退回 MySQL 条件更新。降级后要注意补偿,Redis 扣成功但 MySQL 事务回滚时,要把 Redis 计数补回 1,否则两边数据不一致。

注意:操作类 Redis 的 bean 不要混用。计数用字符串序列化器,业务对象缓存用 JSON 序列化器,两个模板按各自场景固定使用,排查问题会容易很多。

4. REST 接口划分与下单流程的并发控制

4.1 民宿在线预定平台的 8 个核心 REST 接口

接口按查询、操作、管理三类划分,方便确定缓存策略和权限粒度:

查询类: GET /api/houses?cityCode=330100&checkIn=2025-05-01&checkOut=2025-05-04&guests=2 GET /api/houses/{houseId} GET /api/rooms/{roomId}/calendar?month=2025-05 操作类: POST /api/bookings Body: roomId, checkIn, checkOut, guestName, guestPhone POST /api/bookings/{orderNo}/pay POST /api/bookings/{orderNo}/cancel 管理类: GET /api/bookings/{orderNo} GET /api/houses/{houseId}/bookings

分页参数 page 从 1 开始,size 默认 10、最大 100,超过上限直接返回参数错误。民宿的剩余房量属于实时数据,不能用 HTTP 缓存,响应头要加 Cache-Control: no-store。orderNo 可以用“yyyyMMddHHmmss + 4 位随机数”拼 32 位字符串,不要用数据库自增 ID 对外暴露,否则订单量容易被遍历统计。请求体校验也放在这一层,checkIn 和 checkOut 用 LocalDate 接收,配合 @JsonFormat(pattern = "yyyy-MM-dd"),格式错误时返回 400 而不是 500,接口语义更明确。

4.2 乐观锁、Redis 分布式锁与数据库行锁的分工

既然 price_calendar 有带条件的 UPDATE,为什么还需要额外的锁?条件更新只保护单行数据,一个订单跨多个晚上、多个房间时,扣减的顺序和组合校验需要更全局的控制。Redis 分布式锁可以把同一个房间的重复下单请求在业务入口拦截掉,减少数据库层的等待。加锁与释放之间,业务执行时间必须小于锁过期时间。

String lockKey = "booking:lock:" + req.getRoomId() + ":" + req.getCheckIn(); String requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(locked)) { throw new BizException("该房间正被预订,请稍后重试"); } try { // 校验与扣减 } finally { String current = (String) redisTemplate.opsForValue().get(lockKey); if (requestId.equals(current)) { redisTemplate.delete(lockKey); } }

requestId 是锁的所有者标识,防止业务超时后请求 A 的 finally 把请求 B 刚创建的锁删除。更严谨的做法是把“查询并删除”用 Lua 脚本原子化,但大多数单体项目里前面的 if 判断已经足够。几种并发方案各自有适用场景:

方案适用场景注意点
SQL 条件更新单行库存扣减不影响多行组合判断
SELECT ... FOR UPDATE单体应用、事务内锁持有时长必须短
Redis 分布式锁多实例部署关注过期时间和误删
@Version 乐观锁实体更新重试成本低冲突率高时形成大量重试

单体应用阶段,SELECT ... FOR UPDATE 往往比 Redis 锁更省事。唯一注意的是持锁时间,事务里只能做本地计算和数据库操作,任何等待外部接口的时间都不该落在持锁区间,否则数据库连接数很快被打满。

4.3 下单 Service 与事务边界:先扣库存再建订单

下单 Service 的代码量不大,但容易在细节上出错。一个常见的骨架像这样:

@Transactional(rollbackFor = Exception.class) public Booking createBooking(BookingRequest req) { String lockKey = "booking:lock:" + req.getRoomId() + ":" + req.getCheckIn(); boolean locked = tryLock(lockKey, 30); if (!locked) { throw new BizException("系统繁忙,请稍后重试"); } try { for (LocalDate d = req.getCheckIn(); d.isBefore(req.getCheckOut()); d = d.plusDays(1)) { int updated = priceCalendarMapper.deductStock(req.getRoomId(), d); if (updated == 0) { throw new BizException("所选日期房间已满"); } } Booking booking = buildBooking(req); bookingMapper.insert(booking); return booking; } finally { // 锁的释放放到事务提交后的回调里执行 registerAfterCommit(() -> unlock(lockKey)); } }

先扣库存再插订单的顺序最关键。扣库存失败时事务整体回滚,不会产生半截数据;插订单失败时同样回滚,库存恢复,不需要额外补偿。如果反过来,订单已插入而库存扣减失败,就多了一个需要人工兜底的状态。@Transactional 的 rollbackFor 必须指定,默认事务只对 RuntimeException 回滚,如果代码里用 try-catch 吞掉 BizException 并返回错误响应,事务不会回滚,库存被永久扣掉,这是排查订单短账时最隐蔽的原因之一。业务异常统一继承 RuntimeException,事务回滚行为就变得可预期。

5. 用并发压测来验证超卖修复,并确认边界用例

5.1 10 个线程抢 1 间房:CountDownLatch 并发验证脚本

代码写完后,需要证明在 10 个线程同时抢一间房时只有一单成功。下面这个脚本可以直接放进测试目录:

ExecutorService pool = Executors.newFixedThreadPool(10); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(10); AtomicInteger success = new AtomicInteger(); for (int i = 0; i < 10; i++) { pool.submit(() -> { start.await(); try { bookingService.createBooking(buildRequest()); success.incrementAndGet(); } catch (BizException e) { // 预期的业务拒绝 } finally { done.countDown(); } }); } start.countDown(); done.await(10, TimeUnit.SECONDS); System.out.println("成功订单数量: " + success.get()); pool.shutdown();

测试前把 price_calendar 中目标日期的 stock_remaining 重置为 1。如果 success 为 1 且无异常,说明库存扣减逻辑正确;如果大于 1,重点排查 UPDATE 的 WHERE 条件是否带回stock_remaining > 0,以及事务隔离级别是否被调到了较低级别。测试后把测试用到的 Redis lock key 清理掉,自己实现的 setIfAbsent 方案尤其要注意,否则下一个用例会被遗留锁卡住。每次压测从干净的 Redis 和 MySQL 状态开始,连续跑三轮结果都是 1,这套并发方案才算真正收敛。

5.2 四个必须覆盖的边界用例与回补逻辑

日期和金额是民宿平台最容易出故障的地方,建议固定四组用例:跨月订单、节假日价格覆盖、退房当天不计费、取消后库存回补。跨月订单用来验证循环扣库存时 byDays 推进的日期正确性;节假日价格覆盖用来验证价格优先级;退房当天不计费用来验证退房日是否被排除在价格日历之外。最后一个回补逻辑,取消订单补库存的 SQL 写成:

UPDATE price_calendar SET stock_remaining = stock_remaining + 1 WHERE room_id = ? AND date = ?

这里不能用SET stock_remaining = 1,否则会把其他用户已扣减的数量覆盖掉,导致本来没房的日期显示有房。这行 SQL 与扣减逻辑是一个可逆闭环,也是民宿平台订单状态机里最基础也最关键的一步。把并发脚本和这几组边界用例连同初始化 SQL 一起写进项目说明,后续任何一次改动订单逻辑,都能在十分钟内回归验证。

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

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

ESP32+MAX30102血氧监测系统实战:从物理层调优到医疗级精度

1. 这不是“玩具级”项目&#xff0c;而是一套可落地的健康数据采集原型系统你搜“ESP32 血氧”&#xff0c;满屏都是“5分钟搞定”“小白秒上手”的标题——但真正把MAX30102接上ESP32、跑通PPG信号采集、算出SpO₂、再叠加温度补偿、最后稳定输出有效数值的人&#xff0c;不到…

作者头像 李华
网站建设 2026/9/12 22:19:42

Unity飞机大战高分攻略:对象池、状态机与特效实战

简介&#xff1a;这是一份面向Unity 3D课程设计与期末大作业的飞机大战游戏完整项目&#xff0c;开发语言为C#&#xff0c;适合需要完成高分课设或希望从零上手Unity游戏开发的学生参考。项目基于Unity引擎组织工程资源&#xff0c;共约2000个文件&#xff0c;包含场景、预制体…

作者头像 李华
网站建设 2026/9/12 22:19:21

开源多模态模型的4K能力边界与真实可用性解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 22:18:10

邯郸市30米DEM数据处理全流程:从解压到地形分析

简介&#xff1a;河北省邯郸市DEM数字高程数据由三十米分辨率的栅格高程模型和行政边界矢量文件构成&#xff0c;覆盖邯郸市及周边区域&#xff0c;适合GIS从业者、城乡规划人员、测绘人员及高校相关专业学生使用&#xff1b;每个像元代表三十米乘三十米区域的平均海拔高度&…

作者头像 李华
网站建设 2026/9/12 22:18:09

有痕注入设计指南:从数据变更追踪到可审计系统落地实践

“有痕注入”这个词&#xff0c;猛一看容易让人联想到数据库攻击里的SQL注入&#xff0c;或者依赖注入这类框架概念。其实在数据工程和业务系统设计里&#xff0c;还有一个容易被忽略但特别重要的解读&#xff1a;每一次外部数据写入、参数修改、程序装配&#xff0c;都要留下完…

作者头像 李华
网站建设 2026/9/12 22:17:43

智慧农业大屏可视化:数据治理到ECharts高性能实现

简介&#xff1a;面向计算机、电子信息工程及数学等专业大学生毕业设计、课程设计或期末大作业的智慧农业大数据可视化大屏页面&#xff0c;聚焦农业生产环境与产量数据监测展示场景。资源基于HTMLCSSJavaScript技术栈&#xff0c;利用ECharts图表组件呈现可视化效果&#xff0…

作者头像 李华