Commit 9720f208654f13f4e6278503874905ba003030ff
1 parent
ee01a16f
✨ feat(im):增加好友删除时,增加是否删除本地聊天的选项
Showing
9 changed files
with
79 additions
and
22 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java
| @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi; | @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi; | ||
| 13 | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; | 13 | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| 14 | import io.swagger.v3.oas.annotations.Operation; | 14 | import io.swagger.v3.oas.annotations.Operation; |
| 15 | import io.swagger.v3.oas.annotations.Parameter; | 15 | import io.swagger.v3.oas.annotations.Parameter; |
| 16 | +import io.swagger.v3.oas.annotations.Parameters; | ||
| 16 | import io.swagger.v3.oas.annotations.tags.Tag; | 17 | import io.swagger.v3.oas.annotations.tags.Tag; |
| 17 | import jakarta.annotation.Resource; | 18 | import jakarta.annotation.Resource; |
| 18 | import jakarta.validation.Valid; | 19 | import jakarta.validation.Valid; |
| @@ -45,6 +46,7 @@ public class ImFriendController { | @@ -45,6 +46,7 @@ public class ImFriendController { | ||
| 45 | @GetMapping("/list") | 46 | @GetMapping("/list") |
| 46 | @Operation(summary = "获得当前登录用户的好友列表") | 47 | @Operation(summary = "获得当前登录用户的好友列表") |
| 47 | public CommonResult<List<ImFriendRespVO>> getMyFriendList() { | 48 | public CommonResult<List<ImFriendRespVO>> getMyFriendList() { |
| 49 | + // 含 DISABLE 历史好友:保留给前端展示「已删除好友」的历史对话信息;前端按 status 决定会话级联清理 | ||
| 48 | List<ImFriendDO> friends = friendService.getFriendList(getLoginUserId()); | 50 | List<ImFriendDO> friends = friendService.getFriendList(getLoginUserId()); |
| 49 | return success(buildFriendRespVOList(friends)); | 51 | return success(buildFriendRespVOList(friends)); |
| 50 | } | 52 | } |
| @@ -59,10 +61,14 @@ public class ImFriendController { | @@ -59,10 +61,14 @@ public class ImFriendController { | ||
| 59 | 61 | ||
| 60 | @DeleteMapping("/delete") | 62 | @DeleteMapping("/delete") |
| 61 | @Operation(summary = "删除好友(单向软删除)") | 63 | @Operation(summary = "删除好友(单向软删除)") |
| 62 | - @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") | 64 | + @Parameters({ |
| 65 | + @Parameter(description = "好友的用户编号", required = true, example = "2048"), | ||
| 66 | + @Parameter(description = "是否级联清理本端相关数据(如私聊会话)") | ||
| 67 | + }) | ||
| 63 | public CommonResult<Boolean> deleteFriend( | 68 | public CommonResult<Boolean> deleteFriend( |
| 64 | - @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { | ||
| 65 | - friendService.deleteFriend(getLoginUserId(), friendUserId); | 69 | + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId, |
| 70 | + @RequestParam(value = "clear", required = false) Boolean clear) { | ||
| 71 | + friendService.deleteFriend(getLoginUserId(), friendUserId, clear); | ||
| 66 | return success(true); | 72 | return success(true); |
| 67 | } | 73 | } |
| 68 | 74 |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java
| @@ -60,5 +60,9 @@ public interface ImCommonConstants { | @@ -60,5 +60,9 @@ public interface ImCommonConstants { | ||
| 60 | * 好友添加系统提示 | 60 | * 好友添加系统提示 |
| 61 | */ | 61 | */ |
| 62 | String FRIEND_ADD_TIP_MESSAGE = "你们已成为好友,现在可以开始聊天了"; | 62 | String FRIEND_ADD_TIP_MESSAGE = "你们已成为好友,现在可以开始聊天了"; |
| 63 | + /** | ||
| 64 | + * 好友删除系统提示(仅自己可见,单边推送) | ||
| 65 | + */ | ||
| 66 | + String FRIEND_DELETE_TIP_MESSAGE = "你已删除好友"; | ||
| 63 | 67 | ||
| 64 | } | 68 | } |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java
| @@ -86,12 +86,19 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { | @@ -86,12 +86,19 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { | ||
| 86 | return null; | 86 | return null; |
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | - // 2. 落库:复用最新一条未处理记录;否则新建 | 89 | + // 2. 落库:复用最新一条未处理记录走条件 update;否则新建 |
| 90 | ImFriendRequestDO request = friendRequestMapper.selectLatestByFromUserIdAndToUserId(fromUserId, toUserId); | 90 | ImFriendRequestDO request = friendRequestMapper.selectLatestByFromUserIdAndToUserId(fromUserId, toUserId); |
| 91 | if (request != null && ImFriendRequestHandleResultEnum.isUnhandled(request.getHandleResult())) { | 91 | if (request != null && ImFriendRequestHandleResultEnum.isUnhandled(request.getHandleResult())) { |
| 92 | - // 复用未处理记录:刷新申请理由 / 备注 / 来源;createTime 维持首次申请时间不变(与列表 id DESC 排序一致) | 92 | + ImFriendRequestDO updateObj = new ImFriendRequestDO().setApplyContent(reqVO.getApplyContent()) |
| 93 | + .setDisplayName(reqVO.getDisplayName()).setAddSource(reqVO.getAddSource()); | ||
| 94 | + int affected = friendRequestMapper.updateByIdAndHandleResult(request.getId(), | ||
| 95 | + ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), updateObj); | ||
| 96 | + if (affected == 0) { | ||
| 97 | + // 并发场景下另一方刚 agree/refuse 推进了状态,条件 update affected=0 抛已处理错让前端重试 | ||
| 98 | + throw exception(FRIEND_REQUEST_HANDLED); | ||
| 99 | + } | ||
| 100 | + // 同步更新字段,下面推送 payload 用 | ||
| 93 | BeanUtil.copyProperties(reqVO, request, CopyOptions.create().setIgnoreNullValue(true)); | 101 | BeanUtil.copyProperties(reqVO, request, CopyOptions.create().setIgnoreNullValue(true)); |
| 94 | - friendRequestMapper.updateById(request); | ||
| 95 | } else { | 102 | } else { |
| 96 | request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class) | 103 | request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class) |
| 97 | .setFromUserId(fromUserId).setToUserId(toUserId) | 104 | .setFromUserId(fromUserId).setToUserId(toUserId) |
| @@ -103,9 +110,10 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { | @@ -103,9 +110,10 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { | ||
| 103 | AdminUserRespDTO fromUser = adminUserApi.getUser(fromUserId); | 110 | AdminUserRespDTO fromUser = adminUserApi.getUser(fromUserId); |
| 104 | FriendRequestNotification payload = (FriendRequestNotification) new FriendRequestNotification() | 111 | FriendRequestNotification payload = (FriendRequestNotification) new FriendRequestNotification() |
| 105 | .setRequestId(request.getId()).setApplyContent(request.getApplyContent()).setAddSource(request.getAddSource()) | 112 | .setRequestId(request.getId()).setApplyContent(request.getApplyContent()).setAddSource(request.getAddSource()) |
| 106 | - .setFromNickname(fromUser != null ? fromUser.getNickname() : null) | ||
| 107 | - .setFromAvatar(fromUser != null ? fromUser.getAvatar() : null) | ||
| 108 | .setOperatorUserId(fromUserId).setFriendUserId(fromUserId); | 113 | .setOperatorUserId(fromUserId).setFriendUserId(fromUserId); |
| 114 | + if (fromUser != null) { | ||
| 115 | + payload.setFromNickname(fromUser.getNickname()).setFromAvatar(fromUser.getAvatar()); | ||
| 116 | + } | ||
| 109 | websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification( | 117 | websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification( |
| 110 | ImMessageTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), fromUserId, toUserId, payload)); | 118 | ImMessageTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), fromUserId, toUserId, payload)); |
| 111 | 119 |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java
| @@ -79,8 +79,10 @@ public interface ImFriendService { | @@ -79,8 +79,10 @@ public interface ImFriendService { | ||
| 79 | * 删除好友(单向软删除) | 79 | * 删除好友(单向软删除) |
| 80 | * <p> | 80 | * <p> |
| 81 | * 仅删除 userId 视角下的好友关系;对端 friendUserId 的视角不受影响(单边删除语义) | 81 | * 仅删除 userId 视角下的好友关系;对端 friendUserId 的视角不受影响(单边删除语义) |
| 82 | + * | ||
| 83 | + * @param clear 是否级联清理本端相关数据(当前包含私聊会话;通过 FRIEND_DELETE 通知透传给多端) | ||
| 82 | */ | 84 | */ |
| 83 | - void deleteFriend(Long userId, Long friendUserId); | 85 | + void deleteFriend(Long userId, Long friendUserId, Boolean clear); |
| 84 | 86 | ||
| 85 | /** | 87 | /** |
| 86 | * 更新好友单边属性(备注 / 免打扰 / 联系人置顶) | 88 | * 更新好友单边属性(备注 / 免打扰 / 联系人置顶) |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
| @@ -33,6 +33,7 @@ import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STAT | @@ -33,6 +33,7 @@ import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STAT | ||
| 33 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED; | 33 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED; |
| 34 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; | 34 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; |
| 35 | import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE; | 35 | import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE; |
| 36 | +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_TIP_MESSAGE; | ||
| 36 | 37 | ||
| 37 | /** | 38 | /** |
| 38 | * IM 好友关系 Service 实现类 | 39 | * IM 好友关系 Service 实现类 |
| @@ -171,14 +172,17 @@ public class ImFriendServiceImpl implements ImFriendService { | @@ -171,14 +172,17 @@ public class ImFriendServiceImpl implements ImFriendService { | ||
| 171 | @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId") | 172 | @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId") |
| 172 | }) | 173 | }) |
| 173 | @Transactional(rollbackFor = Exception.class) | 174 | @Transactional(rollbackFor = Exception.class) |
| 174 | - public void deleteFriend(Long userId, Long friendUserId) { | 175 | + public void deleteFriend(Long userId, Long friendUserId, Boolean clear) { |
| 175 | // 1. 单边软删:仅 userId 视角的关系置 DISABLE;friendUserId 视角不动 | 176 | // 1. 单边软删:仅 userId 视角的关系置 DISABLE;friendUserId 视角不动 |
| 176 | - // 不推 TIP 系统消息:单边删除语义下对方不应感知;userId 端反馈由前端 toast 承担,多端同步靠下面的 FRIEND_DELETE | ||
| 177 | deleteFriend0(userId, friendUserId); | 177 | deleteFriend0(userId, friendUserId); |
| 178 | 178 | ||
| 179 | - // 2. 推 FRIEND_DELETE 给 userId 多端做同步(friendUserId 不感知) | ||
| 180 | - FriendDeleteNotification payload = (FriendDeleteNotification) new FriendDeleteNotification() | ||
| 181 | - .setOperatorUserId(userId).setFriendUserId(friendUserId); | 179 | + // 2. 推 TIP「你已删除好友」走单边语义(persistent=false): |
| 180 | + // 不入库 + 仅推 userId 多端,对方完全不感知;clear=true 时前端会清对话连带这条 TIP 一起清 | ||
| 181 | + privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_DELETE_TIP_MESSAGE, false); | ||
| 182 | + | ||
| 183 | + // 3. 推 FRIEND_DELETE 给 userId 多端做同步(friendUserId 不感知);clear 透传让多端清理动作一致 | ||
| 184 | + FriendDeleteNotification payload = ((FriendDeleteNotification) new FriendDeleteNotification() | ||
| 185 | + .setOperatorUserId(userId).setFriendUserId(friendUserId)).setClear(clear); | ||
| 182 | websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification( | 186 | websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification( |
| 183 | ImMessageTypeEnum.FRIEND_DELETE.getType(), userId, userId, payload)); | 187 | ImMessageTypeEnum.FRIEND_DELETE.getType(), userId, userId, payload)); |
| 184 | } | 188 | } |
| @@ -237,7 +241,7 @@ public class ImFriendServiceImpl implements ImFriendService { | @@ -237,7 +241,7 @@ public class ImFriendServiceImpl implements ImFriendService { | ||
| 237 | 241 | ||
| 238 | /** | 242 | /** |
| 239 | * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} / {@link #silentReAddFriend} 调用): | 243 | * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} / {@link #silentReAddFriend} 调用): |
| 240 | - * - 情况一:已存在记录(含 ENABLE / DISABLE)→ 复用并恢复 ENABLE,重置 muted / pinned / blocked 为 false(删好友再加回来不沿袭旧设置) | 244 | + * - 情况一:已存在记录(含 ENABLE / DISABLE)→ 复用并恢复 ENABLE,muted / pinned / blocked 一并重置为 false |
| 241 | * - 情况二:不存在记录 → 直接插入新记录 | 245 | * - 情况二:不存在记录 → 直接插入新记录 |
| 242 | * <p> | 246 | * <p> |
| 243 | * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进; | 247 | * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进; |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
| @@ -47,6 +47,16 @@ public interface ImPrivateMessageService { | @@ -47,6 +47,16 @@ public interface ImPrivateMessageService { | ||
| 47 | void sendTipPrivateMessage(Long senderId, Long receiverId, String content); | 47 | void sendTipPrivateMessage(Long senderId, Long receiverId, String content); |
| 48 | 48 | ||
| 49 | /** | 49 | /** |
| 50 | + * 【系统调用】发送系统提示消息(TIP_TEXT),可覆盖 persistent | ||
| 51 | + * | ||
| 52 | + * @param senderId 发送人编号 | ||
| 53 | + * @param receiverId 接收人编号 | ||
| 54 | + * @param content 提示文本 | ||
| 55 | + * @param persistent null 走默认;false 单边(不入库 + 仅推 sender 多端,对方不感知);true 强制双向 + 入库 | ||
| 56 | + */ | ||
| 57 | + void sendTipPrivateMessage(Long senderId, Long receiverId, String content, Boolean persistent); | ||
| 58 | + | ||
| 59 | + /** | ||
| 50 | * 【用户调用】撤回私聊消息 | 60 | * 【用户调用】撤回私聊消息 |
| 51 | * | 61 | * |
| 52 | * @param userId 当前用户编号 | 62 | * @param userId 当前用户编号 |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
| @@ -100,27 +100,37 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | @@ -100,27 +100,37 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | ||
| 100 | String contentString = payload == null || payload instanceof String | 100 | String contentString = payload == null || payload instanceof String |
| 101 | ? (String) payload | 101 | ? (String) payload |
| 102 | : JsonUtils.toJsonString(payload); | 102 | : JsonUtils.toJsonString(payload); |
| 103 | - // 1.2 构建并保存消息 | 103 | + // 1.2 构建消息 |
| 104 | ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) | 104 | ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) |
| 105 | .setSenderId(senderId).setReceiverId(dto.getReceiverId()) | 105 | .setSenderId(senderId).setReceiverId(dto.getReceiverId()) |
| 106 | .setType(dto.getType()).setContent(contentString) | 106 | .setType(dto.getType()).setContent(contentString) |
| 107 | .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); | 107 | .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); |
| 108 | - // 1.3 按 type.persistent 决定是否入库 | ||
| 109 | - if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { | 108 | + // 1.3 决定是否持久化:dto.persistent 优先;为 null 时按 type 默认 |
| 109 | + boolean persistent = dto.getPersistent() != null | ||
| 110 | + ? dto.getPersistent() | ||
| 111 | + : ImMessageTypeEnum.validate(dto.getType()).isPersistent(); | ||
| 112 | + if (persistent) { | ||
| 110 | privateMessageMapper.insert(message); | 113 | privateMessageMapper.insert(message); |
| 111 | } | 114 | } |
| 112 | 115 | ||
| 113 | - // 2. WebSocket 异步推送(接收方 + 发送方多端同步) | 116 | + // 2. WebSocket 异步推送:双向(默认);单边语义(persistent=false)下仅推 sender 多端,对方不感知 |
| 114 | ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); | 117 | ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); |
| 115 | - imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage); | 118 | + if (persistent) { |
| 119 | + imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage); | ||
| 120 | + } | ||
| 116 | imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); | 121 | imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); |
| 117 | return message; | 122 | return message; |
| 118 | } | 123 | } |
| 119 | 124 | ||
| 120 | @Override | 125 | @Override |
| 121 | public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { | 126 | public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { |
| 127 | + sendTipPrivateMessage(senderId, receiverId, content, null); | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + @Override | ||
| 131 | + public void sendTipPrivateMessage(Long senderId, Long receiverId, String content, Boolean persistent) { | ||
| 122 | sendPrivateMessage(senderId, new ImPrivateMessageSendDTO().setReceiverId(receiverId) | 132 | sendPrivateMessage(senderId, new ImPrivateMessageSendDTO().setReceiverId(receiverId) |
| 123 | - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)); | 133 | + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content).setPersistent(persistent)); |
| 124 | } | 134 | } |
| 125 | 135 | ||
| 126 | @Override | 136 | @Override |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java
| @@ -27,5 +27,13 @@ public class ImPrivateMessageSendDTO { | @@ -27,5 +27,13 @@ public class ImPrivateMessageSendDTO { | ||
| 27 | * 支持 String / POJO;非 String 时由 service 序列化为 JSON | 27 | * 支持 String / POJO;非 String 时由 service 序列化为 JSON |
| 28 | */ | 28 | */ |
| 29 | private Object content; | 29 | private Object content; |
| 30 | + /** | ||
| 31 | + * 是否持久化 + 推送给接收方(单边语义开关) | ||
| 32 | + * <p> | ||
| 33 | + * null:默认按 {@link ImMessageTypeEnum#isPersistent()} 决定是否入库 + 双向 WS 推送(保持原行为)<br> | ||
| 34 | + * false:覆盖为单边——不入库、仅推 sender 多端,对方不感知(如「你已删除 XXX」这类仅自己可见的 TIP)<br> | ||
| 35 | + * true:覆盖为双向 + 入库 | ||
| 36 | + */ | ||
| 37 | + private Boolean persistent; | ||
| 30 | 38 | ||
| 31 | } | 39 | } |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendDeleteNotification.java
| @@ -5,9 +5,14 @@ import lombok.Data; | @@ -5,9 +5,14 @@ import lombok.Data; | ||
| 5 | /** | 5 | /** |
| 6 | * 好友删除通知 | 6 | * 好友删除通知 |
| 7 | * <p> | 7 | * <p> |
| 8 | - * 仅推送给操作人多端做同步(对端不感知,与单边删除语义对齐);前端清除本地好友 + 级联清理私聊会话 | 8 | + * 仅推送给操作人多端做同步(对端不感知,与单边删除语义对齐);前端清除本地好友 + 按 clear 决定级联清理 |
| 9 | */ | 9 | */ |
| 10 | @Data | 10 | @Data |
| 11 | public class FriendDeleteNotification extends BaseFriendNotification { | 11 | public class FriendDeleteNotification extends BaseFriendNotification { |
| 12 | 12 | ||
| 13 | + /** | ||
| 14 | + * 是否级联清理本端相关数据(当前包含私聊会话;未来可能扩展更多 clear 项) | ||
| 15 | + */ | ||
| 16 | + private Boolean clear; | ||
| 17 | + | ||
| 13 | } | 18 | } |