From 4dd7d98ce9b885a7d9b9d8b4ffd89e03d3cdfba8 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 9 Nov 2026 09:23:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(profile):=20=E6=96=B0=E5=A2=9EC=E7=AB=AF?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E8=B5=84=E6=96=99=E4=B8=8E=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=9B=B8=E5=86=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增个人资料模块,支持获取、修改昵称和个性签名 - 实现头像上传与校验,支持JPEG/PNG格式及尺寸限制 - 添加app_user表signature字段的可重复执行SQL脚本 - 新增设备相册控制器,支持读取、收藏和删除相册图片 - 编写AppProfileService和DeviceAlbumController单元测试 --- sql/ttstd/app_user_profile.sql | 12 ++ .../controller/AppProfileController.java | 39 +++++++ .../app/profile/mapper/AppProfileMapper.java | 24 ++++ .../app/profile/model/AppProfileForm.java | 15 +++ .../boot/app/profile/model/AppProfileVO.java | 12 ++ .../profile/service/AppProfileService.java | 109 ++++++++++++++++++ .../controller/DeviceAlbumController.java | 53 +++++++++ .../app/profile/AppProfileServiceTest.java | 64 ++++++++++ .../device/DeviceAlbumControllerTest.java | 54 +++++++++ 9 files changed, 382 insertions(+) create mode 100644 sql/ttstd/app_user_profile.sql create mode 100644 src/main/java/com/youlai/boot/app/profile/controller/AppProfileController.java create mode 100644 src/main/java/com/youlai/boot/app/profile/mapper/AppProfileMapper.java create mode 100644 src/main/java/com/youlai/boot/app/profile/model/AppProfileForm.java create mode 100644 src/main/java/com/youlai/boot/app/profile/model/AppProfileVO.java create mode 100644 src/main/java/com/youlai/boot/app/profile/service/AppProfileService.java create mode 100644 src/main/java/com/youlai/boot/device/controller/DeviceAlbumController.java create mode 100644 src/test/java/com/youlai/boot/app/profile/AppProfileServiceTest.java create mode 100644 src/test/java/com/youlai/boot/device/DeviceAlbumControllerTest.java diff --git a/sql/ttstd/app_user_profile.sql b/sql/ttstd/app_user_profile.sql new file mode 100644 index 00000000..cb7546d9 --- /dev/null +++ b/sql/ttstd/app_user_profile.sql @@ -0,0 +1,12 @@ +-- 执行于部署个人资料接口之前,可重复执行。 +SET @profile_column_exists = ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'app_user' + AND COLUMN_NAME = 'signature' +); +SET @profile_sql = IF(@profile_column_exists = 0, + 'ALTER TABLE app_user ADD COLUMN signature VARCHAR(200) NOT NULL DEFAULT '''' COMMENT ''个性签名''', + 'SELECT 1'); +PREPARE profile_statement FROM @profile_sql; +EXECUTE profile_statement; +DEALLOCATE PREPARE profile_statement; diff --git a/src/main/java/com/youlai/boot/app/profile/controller/AppProfileController.java b/src/main/java/com/youlai/boot/app/profile/controller/AppProfileController.java new file mode 100644 index 00000000..b06e97c5 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/profile/controller/AppProfileController.java @@ -0,0 +1,39 @@ +package com.youlai.boot.app.profile.controller; + +import com.youlai.boot.app.profile.model.AppProfileForm; +import com.youlai.boot.app.profile.model.AppProfileVO; +import com.youlai.boot.app.profile.service.AppProfileService; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.util.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/v1/client/account/profile") +@PreAuthorize("isAuthenticated()") +@RequiredArgsConstructor +public class AppProfileController { + private final AppProfileService service; + + @GetMapping + @Operation(summary = "获取当前C端用户个人资料") + public Result get() { + return Result.success(service.get(SecurityUtils.getUserId())); + } + + @PutMapping + @Operation(summary = "修改当前C端用户昵称和个性签名") + public Result update(@Valid @RequestBody AppProfileForm form) { + return Result.success(service.update(SecurityUtils.getUserId(), form)); + } + + @PostMapping("/avatar") + @Operation(summary = "上传并保存当前C端用户头像") + public Result avatar(@RequestPart("file") MultipartFile file) { + return Result.success(service.uploadAvatar(SecurityUtils.getUserId(), file)); + } +} diff --git a/src/main/java/com/youlai/boot/app/profile/mapper/AppProfileMapper.java b/src/main/java/com/youlai/boot/app/profile/mapper/AppProfileMapper.java new file mode 100644 index 00000000..14ed7006 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/profile/mapper/AppProfileMapper.java @@ -0,0 +1,24 @@ +package com.youlai.boot.app.profile.mapper; + +import com.youlai.boot.app.profile.model.AppProfileVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface AppProfileMapper { + @Select("SELECT id, username, nickname, avatar, signature FROM app_user " + + "WHERE id = #{id} AND is_deleted = 0 AND status = 1") + AppProfileVO findCurrent(@Param("id") Long id); + + @Update("UPDATE app_user SET nickname = #{nickname}, signature = #{signature}, " + + "update_time = CURRENT_TIMESTAMP " + + "WHERE id = #{id} AND is_deleted = 0 AND status = 1") + int updateProfile(@Param("id") Long id, @Param("nickname") String nickname, + @Param("signature") String signature); + + @Update("UPDATE app_user SET avatar = #{avatar}, update_time = CURRENT_TIMESTAMP " + + "WHERE id = #{id} AND is_deleted = 0 AND status = 1") + int updateAvatar(@Param("id") Long id, @Param("avatar") String avatar); +} diff --git a/src/main/java/com/youlai/boot/app/profile/model/AppProfileForm.java b/src/main/java/com/youlai/boot/app/profile/model/AppProfileForm.java new file mode 100644 index 00000000..5da4ed58 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/profile/model/AppProfileForm.java @@ -0,0 +1,15 @@ +package com.youlai.boot.app.profile.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AppProfileForm { + @NotBlank(message = "昵称不能为空") + @Size(max = 64, message = "昵称不能超过64个字符") + private String nickname; + + @Size(max = 200, message = "个性签名不能超过200个字符") + private String signature; +} diff --git a/src/main/java/com/youlai/boot/app/profile/model/AppProfileVO.java b/src/main/java/com/youlai/boot/app/profile/model/AppProfileVO.java new file mode 100644 index 00000000..e8428ca5 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/profile/model/AppProfileVO.java @@ -0,0 +1,12 @@ +package com.youlai.boot.app.profile.model; + +import lombok.Data; + +@Data +public class AppProfileVO { + private Long id; + private String username; + private String nickname; + private String avatar; + private String signature; +} diff --git a/src/main/java/com/youlai/boot/app/profile/service/AppProfileService.java b/src/main/java/com/youlai/boot/app/profile/service/AppProfileService.java new file mode 100644 index 00000000..de589f5a --- /dev/null +++ b/src/main/java/com/youlai/boot/app/profile/service/AppProfileService.java @@ -0,0 +1,109 @@ +package com.youlai.boot.app.profile.service; + +import com.youlai.boot.app.profile.mapper.AppProfileMapper; +import com.youlai.boot.app.profile.model.AppProfileForm; +import com.youlai.boot.app.profile.model.AppProfileVO; +import com.youlai.boot.common.config.StorageDir; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.InputStream; +import java.util.Iterator; +import java.util.Locale; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppProfileService { + private final AppProfileMapper mapper; + private final FileService fileService; + + public AppProfileVO get(Long userId) { + AppProfileVO profile = mapper.findCurrent(userId); + if (profile == null) throw new BusinessException("用户不存在或已停用"); + return profile; + } + + public AppProfileVO update(Long userId, AppProfileForm form) { + get(userId); + String nickname = form.getNickname().strip(); + if (nickname.isEmpty() || nickname.length() > 64) { + throw new BusinessException("昵称须为1至64个字符"); + } + String signature = form.getSignature() == null ? "" : form.getSignature().strip(); + mapper.updateProfile(userId, nickname, signature); + return get(userId); + } + + public AppProfileVO uploadAvatar(Long userId, MultipartFile file) { + get(userId); + validateAvatar(file); + FileInfo uploaded = fileService.uploadFile(file, StorageDir.AVATAR); + if (uploaded == null || !StringUtils.hasText(uploaded.getUrl())) { + throw new BusinessException("头像上传失败"); + } + try { + if (uploaded.getUrl().length() > 255) { + throw new BusinessException("头像地址过长"); + } + if (mapper.updateAvatar(userId, uploaded.getUrl()) != 1) { + throw new BusinessException("头像保存失败,请重试"); + } + } catch (RuntimeException error) { + try { + fileService.deleteFile(uploaded.getUrl()); + } catch (Exception cleanupError) { + log.warn("清理未保存的头像失败, userId={}", userId); + } + throw error; + } + return get(userId); + } + + private void validateAvatar(MultipartFile file) { + if (file.isEmpty() || file.getSize() > 5 * 1024 * 1024) { + throw new BusinessException("请选择不超过5MB的头像图片"); + } + String name = file.getOriginalFilename(); + if (name == null) throw new BusinessException("头像文件名不能为空"); + String lowerName = name.toLowerCase(Locale.ROOT); + boolean png = lowerName.endsWith(".png"); + boolean jpeg = lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg"); + if ((!png && !jpeg) || !(png ? "image/png" : "image/jpeg").equals(file.getContentType())) { + throw new BusinessException("头像仅支持JPEG或PNG图片"); + } + try (InputStream stream = file.getInputStream(); + ImageInputStream input = ImageIO.createImageInputStream(stream)) { + if (input == null) throw new BusinessException("头像图片无效"); + Iterator readers = ImageIO.getImageReaders(input); + if (!readers.hasNext()) throw new BusinessException("头像图片无效"); + ImageReader reader = readers.next(); + try { + reader.setInput(input); + String format = reader.getFormatName(); + if (!(png ? "png" : "JPEG").equalsIgnoreCase(format) + || reader.getWidth(0) > 4096 || reader.getHeight(0) > 4096) { + throw new BusinessException("头像格式或尺寸不支持"); + } + var image = reader.read(0); + if (image == null) throw new BusinessException("头像图片无效"); + image.flush(); + } finally { + reader.dispose(); + } + } catch (BusinessException error) { + throw error; + } catch (Exception error) { + throw new BusinessException("头像图片无效"); + } + } +} diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceAlbumController.java b/src/main/java/com/youlai/boot/device/controller/DeviceAlbumController.java new file mode 100644 index 00000000..1699c046 --- /dev/null +++ b/src/main/java/com/youlai/boot/device/controller/DeviceAlbumController.java @@ -0,0 +1,53 @@ +package com.youlai.boot.device.controller; + +import com.youlai.boot.common.result.Result; +import com.youlai.boot.device.model.vo.DevicePictureVO; +import com.youlai.boot.device.service.DevicePictureService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** DeviceSignatureVerifier validates the device headers for every /api/v1/sn request. */ +@RestController +@RequestMapping("/api/v1/sn/pictures") +@RequiredArgsConstructor +@Validated +@Tag(name = "设备相册") +public class DeviceAlbumController { + private final DevicePictureService pictures; + + @GetMapping + @Operation(summary = "设备读取家属端上传的相册图片及收藏状态") + public Result> list(@RequestHeader("X-Device-SN") @NotBlank String sn) { + return Result.success(pictures.listPictures(sn)); + } + + @PostMapping("/favorite") + @Operation(summary = "设备设置相册图片收藏状态") + public Result favorite( + @RequestHeader("X-Device-SN") @NotBlank String sn, + @RequestParam("url") @NotBlank String url, + @RequestParam("favorite") @NotNull @Min(0) @Max(1) Integer favorite) { + if (!pictures.updateFavorite(sn, url, favorite == 1)) { + return Result.failed("图片不存在或已删除"); + } + return Result.success(); + } + + @DeleteMapping + @Operation(summary = "设备删除家属端上传的相册图片记录") + public Result delete( + @RequestHeader("X-Device-SN") @NotBlank String sn, + @RequestParam("url") @NotBlank String url) { + pictures.deletePicture(sn, url); + return Result.success(); + } +} diff --git a/src/test/java/com/youlai/boot/app/profile/AppProfileServiceTest.java b/src/test/java/com/youlai/boot/app/profile/AppProfileServiceTest.java new file mode 100644 index 00000000..12c37b7f --- /dev/null +++ b/src/test/java/com/youlai/boot/app/profile/AppProfileServiceTest.java @@ -0,0 +1,64 @@ +package com.youlai.boot.app.profile; + +import com.youlai.boot.app.profile.mapper.AppProfileMapper; +import com.youlai.boot.app.profile.model.AppProfileForm; +import com.youlai.boot.app.profile.model.AppProfileVO; +import com.youlai.boot.app.profile.service.AppProfileService; +import com.youlai.boot.common.config.StorageDir; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppProfileServiceTest { + private final AppProfileMapper mapper = mock(AppProfileMapper.class); + private final FileService files = mock(FileService.class); + private final AppProfileService service = new AppProfileService(mapper, files); + + @Test + void updatesOnlyCurrentUsersTextFields() { + AppProfileVO current = new AppProfileVO(); + current.setId(7L); + when(mapper.findCurrent(7L)).thenReturn(current); + AppProfileForm form = new AppProfileForm(); + form.setNickname(" 家人 "); + form.setSignature(" 你好 "); + + service.update(7L, form); + + verify(mapper).updateProfile(7L, "家人", "你好"); + verify(mapper, never()).updateAvatar(anyLong(), anyString()); + verifyNoInteractions(files); + } + + @Test + void rejectsInvalidAvatarBeforeUploading() { + when(mapper.findCurrent(7L)).thenReturn(new AppProfileVO()); + MockMultipartFile invalid = new MockMultipartFile( + "file", "avatar.png", "image/png", "invalid".getBytes()); + assertThrows(BusinessException.class, () -> service.uploadAvatar(7L, invalid)); + verifyNoInteractions(files); + } + + @Test + void removesNewUploadWhenUserCannotBeUpdated() { + when(mapper.findCurrent(7L)).thenReturn(new AppProfileVO()); + byte[] png = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="); + MockMultipartFile image = new MockMultipartFile("file", "avatar.png", "image/png", png); + FileInfo uploaded = new FileInfo(); + uploaded.setUrl("https://files.example/app/avatar/new.png"); + when(files.uploadFile(image, StorageDir.AVATAR)).thenReturn(uploaded); + when(mapper.updateAvatar(7L, uploaded.getUrl())).thenReturn(0); + + assertThrows(BusinessException.class, () -> service.uploadAvatar(7L, image)); + + verify(files).deleteFile(uploaded.getUrl()); + } +} diff --git a/src/test/java/com/youlai/boot/device/DeviceAlbumControllerTest.java b/src/test/java/com/youlai/boot/device/DeviceAlbumControllerTest.java new file mode 100644 index 00000000..9f8060d7 --- /dev/null +++ b/src/test/java/com/youlai/boot/device/DeviceAlbumControllerTest.java @@ -0,0 +1,54 @@ +package com.youlai.boot.device; + +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.device.controller.DeviceAlbumController; +import com.youlai.boot.device.model.vo.DevicePictureVO; +import com.youlai.boot.device.service.DevicePictureService; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class DeviceAlbumControllerTest { + private final DevicePictureService pictures = mock(DevicePictureService.class); + private final DeviceAlbumController controller = new DeviceAlbumController(pictures); + + @Test + void listUsesAuthenticatedDeviceAndRetainsFavoriteState() { + DevicePictureVO photo = new DevicePictureVO(); + photo.setUrl("https://example.com/photo.jpg"); + photo.setFavorite(1); + when(pictures.listPictures("device-a")).thenReturn(List.of(photo)); + assertThat(controller.list("device-a").getData()).containsExactly(photo); + verify(pictures).listPictures("device-a"); + verifyNoMoreInteractions(pictures); + } + + @Test + void favoriteAndDeleteUseAuthenticatedDeviceAndExactUrl() { + String url = "https://example.com/photo.jpg?key=a%2Bb"; + when(pictures.updateFavorite("device-a", url, true)).thenReturn(true); + assertThat(controller.favorite("device-a", url, 1).getCode()).isEqualTo(ResultCode.SUCCESS.getCode()); + controller.delete("device-a", url); + verify(pictures).updateFavorite("device-a", url, true); + verify(pictures).deletePicture("device-a", url); + verifyNoMoreInteractions(pictures); + } + + @Test + void missingPictureDoesNotReportFavoriteSuccess() { + assertThat(controller.favorite("device-a", "missing", 0).getCode()).isNotEqualTo(ResultCode.SUCCESS.getCode()); + } + + @Test + void mutationRejectsBlankIdentityAndInvalidFavorite() throws Exception { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator().forExecutables(); + var method = DeviceAlbumController.class.getMethod("favorite", String.class, String.class, Integer.class); + assertThat(validator.validateParameters(controller, method, new Object[]{"device-a", "url", 2})).isNotEmpty(); + assertThat(validator.validateParameters(controller, method, new Object[]{"", " ", 1})).hasSize(2); + assertThat(validator.validateParameters(controller, method, new Object[]{"device-a", "url", 0})).isEmpty(); + } + } +}