package com.tcm.training.admin.app.settings.service; import cn.dev33.satoken.stp.SaTokenInfo; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest; import com.tcm.training.admin.app.auth.service.AppAuthSessionService; import com.tcm.training.admin.app.auth.vo.AppLoginResponse; import com.tcm.training.admin.app.auth.vo.AppLoginUserVO; import com.tcm.training.admin.app.auth.service.AppSmsCodeService; import com.tcm.training.admin.app.auth.service.AppWechatAuthClient; import com.tcm.training.admin.app.auth.service.AppWechatUserProfile; import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest; import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest; import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest; import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneCodeRequest; import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneUpdateRequest; import com.tcm.training.admin.app.settings.dto.AppSettingsProfileUpdateRequest; import com.tcm.training.admin.app.settings.entity.AppUserNotificationSetting; import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper; import com.tcm.training.admin.app.settings.vo.AppSettingsNotificationVO; import com.tcm.training.admin.app.settings.vo.AppSettingsProfileVO; import com.tcm.training.admin.app.settings.vo.AppSettingsWechatBindingVO; import com.tcm.training.admin.app.share.service.AppShareService; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.mapper.UserMapper; import com.tcm.training.admin.upload.ObsUploadService; import com.tcm.training.admin.upload.UploadController; import com.tcm.training.admin.upload.UploadFileResult; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.time.LocalDateTime; import java.util.HashSet; import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.regex.Pattern; /** * APP 设置页服务。 */ @Service public class AppSettingsService { private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$"); private static final List SUPPORTED_ADVANCE_MINUTES = Arrays.asList(15, 30, 60, 120, 180); private static final long AVATAR_MAX_SIZE = 5 * 1024 * 1024L; private static final Set ALLOWED_IMAGE_TYPES = new HashSet( Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp") ); private final AppAuthSessionService appAuthSessionService; private final UserMapper userMapper; private final AppSmsCodeService appSmsCodeService; private final BCryptPasswordEncoder passwordEncoder; private final AppWechatAuthClient appWechatAuthClient; private final AppShareService appShareService; private final AppUserNotificationSettingMapper appUserNotificationSettingMapper; private final ObsUploadService obsUploadService; /** * 注入 APP 设置页所需依赖。 */ public AppSettingsService(AppAuthSessionService appAuthSessionService, UserMapper userMapper, AppSmsCodeService appSmsCodeService, BCryptPasswordEncoder passwordEncoder, AppWechatAuthClient appWechatAuthClient, AppShareService appShareService, AppUserNotificationSettingMapper appUserNotificationSettingMapper, ObsUploadService obsUploadService) { this.appAuthSessionService = appAuthSessionService; this.userMapper = userMapper; this.appSmsCodeService = appSmsCodeService; this.passwordEncoder = passwordEncoder; this.appWechatAuthClient = appWechatAuthClient; this.appShareService = appShareService; this.appUserNotificationSettingMapper = appUserNotificationSettingMapper; this.obsUploadService = obsUploadService; } /** * 获取当前登录用户设置页资料。 */ public AppSettingsProfileVO getProfile() { return toProfileVO(requireCurrentEnabledUser()); } /** * 上传当前登录用户头像。 */ public UploadController.UploadResult uploadAvatar(MultipartFile file) throws IOException { // APP 路由默认允许游客访问,因此头像上传必须在业务层显式校验登录用户。 requireCurrentEnabledUser(); validateAvatarImage(file); UploadFileResult uploadResult = obsUploadService.upload(file, "app/avatar", null); return new UploadController.UploadResult(uploadResult.getUrl(), uploadResult.getFileName()); } /** * 更新当前登录用户设置页资料。 */ @Transactional public AppSettingsProfileVO updateProfile(AppSettingsProfileUpdateRequest request) { AppUser user = requireCurrentEnabledUser(); user.setNickname(normalizeNickname(request.getNickname())); user.setAvatar(normalizeAvatar(request.getAvatar())); user.setIntro(normalizeIntro(request.getIntro())); userMapper.updateById(user); return toProfileVO(user); } /** * 发送修改手机号验证码。 */ public void sendPhoneChangeCode(AppSettingsPhoneCodeRequest request) { AppUser currentUser = requireCurrentEnabledUser(); String newPhone = normalizePhone(request.getNewPhone()); if (newPhone.equals(trimToEmpty(currentUser.getPhone()))) { throw new IllegalArgumentException("新手机号不能与当前手机号一致"); } if (trimToNull(currentUser.getPhone()) == null) { validateThirdPartyPhoneBindable(currentUser, newPhone); } else { requirePhoneAvailable(newPhone, currentUser.getId()); } appSmsCodeService.createPhoneChangeCode(newPhone); } /** * 修改当前登录用户手机号。 */ @Transactional public AppSettingsProfileVO updatePhone(AppSettingsPhoneUpdateRequest request) { AppUser user = requireCurrentEnabledUser(); String oldPhone = normalizePhone(request.getOldPhone()); String newPhone = normalizePhone(request.getNewPhone()); String code = normalizeRequired(request.getCode(), "请输入验证码"); if (!oldPhone.equals(trimToEmpty(user.getPhone()))) { // 设置页保留旧手机号输入框,因此服务端需要显式校验,避免用户误改其它账号手机号。 throw new IllegalArgumentException("旧手机号校验失败"); } if (newPhone.equals(oldPhone)) { throw new IllegalArgumentException("新手机号不能与当前手机号一致"); } requirePhoneAvailable(newPhone, user.getId()); appSmsCodeService.validatePhoneChangeCode(newPhone, code); user.setPhone(newPhone); userMapper.updateById(user); return toProfileVO(user); } /** * 首次绑定当前登录用户手机号,供微信登录未绑定手机号场景使用。 */ @Transactional public AppLoginResponse bindPhone(AppSettingsPhoneBindRequest request) { AppUser user = requireCurrentEnabledUser(); String newPhone = normalizePhone(request.getNewPhone()); String code = normalizeRequired(request.getCode(), "请输入验证码"); if (trimToNull(user.getPhone()) != null) { throw new IllegalArgumentException("当前账号已绑定手机号"); } AppUser occupiedUser = findUserByPhone(newPhone); validatePhoneBindTarget(user, occupiedUser); appSmsCodeService.validatePhoneChangeCode(newPhone, code); AppUser loginUser = occupiedUser == null ? bindPhoneToCurrentUser(user, newPhone, request.getInviteCode()) : mergeThirdPartyUserToPhoneUser(user, occupiedUser, request.getInviteCode()); return createLoginResponse(loginUser); } /** * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。 */ private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) { if (user.getClinicId() != null || user.getSuperiorClinicUserId() != null) { return; } String normalizedInviteCode = normalizeInviteCode(inviteCode); if (normalizedInviteCode == null) { return; } AppShareService.InviteBindingTarget bindingTarget = appShareService.resolveInviteBindingTarget(normalizedInviteCode); user.setClinicId(bindingTarget.getClinicId()); user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId()); } /** * 将手机号直接绑定到当前临时账号。 */ private AppUser bindPhoneToCurrentUser(AppUser user, String phone, String inviteCode) { user.setPhone(phone); bindInviteRelationIfAbsent(user, inviteCode); userMapper.updateById(user); return user; } /** * 将当前第三方临时账号合并到已存在的手机号账号。 */ private AppUser mergeThirdPartyUserToPhoneUser(AppUser currentUser, AppUser targetUser, String inviteCode) { requireEnabledUser(targetUser); copyThirdPartyBinding(currentUser, targetUser); fillMissingProfile(targetUser, currentUser); bindInviteRelationIfAbsent(targetUser, inviteCode); userMapper.updateById(targetUser); disableMergedTemporaryUser(currentUser); return targetUser; } /** * 校验首次绑定手机号时目标手机号是否允许承接当前第三方身份。 */ private void validateThirdPartyPhoneBindable(AppUser currentUser, String phone) { validatePhoneBindTarget(currentUser, findUserByPhone(phone)); } /** * 校验目标手机号账号未绑定当前第三方类型。 */ private void validatePhoneBindTarget(AppUser currentUser, AppUser targetUser) { if (targetUser == null || targetUser.getId().equals(currentUser.getId())) { return; } if (hasWechatBinding(currentUser) || hasAppleBinding(currentUser)) { return; } throw new IllegalArgumentException("该手机号已注册"); } /** * 将当前临时账号上的第三方票据写入目标手机号账号。 */ private void copyThirdPartyBinding(AppUser sourceUser, AppUser targetUser) { if (hasWechatBinding(sourceUser)) { targetUser.setWechatOpenid(sourceUser.getWechatOpenid()); targetUser.setWechatNickname(sourceUser.getWechatNickname()); targetUser.setWechatAvatar(sourceUser.getWechatAvatar()); targetUser.setWechatBoundAt(sourceUser.getWechatBoundAt() == null ? LocalDateTime.now() : sourceUser.getWechatBoundAt()); } if (hasAppleBinding(sourceUser)) { targetUser.setAppleUserIdentifier(sourceUser.getAppleUserIdentifier()); targetUser.setAppleIdentitySubject(sourceUser.getAppleIdentitySubject()); targetUser.setAppleBoundAt(sourceUser.getAppleBoundAt() == null ? LocalDateTime.now() : sourceUser.getAppleBoundAt()); } } /** * 目标账号资料为空时,复用临时第三方账号资料补齐展示信息。 */ private void fillMissingProfile(AppUser targetUser, AppUser sourceUser) { if (trimToNull(targetUser.getNickname()) == null) { targetUser.setNickname(resolveNickname(sourceUser)); } if (trimToNull(targetUser.getAvatar()) == null) { targetUser.setAvatar(resolveAvatar(sourceUser)); } if (targetUser.getGender() == null) { targetUser.setGender(sourceUser.getGender()); } } /** * 合并后清空第三方票据并逻辑删除临时账号,避免同一 openid 或苹果 subject 命中多个账号。 */ private void disableMergedTemporaryUser(AppUser currentUser) { userMapper.update(null, new LambdaUpdateWrapper() .eq(AppUser::getId, currentUser.getId()) .set(AppUser::getStatus, 0) .set(AppUser::getWechatOpenid, null) .set(AppUser::getWechatNickname, null) .set(AppUser::getWechatAvatar, null) .set(AppUser::getWechatBoundAt, null) .set(AppUser::getAppleUserIdentifier, null) .set(AppUser::getAppleIdentitySubject, null) .set(AppUser::getAppleBoundAt, null) .set(AppUser::getDeleted, 1)); } /** * 修改当前登录用户密码,并在成功后使当前登录态失效。 */ @Transactional public void updatePassword(AppSettingsPasswordUpdateRequest request) { AppUser user = requireCurrentEnabledUser(); String oldPassword = normalizeRequired(request.getOldPassword(), "请输入原密码"); String newPassword = normalizePassword(request.getNewPassword(), "请输入新密码"); String confirmPassword = normalizePassword(request.getConfirmPassword(), "请再次输入密码"); if (!passwordEncoder.matches(oldPassword, trimToEmpty(user.getPassword()))) { throw new IllegalArgumentException("原密码错误"); } if (!newPassword.equals(confirmPassword)) { throw new IllegalArgumentException("两次密码输入不一致"); } user.setPassword(passwordEncoder.encode(newPassword)); userMapper.updateById(user); appAuthSessionService.logout(); } /** * 绑定或换绑当前登录用户微信信息。 */ @Transactional public AppSettingsWechatBindingVO bindWechat(AppWechatLoginRequest request) { AppUser currentUser = requireCurrentEnabledUser(); AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request); AppUser occupiedUser = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getWechatOpenid, profile.getOpenid()) .eq(AppUser::getDeleted, 0) .last("limit 1")); // 同一个 openid 只允许归属一个有效账号,避免微信账号被多个本地账号共用。 if (occupiedUser != null && !occupiedUser.getId().equals(currentUser.getId())) { throw new IllegalArgumentException("该微信已绑定其他账号"); } String wechatNickname = trimToNull(profile.getNickname()); String wechatAvatar = trimToNull(profile.getAvatar()); currentUser.setWechatOpenid(profile.getOpenid()); currentUser.setWechatNickname(wechatNickname); currentUser.setWechatAvatar(wechatAvatar); currentUser.setWechatBoundAt(LocalDateTime.now()); if (trimToNull(currentUser.getNickname()) == null && wechatNickname != null) { currentUser.setNickname(wechatNickname); } if (trimToNull(currentUser.getAvatar()) == null && wechatAvatar != null) { currentUser.setAvatar(wechatAvatar); } if (currentUser.getGender() == null) { currentUser.setGender(resolveWechatGender(profile.getSex())); } userMapper.updateById(currentUser); return buildWechatBinding(currentUser); } /** * 获取当前登录用户通知设置,没有记录时按默认值初始化。 */ @Transactional public AppSettingsNotificationVO getNotificationSettings() { return toNotificationVO(requireNotificationSetting(requireCurrentEnabledUser().getId())); } /** * 保存当前登录用户通知设置。 */ @Transactional public AppSettingsNotificationVO updateNotificationSettings(AppSettingsNotificationUpdateRequest request) { AppUser user = requireCurrentEnabledUser(); AppUserNotificationSetting setting = requireNotificationSetting(user.getId()); Integer advanceMinutes = normalizeAdvanceMinutes(request.getAdvanceMinutes()); setting.setOrderStatusUpdateEnabled(booleanToInteger(request.getOrderStatusUpdateEnabled())); setting.setActivityNoticeEnabled(booleanToInteger(request.getActivityNoticeEnabled())); setting.setCourseUpdateEnabled(booleanToInteger(request.getCourseUpdateEnabled())); setting.setLiveReservationEnabled(booleanToInteger(request.getLiveReservationEnabled())); setting.setAdvanceNoticeMinutes(advanceMinutes); appUserNotificationSettingMapper.updateById(setting); return toNotificationVO(setting); } /** * 注销当前登录账号,保留历史业务记录用于审计和数据回溯。 */ @Transactional public void cancelAccount() { AppUser user = requireCurrentEnabledUser(); user.setStatus(0); user.setDeleted(1); userMapper.updateById(user); appAuthSessionService.logout(); } /** * 校验当前登录用户存在且启用。 */ private AppUser requireCurrentEnabledUser() { Long userId = appAuthSessionService.requireLoginUserId(); AppUser user = userMapper.selectById(userId); if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) { throw new IllegalArgumentException("用户不存在"); } if (user.getStatus() == null || user.getStatus().intValue() != 1) { throw new IllegalArgumentException("用户已禁用"); } return user; } /** * 读取当前用户通知设置,不存在时自动创建默认值。 */ private AppUserNotificationSetting requireNotificationSetting(Long userId) { AppUserNotificationSetting setting = appUserNotificationSettingMapper.selectOne(new LambdaQueryWrapper() .eq(AppUserNotificationSetting::getAppUserId, userId) .eq(AppUserNotificationSetting::getDeleted, 0) .last("limit 1")); if (setting != null) { return setting; } AppUserNotificationSetting created = new AppUserNotificationSetting(); created.setAppUserId(userId); created.setOrderStatusUpdateEnabled(1); created.setActivityNoticeEnabled(0); created.setCourseUpdateEnabled(0); created.setLiveReservationEnabled(1); created.setAdvanceNoticeMinutes(60); created.setDeleted(0); appUserNotificationSettingMapper.insert(created); return created; } /** * 将用户实体转换为设置页资料响应。 */ private AppSettingsProfileVO toProfileVO(AppUser user) { AppSettingsProfileVO result = new AppSettingsProfileVO(); result.setId(user.getId()); result.setNickname(resolveNickname(user)); result.setAvatar(resolveAvatar(user)); result.setPhone(trimToEmpty(user.getPhone())); result.setIntro(trimToEmpty(user.getIntro())); result.setType(user.getType()); result.setWechatBinding(buildWechatBinding(user)); return result; } /** * 将通知设置实体转换为响应结构。 */ private AppSettingsNotificationVO toNotificationVO(AppUserNotificationSetting setting) { AppSettingsNotificationVO result = new AppSettingsNotificationVO(); result.setOrderStatusUpdateEnabled(integerToBoolean(setting.getOrderStatusUpdateEnabled())); result.setActivityNoticeEnabled(integerToBoolean(setting.getActivityNoticeEnabled())); result.setCourseUpdateEnabled(integerToBoolean(setting.getCourseUpdateEnabled())); result.setLiveReservationEnabled(integerToBoolean(setting.getLiveReservationEnabled())); result.setAdvanceMinutes(setting.getAdvanceNoticeMinutes()); return result; } /** * 构造微信绑定展示信息。 */ private AppSettingsWechatBindingVO buildWechatBinding(AppUser user) { AppSettingsWechatBindingVO result = new AppSettingsWechatBindingVO(); result.setBound(trimToNull(user.getWechatOpenid()) != null); result.setNickname(trimToEmpty(user.getWechatNickname())); result.setAvatar(trimToEmpty(user.getWechatAvatar())); result.setBoundAt(user.getWechatBoundAt()); return result; } /** * 按手机号查询未删除的 APP 用户。 */ private AppUser findUserByPhone(String phone) { return userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getPhone, phone) .eq(AppUser::getDeleted, 0) .last("limit 1")); } /** * 校验用户启用状态。 */ private void requireEnabledUser(AppUser user) { if (user.getStatus() == null || user.getStatus().intValue() != 1) { throw new IllegalArgumentException("账号已被禁用"); } } /** * 判断账号是否已绑定微信。 */ private boolean hasWechatBinding(AppUser user) { return trimToNull(user.getWechatOpenid()) != null; } /** * 判断账号是否已绑定苹果。 */ private boolean hasAppleBinding(AppUser user) { return trimToNull(user.getAppleIdentitySubject()) != null; } /** * 为绑定后的用户签发新的 APP 登录态。 */ private AppLoginResponse createLoginResponse(AppUser user) { appAuthSessionService.login(user.getId()); SaTokenInfo tokenInfo = appAuthSessionService.getTokenInfo(); return new AppLoginResponse( tokenInfo.getTokenValue(), "Bearer", tokenInfo.getTokenTimeout(), buildLoginUser(user) ); } /** * 将用户实体转换为登录响应中的用户摘要。 */ private AppLoginUserVO buildLoginUser(AppUser user) { AppLoginUserVO loginUser = new AppLoginUserVO(); loginUser.setId(user.getId()); loginUser.setNickname(resolveNickname(user)); loginUser.setPhone(trimToEmpty(user.getPhone())); loginUser.setAvatar(resolveAvatar(user)); loginUser.setType(user.getType()); loginUser.setStatus(user.getStatus()); loginUser.setClinicId(user.getClinicId()); loginUser.setMemberExpireAt(user.getMemberExpireAt()); return loginUser; } /** * 校验手机号未被其他有效用户占用。 */ private void requirePhoneAvailable(String phone, Long currentUserId) { AppUser occupiedUser = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getPhone, phone) .eq(AppUser::getDeleted, 0) .last("limit 1")); if (occupiedUser != null && !occupiedUser.getId().equals(currentUserId)) { throw new IllegalArgumentException("该手机号已注册"); } } /** * 归一化昵称输入。 */ private String normalizeNickname(String nickname) { String normalized = trimToNull(nickname); if (normalized == null) { throw new IllegalArgumentException("请输入姓名"); } return normalized; } /** * 校验头像图片大小与格式。 */ private void validateAvatarImage(MultipartFile file) { if (file == null || file.isEmpty()) { throw new IllegalArgumentException("请选择要上传的文件"); } if (file.getSize() > AVATAR_MAX_SIZE) { throw new IllegalArgumentException("文件大小超出限制"); } String contentType = file.getContentType(); if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { throw new IllegalArgumentException("仅支持 JPG、PNG、GIF、WebP 格式"); } } /** * 归一化头像地址输入。 */ private String normalizeAvatar(String avatar) { return trimToNull(avatar); } /** * 归一化简介输入,允许为空。 */ private String normalizeIntro(String intro) { return trimToNull(intro); } /** * 归一化并校验手机号。 */ private String normalizePhone(String phone) { String normalized = trimToNull(phone); if (normalized == null || !PHONE_PATTERN.matcher(normalized).matches()) { throw new IllegalArgumentException("请输入正确的手机号"); } return normalized; } /** * 归一化必填文本。 */ private String normalizeRequired(String value, String message) { String normalized = trimToNull(value); if (normalized == null) { throw new IllegalArgumentException(message); } return normalized; } /** * 归一化并校验密码规则。 */ private String normalizePassword(String password, String emptyMessage) { String normalized = trimToNull(password); if (normalized == null) { throw new IllegalArgumentException(emptyMessage); } if (!PASSWORD_PATTERN.matcher(normalized).matches()) { throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); } return normalized; } /** * 归一化医生邀请码,非法时忽略以保持绑定手机号主流程可用。 */ private String normalizeInviteCode(String inviteCode) { String normalized = trimToNull(inviteCode); if (normalized == null) { return null; } String upperInviteCode = normalized.toUpperCase(); return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null; } /** * 校验通知时间仅允许使用页面固定选项。 */ private Integer normalizeAdvanceMinutes(Integer advanceMinutes) { if (advanceMinutes == null || !SUPPORTED_ADVANCE_MINUTES.contains(advanceMinutes)) { throw new IllegalArgumentException("通知时间不合法"); } return advanceMinutes; } /** * 将微信性别值转换为本地性别枚举。 */ private Integer resolveWechatGender(Integer sex) { if (sex == null) { return null; } if (sex.intValue() == 1) { return 1; } if (sex.intValue() == 2) { return 0; } return null; } /** * 优先使用本地昵称,没有时回退微信昵称和手机号。 */ private String resolveNickname(AppUser user) { String nickname = trimToNull(user.getNickname()); if (nickname != null) { return nickname; } String wechatNickname = trimToNull(user.getWechatNickname()); if (wechatNickname != null) { return wechatNickname; } return trimToEmpty(user.getPhone()); } /** * 优先使用本地头像,没有时回退微信头像。 */ private String resolveAvatar(AppUser user) { String avatar = trimToNull(user.getAvatar()); if (avatar != null) { return avatar; } return trimToEmpty(user.getWechatAvatar()); } /** * 将布尔值转换为数据库使用的 0/1。 */ private Integer booleanToInteger(Boolean value) { return Boolean.TRUE.equals(value) ? 1 : 0; } /** * 将数据库 0/1 转换为布尔值。 */ private Boolean integerToBoolean(Integer value) { return value != null && value.intValue() == 1; } /** * 将空白文本归一化为 null。 */ private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } /** * 将可空文本转换为空字符串。 */ private String trimToEmpty(String value) { String normalized = trimToNull(value); return normalized == null ? "" : normalized; } }