From e1717a144372473d018668bf293061c0cbbc0c9d Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Tue, 29 Sep 2026 09:52:01 +0800 Subject: [PATCH] =?UTF-8?q?AI=E5=8A=A9=E6=89=8B=E4=BC=98=E5=8C=96=20?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E4=BF=A1=E6=81=AF=E5=8F=AF=E4=BB=A5=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=20WEB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../accounting/controller/AiController.java | 25 ++- .../accounting/controller/AuthController.java | 53 ++++-- .../java/com/accounting/dto/AuthResponse.java | 8 +- .../accounting/dto/AvatarUploadResponse.java | 24 +++ .../accounting/dto/UpdateProfileRequest.java | 25 +++ .../accounting/dto/ai/AiPersonaRequest.java | 11 ++ .../accounting/dto/ai/AiPersonaResponse.java | 7 + .../accounting/dto/ai/EffectivePersona.java | 14 +- .../com/accounting/dto/ai/SessionItem.java | 13 ++ .../accounting/entity/AiPersonaEntity.java | 8 + .../com/accounting/entity/ChatSession.java | 11 ++ src/main/java/com/accounting/entity/User.java | 7 + .../com/accounting/service/AiChatService.java | 24 ++- .../accounting/service/AiPersonaService.java | 55 +++++++ .../com/accounting/service/AuthService.java | 65 +++++++- .../com/accounting/service/AvatarService.java | 103 ++++++++++++ .../db/migration_add_avatar_and_persona.sql | 41 +++++ src/main/resources/db/schema.sql | 3 + src/main/resources/static/admin/admin.css | 28 ++++ src/main/resources/static/admin/admin.js | 139 +++++++++++++++- src/main/resources/static/admin/index.html | 9 ++ .../service/AiPersonaServiceTest.java | 151 +++++++++++++++++- 22 files changed, 789 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/accounting/dto/AvatarUploadResponse.java create mode 100644 src/main/java/com/accounting/dto/UpdateProfileRequest.java create mode 100644 src/main/java/com/accounting/service/AvatarService.java create mode 100644 src/main/resources/db/migration_add_avatar_and_persona.sql diff --git a/src/main/java/com/accounting/controller/AiController.java b/src/main/java/com/accounting/controller/AiController.java index 8348e3f..ec775a9 100644 --- a/src/main/java/com/accounting/controller/AiController.java +++ b/src/main/java/com/accounting/controller/AiController.java @@ -1,6 +1,7 @@ package com.accounting.controller; import com.accounting.config.AiConfig; +import com.accounting.dto.AvatarUploadResponse; import com.accounting.dto.ai.AiConfigRequest; import com.accounting.dto.ai.AiConfigResponse; import com.accounting.dto.ai.AiPersonaRequest; @@ -19,6 +20,7 @@ import com.accounting.mapper.UserMapper; import com.accounting.service.AiChatService; import com.accounting.service.AiConfigService; import com.accounting.service.AiPersonaService; +import com.accounting.service.AvatarService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import io.swagger.v3.oas.annotations.Operation; @@ -36,7 +38,9 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.List; @@ -69,6 +73,9 @@ public class AiController { @Autowired private AiPersonaService aiPersonaService; + @Autowired + private AvatarService avatarService; + @Autowired private ChatSessionMapper sessionMapper; @@ -84,7 +91,7 @@ public class AiController { return Map.of("enabled", aiConfig.isReady()); } - @Operation(summary = "会话列表(按最近活跃倒序)") + @Operation(summary = "会话列表(按最近活跃倒序,带人设信息)") @GetMapping("/sessions") public List sessions(Authentication authentication) { Long userId = getUserId(authentication); @@ -99,6 +106,13 @@ public class AiController { item.setId(s.getId()); item.setTitle(s.getTitle()); item.setUpdateTime(s.getUpdateTime()); + item.setPersonaId(s.getPersonaId()); + + // 带上人设名和头像,前端会话列表要显示「这段对话是谁在陪你」。 + // 人设被删了就退回默认名的显示 —— 历史会话不能因为人设没了就打不开 + EffectivePersona persona = aiPersonaService.resolve(userId, s.getPersonaId()); + item.setPersonaName(persona.label()); + item.setPersonaAvatar(aiPersonaService.avatarOf(s.getPersonaId(), userId)); return item; }).toList(); } @@ -181,6 +195,15 @@ public class AiController { return Map.of("success", true); } + @Operation(summary = "上传人设头像(返回路径,再由保存人设的接口提交)") + @PostMapping("/personas/avatar") + public AvatarUploadResponse uploadPersonaAvatar(@RequestParam("file") MultipartFile file) + throws Exception { + // 只落盘不落库 —— 用户在编辑弹层里可能传了头像又取消, + // 那种情况不该在库里留一条记录。写库交给 PUT/POST /personas + return avatarService.save(file, AvatarService.PERSONA_SUB_DIR); + } + // ---------------------------------------------------------------- AI 配置 @Operation(summary = "AI 配置列表(第一项是虚拟的「默认」,值来自服务端 .env)") diff --git a/src/main/java/com/accounting/controller/AuthController.java b/src/main/java/com/accounting/controller/AuthController.java index 3257032..efd3840 100644 --- a/src/main/java/com/accounting/controller/AuthController.java +++ b/src/main/java/com/accounting/controller/AuthController.java @@ -1,44 +1,71 @@ package com.accounting.controller; import com.accounting.dto.AuthResponse; +import com.accounting.dto.AvatarUploadResponse; import com.accounting.dto.LoginRequest; import com.accounting.dto.RegisterRequest; +import com.accounting.dto.UpdateProfileRequest; import com.accounting.service.AuthService; +import com.accounting.service.AvatarService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; -@Tag(name = "认证管理", description = "用户注册、登录接口") +/** + * 认证与个人资料。 + * + *

头像放在这里而不是单开一个 UserController:App 里只有「我的」页面用得到, + * 而且它天然跟登录态绑定 —— 换完头像要返回新的用户信息给前端刷新。

+ */ +@Tag(name = "认证管理", description = "用户注册、登录、个人资料接口") @RestController @RequestMapping("/api/auth") public class AuthController { - + @Autowired private AuthService authService; - + + @Autowired + private AvatarService avatarService; + @Operation(summary = "用户注册") @PostMapping("/register") public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { AuthResponse response = authService.register(request); return ResponseEntity.ok(response); } - + @Operation(summary = "用户登录") @PostMapping("/login") public ResponseEntity login(@Valid @RequestBody LoginRequest request) { AuthResponse response = authService.login(request); return ResponseEntity.ok(response); } + + @Operation(summary = "拉取当前用户资料(App 冷启动后刷新头像用)") + @GetMapping("/profile") + public ResponseEntity profile(Authentication authentication) { + return ResponseEntity.ok(authService.profile(authentication.getName())); + } + + @Operation(summary = "修改个人资料(目前只有头像)") + @PutMapping("/profile") + public ResponseEntity updateProfile(@Valid @RequestBody UpdateProfileRequest request, + Authentication authentication) { + return ResponseEntity.ok(authService.updateProfile(authentication.getName(), request)); + } + + @Operation(summary = "上传头像(返回路径,再由 PUT /profile 提交)") + @PostMapping("/avatar") + public ResponseEntity uploadAvatar(@RequestParam("file") MultipartFile file) + throws Exception { + // 和上传人设头像一样:只落盘不落库。 + // 用户在弹层里可能传了又取消,那种情况不该留下记录 + return ResponseEntity.ok(avatarService.save(file, AvatarService.USER_SUB_DIR)); + } } - - - - - - - - - diff --git a/src/main/java/com/accounting/dto/AuthResponse.java b/src/main/java/com/accounting/dto/AuthResponse.java index 9a4e240..66d7da6 100644 --- a/src/main/java/com/accounting/dto/AuthResponse.java +++ b/src/main/java/com/accounting/dto/AuthResponse.java @@ -7,11 +7,15 @@ public class AuthResponse { private String token; private String username; private String nickname; - - public AuthResponse(String token, String username, String nickname) { + + /** 头像相对路径。null = 用默认头像 */ + private String avatar; + + public AuthResponse(String token, String username, String nickname, String avatar) { this.token = token; this.username = username; this.nickname = nickname; + this.avatar = avatar; } } diff --git a/src/main/java/com/accounting/dto/AvatarUploadResponse.java b/src/main/java/com/accounting/dto/AvatarUploadResponse.java new file mode 100644 index 0000000..9dc2e84 --- /dev/null +++ b/src/main/java/com/accounting/dto/AvatarUploadResponse.java @@ -0,0 +1,24 @@ +package com.accounting.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 上传头像后返回的东西。 + * + *

只回**相对路径**,不回完整 URL —— 服务端不知道客户端是从哪个域名访问的 + * (局域网 / 内网穿透 / 正式域名都可能)。前端自己拼 {@code /uploads/} 前缀。

+ * + *

给 {@code url} 是为了让前端能立刻预览:那是「拼好 /uploads/ 的相对地址」, + * 仍然不含 host。

+ */ +@Data +@AllArgsConstructor +public class AvatarUploadResponse { + + /** 相对路径,如 {@code persona/2026/09/29/xxx.jpg}。存库用这个 */ + private String path; + + /** 预览地址,如 {@code /uploads/persona/2026/09/29/xxx.jpg}。前端拼 host 后可直接显示 */ + private String url; +} diff --git a/src/main/java/com/accounting/dto/UpdateProfileRequest.java b/src/main/java/com/accounting/dto/UpdateProfileRequest.java new file mode 100644 index 0000000..c1938f3 --- /dev/null +++ b/src/main/java/com/accounting/dto/UpdateProfileRequest.java @@ -0,0 +1,25 @@ +package com.accounting.dto; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 修改个人资料。 + * + *

目前只有头像。昵称故意不开放修改 —— App 里没有这个入口, + * 多一个字段就多一处「改了但界面没反应」的可能。

+ */ +@Data +public class UpdateProfileRequest { + + /** + * 头像相对路径(先调 {@code POST /api/auth/avatar} 拿到)。 + * + *

传 null 或空串 = 不改动。要清空头像用 {@code clearAvatar}。

+ */ + @Size(max = 255, message = "头像路径过长") + private String avatar; + + /** 是否清空头像(回到默认头像) */ + private Boolean clearAvatar; +} diff --git a/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java b/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java index 4f9fac3..55c5d27 100644 --- a/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java +++ b/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java @@ -24,4 +24,15 @@ public class AiPersonaRequest { */ @Size(max = 8000, message = "提示词最长 8000 字") private String systemPrompt; + + /** + * 头像相对路径。前端先调 {@code POST /api/ai/personas/avatar} 拿到路径,再连配置一起提交。 + * + *

传 null 或空串表示**不改动** —— 用户只改提示词时不该被迫重新选头像。

+ */ + @Size(max = 255, message = "头像路径过长") + private String avatar; + + /** 是否清空头像(回到默认头像)。比传空串更明确,避免「不改动」和「清空」混淆 */ + private Boolean clearAvatar; } diff --git a/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java b/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java index 85799cd..6f85f20 100644 --- a/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java +++ b/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java @@ -21,6 +21,13 @@ public class AiPersonaResponse { /** 提示词字数(前端显示「1234 字」用,省得在客户端数) */ private Integer promptLength; + /** + * 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg})。null = 用默认头像。 + * + *

是相对路径不是完整 URL —— 前端自己拼 {@code /uploads/} 前缀。

+ */ + private String avatar; + /** 是否是「默认」那一项 */ private Boolean isDefault; diff --git a/src/main/java/com/accounting/dto/ai/EffectivePersona.java b/src/main/java/com/accounting/dto/ai/EffectivePersona.java index 1ab9d44..c6687d9 100644 --- a/src/main/java/com/accounting/dto/ai/EffectivePersona.java +++ b/src/main/java/com/accounting/dto/ai/EffectivePersona.java @@ -21,14 +21,21 @@ public record EffectivePersona( /** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */ String label, /** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */ - boolean isDefault + boolean isDefault, + /** + * 人设 id。**内置默认时为 null**。 + * + *

带上它是为了「新建会话时把人设绑到会话上」—— 没有 id 就没法落库, + * 而按名字反查会在重名时选错。

+ */ + Long id ) { public static final String DEFAULT_LABEL = "默认人设"; /** 用内置默认文本 */ public static EffectivePersona fromDefaults(String defaultPrompt) { - return new EffectivePersona(defaultPrompt, DEFAULT_LABEL, true); + return new EffectivePersona(defaultPrompt, DEFAULT_LABEL, true, null); } /** 用用户自己配的一条 */ @@ -36,6 +43,7 @@ public record EffectivePersona( return new EffectivePersona( entity.getSystemPrompt() == null ? "" : entity.getSystemPrompt(), entity.getName() == null ? "未命名" : entity.getName(), - false); + false, + entity.getId()); } } diff --git a/src/main/java/com/accounting/dto/ai/SessionItem.java b/src/main/java/com/accounting/dto/ai/SessionItem.java index e911160..ca4109e 100644 --- a/src/main/java/com/accounting/dto/ai/SessionItem.java +++ b/src/main/java/com/accounting/dto/ai/SessionItem.java @@ -14,4 +14,17 @@ public class SessionItem { /** 最近活跃时间,列表按它倒序 */ private LocalDateTime updateTime; + + /** + * 本会话用的人设 id。null = 内置默认人设。 + * + *

给前端用来在会话列表里显示「这段对话是谁在陪你」。

+ */ + private Long personaId; + + /** 人设显示名。查不到(人设已删)时为「默认人设」—— 历史会话仍要能正常打开 */ + private String personaName; + + /** 人设头像相对路径。null = 用默认头像 */ + private String personaAvatar; } diff --git a/src/main/java/com/accounting/entity/AiPersonaEntity.java b/src/main/java/com/accounting/entity/AiPersonaEntity.java index a3905c8..d55eed4 100644 --- a/src/main/java/com/accounting/entity/AiPersonaEntity.java +++ b/src/main/java/com/accounting/entity/AiPersonaEntity.java @@ -35,6 +35,14 @@ public class AiPersonaEntity { /** 人设 + 运行规则。不含日期等环境信息 */ private String systemPrompt; + /** + * 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg}),不是完整 URL。 + * + *

存相对路径是为了换域名/换服务器时不用洗数据 —— 前端拿到后自己拼 + * {@code /uploads/} 前缀。null 表示用默认头像。

+ */ + private String avatar; + @TableLogic private Integer deleted; diff --git a/src/main/java/com/accounting/entity/ChatSession.java b/src/main/java/com/accounting/entity/ChatSession.java index aa5bca6..2f5dee2 100644 --- a/src/main/java/com/accounting/entity/ChatSession.java +++ b/src/main/java/com/accounting/entity/ChatSession.java @@ -27,6 +27,17 @@ public class ChatSession { /** 标题,首条消息时自动取前 20 字 */ private String title; + /** + * 本会话用的人设 ID。**新建会话时确定,之后不再改**。 + * + *

存在会话上而不是每条消息上,是因为人设的粒度是「这次对话用谁」, + * 不是「这句话用谁」—— 聊到一半换性格会很怪。

+ * + *

null 表示用代码内置的默认人设。不建外键:人设被删了之后历史会话 + * 仍要能打开,指向一个不存在的 id 时服务端会静默降级到默认。

+ */ + private Long personaId; + @TableLogic private Integer deleted; diff --git a/src/main/java/com/accounting/entity/User.java b/src/main/java/com/accounting/entity/User.java index e164711..466f850 100644 --- a/src/main/java/com/accounting/entity/User.java +++ b/src/main/java/com/accounting/entity/User.java @@ -18,6 +18,13 @@ public class User { private String password; private String nickname; + + /** + * 头像相对路径(如 {@code avatar/2026/09/29/xxx.jpg}),不是完整 URL。 + * + *

和 App「我的」页面共用同一个字段。null 表示用默认头像。

+ */ + private String avatar; private LocalDateTime createTime; diff --git a/src/main/java/com/accounting/service/AiChatService.java b/src/main/java/com/accounting/service/AiChatService.java index 7291b4e..d2c536f 100644 --- a/src/main/java/com/accounting/service/AiChatService.java +++ b/src/main/java/com/accounting/service/AiChatService.java @@ -100,10 +100,16 @@ public class AiChatService { SseEmitter emitter, EffectiveAiConfig effective, EffectivePersona persona) { try { - ChatSession session = resolveSession(userId, sessionId, content); + ChatSession session = resolveSession(userId, sessionId, content, persona); saveMessage(session.getId(), ROLE_USER, content); - List> llmMessages = buildLlmMessages(session.getId(), persona); + // 用**会话上绑定的人设**,而不是请求里带的那套 —— + // 人设在建会话时就定了,后续轮次一律以会话为准,聊到一半不会换性格。 + // 请求里的 personaId 只在「这是个新会话」时才有意义(见 resolveSession)。 + EffectivePersona sessionPersona = + aiPersonaService.resolve(userId, session.getPersonaId()); + + List> llmMessages = buildLlmMessages(session.getId(), sessionPersona); String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective); if (assistantText.isBlank()) { @@ -343,7 +349,17 @@ public class AiChatService { // ---------------------------------------------------------------- 会话与消息 - private ChatSession resolveSession(Long userId, Long sessionId, String firstContent) { + /** + * 拿到(或新建)会话。 + * + *

人设只在新建时写入:已有会话直接用会话上存的那个 personaId, + * 忽略本次请求带的 —— 这样一次对话从头到尾是同一个人。 + * 想换人设就开新会话,这是刻意的设计,不是遗漏。

+ * + * @param persona 本次请求解析出的人设,**仅在建新会话时**用来决定绑哪个 + */ + private ChatSession resolveSession(Long userId, Long sessionId, String firstContent, + EffectivePersona persona) { if (sessionId != null) { ChatSession session = sessionMapper.selectById(sessionId); if (session == null || !session.getUserId().equals(userId)) { @@ -354,6 +370,8 @@ public class AiChatService { ChatSession session = new ChatSession(); session.setUserId(userId); session.setTitle(abbreviate(firstContent.trim(), 20)); + // 内置默认人设不落库(persona_id 留 null)—— 和 ai_persona 的「默认项不入库」一致 + session.setPersonaId(persona == null ? null : persona.id()); sessionMapper.insert(session); return session; } diff --git a/src/main/java/com/accounting/service/AiPersonaService.java b/src/main/java/com/accounting/service/AiPersonaService.java index 41efbc6..aa4775a 100644 --- a/src/main/java/com/accounting/service/AiPersonaService.java +++ b/src/main/java/com/accounting/service/AiPersonaService.java @@ -46,6 +46,7 @@ public class AiPersonaService { private static final int MAX_NAME = 50; private static final int MAX_PROMPT = 8000; + private static final int MAX_AVATAR = 255; /** 名字留空时,从提示词里截取多少字当兜底名字 */ private static final int FALLBACK_NAME_LEN = 12; @@ -91,6 +92,7 @@ public class AiPersonaService { - 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。"""; private final AiPersonaMapper aiPersonaMapper; + private final AvatarService avatarService; // ---------------------------------------------------------------- 查询 @@ -157,6 +159,9 @@ public class AiPersonaService { public void delete(Long userId, Long id) { AiPersonaEntity entity = requireOwned(userId, id); aiPersonaMapper.deleteById(entity.getId()); + // 顺手把人设头像也删了,别在磁盘上堆孤儿文件。 + // 失败只是记日志 —— 删不掉一张图不该让「删人设」失败 + avatarService.deleteQuietly(entity.getAvatar()); log.info("AI 人设已删除: userId={}, id={}", userId, id); } @@ -187,6 +192,21 @@ public class AiPersonaService { // ---------------------------------------------------------------- 最终 prompt 组装 + /** + * 取某个人设的头像路径。 + * + *

给会话列表用:会话只存 personaId,要显示头像得再查一次。 + * 人设不存在(被删了)时返回 null,前端显示默认头像 —— 不要让历史会话因此报错。

+ */ + public String avatarOf(Long personaId, Long userId) { + if (personaId == null) return null; + AiPersonaEntity entity = aiPersonaMapper.selectOne( + new LambdaQueryWrapper() + .eq(AiPersonaEntity::getId, personaId) + .eq(AiPersonaEntity::getUserId, userId)); + return entity == null ? null : entity.getAvatar(); + } + /** * 组装最终送给模型的 system prompt:{@code 用户文本 + 空行 + 环境信息}。 * @@ -255,6 +275,40 @@ public class AiPersonaService { String name = request.getName() == null ? "" : request.getName().strip(); entity.setName(name.isEmpty() ? fallbackName(prompt) : truncate(name, MAX_NAME)); entity.setSystemPrompt(prompt); + + applyAvatar(entity, request); + } + + /** + * 处理头像。三种情况要分清: + * + *
    + *
  • {@code clearAvatar=true} → 清空(并把旧文件删掉)
  • + *
  • 传了新路径 → 换头像(旧文件删掉)
  • + *
  • 什么都没传 → **保持原样**,用户只改提示词时不该被迫重新选头像
  • + *
+ * + *

删旧文件失败只记日志 —— 删不掉一张旧图不该让「保存人设」失败。

+ */ + private void applyAvatar(AiPersonaEntity entity, AiPersonaRequest request) { + String oldAvatar = entity.getAvatar(); + + if (Boolean.TRUE.equals(request.getClearAvatar())) { + entity.setAvatar(null); + if (oldAvatar != null) avatarService.deleteQuietly(oldAvatar); + return; + } + + String newAvatar = request.getAvatar() == null ? "" : request.getAvatar().strip(); + if (newAvatar.isEmpty()) { + return; // 不改动 + } + if (newAvatar.equals(oldAvatar)) { + return; // 没变,别白删一遍 + } + + entity.setAvatar(truncate(newAvatar, MAX_AVATAR)); + if (oldAvatar != null) avatarService.deleteQuietly(oldAvatar); } /** 名字留空时,拿提示词开头几个字当名字,别让列表里出现一堆「未命名」 */ @@ -273,6 +327,7 @@ public class AiPersonaService { response.setName(entity.getName()); response.setSystemPrompt(entity.getSystemPrompt()); response.setPromptLength(entity.getSystemPrompt() == null ? 0 : entity.getSystemPrompt().length()); + response.setAvatar(entity.getAvatar()); response.setIsDefault(false); response.setEditable(true); // 用户项不带 defaultPrompt —— 它只在默认项上出现 diff --git a/src/main/java/com/accounting/service/AuthService.java b/src/main/java/com/accounting/service/AuthService.java index b180d22..988dbf2 100644 --- a/src/main/java/com/accounting/service/AuthService.java +++ b/src/main/java/com/accounting/service/AuthService.java @@ -4,6 +4,7 @@ import com.accounting.config.JwtConfig; import com.accounting.dto.AuthResponse; import com.accounting.dto.LoginRequest; import com.accounting.dto.RegisterRequest; +import com.accounting.dto.UpdateProfileRequest; import com.accounting.entity.User; import com.accounting.mapper.UserMapper; import org.springframework.beans.factory.annotation.Autowired; @@ -29,6 +30,9 @@ public class AuthService { @Autowired private AuthenticationManager authenticationManager; + + @Autowired + private AvatarService avatarService; @Transactional public AuthResponse register(RegisterRequest request) { @@ -53,7 +57,7 @@ public class AuthService { // 生成token String token = jwtConfig.generateToken(user.getUsername()); - return new AuthResponse(token, user.getUsername(), user.getNickname()); + return new AuthResponse(token, user.getUsername(), user.getNickname(), user.getAvatar()); } public AuthResponse login(LoginRequest request) { @@ -75,12 +79,57 @@ public class AuthService { // 生成token String token = jwtConfig.generateToken(user.getUsername()); - return new AuthResponse(token, user.getUsername(), user.getNickname()); + return new AuthResponse(token, user.getUsername(), user.getNickname(), user.getAvatar()); + } + + /** + * 拉当前用户资料。 + * + *

App 冷启动时本地缓存的 userInfo 可能已经过期(在别处换过头像), + * 这个接口用来刷一次。

+ */ + public AuthResponse profile(String username) { + User user = requireUser(username); + return new AuthResponse(null, user.getUsername(), user.getNickname(), user.getAvatar()); + } + + /** + * 改个人资料。目前只有头像。 + * + *

语义和 {@code AiPersonaRequest} 一致:不传 = 不改动, + * {@code clearAvatar=true} = 清空。两种混淆会让「只改昵称」意外清掉头像。

+ */ + @Transactional + public AuthResponse updateProfile(String username, UpdateProfileRequest request) { + User user = requireUser(username); + + String oldAvatar = user.getAvatar(); + + if (Boolean.TRUE.equals(request.getClearAvatar())) { + user.setAvatar(null); + avatarService.deleteQuietly(oldAvatar); + } else { + String newAvatar = request.getAvatar() == null ? "" : request.getAvatar().strip(); + if (!newAvatar.isEmpty() && !newAvatar.equals(oldAvatar)) { + user.setAvatar(newAvatar); + // 换了新头像,旧文件删掉,别在磁盘上堆孤儿 + avatarService.deleteQuietly(oldAvatar); + } + } + + userMapper.updateById(user); + // token 传 null —— 这不是登录,不发新 token,前端继续用手里那个 + return new AuthResponse(null, user.getUsername(), user.getNickname(), user.getAvatar()); + } + + private User requireUser(String username) { + User user = userMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(User::getUsername, username) + ); + if (user == null) { + throw new UsernameNotFoundException("用户不存在"); + } + return user; } } - - - - - - diff --git a/src/main/java/com/accounting/service/AvatarService.java b/src/main/java/com/accounting/service/AvatarService.java new file mode 100644 index 0000000..7718c4e --- /dev/null +++ b/src/main/java/com/accounting/service/AvatarService.java @@ -0,0 +1,103 @@ +package com.accounting.service; + +import com.accounting.dto.AvatarUploadResponse; +import com.accounting.util.FileUtil; +import com.accounting.util.ImageThumbUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 头像上传。人设头像和用户头像共用这一套逻辑。 + * + *

与照片上传({@link PhotoService})的三点不同,都是刻意的:

+ *
    + *
  • 上限 2MB 而不是 20MB:头像是小图,App 侧选图时已经压过一遍; + * 给 20MB 只会让用户误传一张原图上来。
  • + *
  • 不落库:只返回路径,由调用方(改人设 / 改资料)自己写进对应字段。 + * 这样同一张头像可以被复用,也不会出现「传了但没保存」的孤儿记录。
  • + *
  • 顺手压成方形缩略图:头像显示尺寸是 40~60px,原图几百 KB 没必要。 + * 失败不致命 —— 原图已经在了,大不了多传几 KB。
  • + *
+ * + *

路径落在 {@code /uploads/**} 下,那个前缀在 SecurityConfig 里已放行, + * 文件名是 UUID 不可枚举,所以头像 URL 不需要鉴权就能显示(列表里也能直接加载)。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AvatarService { + + /** 头像上限。App 侧选图时已限过 1600px / 质量 80,正常在几百 KB */ + private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024; + + /** 用户头像的子目录 */ + public static final String USER_SUB_DIR = "avatar"; + + /** 人设头像的子目录。分开是为了后台排查时一眼能看出来源 */ + public static final String PERSONA_SUB_DIR = "persona"; + + private final FileUtil fileUtil; + private final ImageThumbUtil imageThumbUtil; + + /** + * 保存一张头像。 + * + * @param subDir 子目录,用 {@link #USER_SUB_DIR} 或 {@link #PERSONA_SUB_DIR} + * @return 相对路径 + 预览地址 + */ + public AvatarUploadResponse save(MultipartFile file, String subDir) throws IOException { + String relativePath = fileUtil.saveFile(file, MAX_AVATAR_SIZE, subDir); + + // 顺手压一张方形小图。失败也继续 —— 原图已经落好了 + shrinkInPlace(relativePath); + + log.info("头像已保存: 路径={}, 原始大小={}B", relativePath, file.getSize()); + return new AvatarUploadResponse(relativePath, "/uploads/" + relativePath); + } + + /** + * 把人设头像的旧文件删掉(换头像 / 删除人设时调用)。 + * + *

失败只记日志不抛异常 —— 删不掉一张旧图不该让「保存人设」这个动作失败。

+ */ + public void deleteQuietly(String relativePath) { + if (relativePath == null || relativePath.isBlank()) return; + if (!fileUtil.deleteFile(relativePath)) { + log.warn("头像文件删除失败(不影响主流程),残留: {}", relativePath); + } + } + + /** + * 原地把图缩到 400px 以内。 + * + *

复用照片那套 {@link ImageThumbUtil}(最长边 400、JPEG 质量 0.82)。 + * 头像不是正方形也没关系 —— 前端用 {@code BoxFit.cover} 圆形裁切, + * 比在这儿做居中裁剪简单,也不会因为裁剪算法选得不好把脸切掉。

+ */ + private void shrinkInPlace(String relativePath) { + try { + Path source = Path.of(fileUtil.getFullPath(relativePath)); + if (!Files.exists(source)) return; + + // 生成到临时名,成功了再覆盖回去 —— 避免压缩中途失败把原图弄没 + Path temp = Path.of(source + ".tmp.jpg"); + File tempFile = temp.toFile(); + + if (imageThumbUtil.generate(source.toFile(), tempFile)) { + Files.move(temp, source, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } else { + Files.deleteIfExists(temp); + } + } catch (Exception e) { + // 压不了就算了,原图还在,头像照样能显示 + log.warn("头像压缩失败(不影响使用): {} - {}", relativePath, e.getMessage()); + } + } +} diff --git a/src/main/resources/db/migration_add_avatar_and_persona.sql b/src/main/resources/db/migration_add_avatar_and_persona.sql new file mode 100644 index 0000000..9ead96a --- /dev/null +++ b/src/main/resources/db/migration_add_avatar_and_persona.sql @@ -0,0 +1,41 @@ +-- 头像 + 会话绑定人设(2026-09-29) +-- +-- 三个改动,都是加列,不动已有数据: +-- +-- 1. ai_persona.avatar 人设头像。存**相对路径**(如 persona/2026/09/29/xxx.jpg), +-- 不是完整 URL —— 换服务器域名时不用洗数据。 +-- 前端拿到后自己拼 /uploads/ 前缀。 +-- 2. user.avatar 用户头像。和 App「我的」页面共用同一个字段, +-- 也走 /uploads/** 免鉴权访问(文件名是 UUID,不可枚举)。 +-- 3. chat_session.persona_id 会话绑定的人设。 +-- **新建会话时确定,之后不可改** —— 这样一次对话从头到尾 +-- 是同一个人,不会聊到一半换了性格。 +-- 存在会话上而不是每条消息上,是因为人设本来就是 +-- 「这次对话用谁」的粒度,不是「这句话用谁」。 +-- +-- 为什么 avatar 不建外键 / 不建独立文件表: +-- 就是一张图,一个路径字符串足够了。独立表意味着多一次 join 和一套 CRUD, +-- 换来的能力(同一张图被多处引用、文件元信息)这里一个都用不上。 +-- +-- 执行方式(在 231 上): +-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \ +-- -uroot -p密码 accounting_db < migration_add_avatar_and_persona.sql +-- +-- ⚠️ MySQL 8.0 **不支持** ADD COLUMN IF NOT EXISTS(那是 MariaDB 的语法), +-- 所以这个脚本只能跑一次。重复执行会报 "Duplicate column name",那是正常的, +-- 说明已经加过了,忽略即可。 + +ALTER TABLE `ai_persona` + ADD COLUMN `avatar` VARCHAR(255) NULL COMMENT '头像相对路径,如 persona/2026/09/29/xxx.jpg;NULL = 用默认头像' AFTER `system_prompt`; + +ALTER TABLE `user` + ADD COLUMN `avatar` VARCHAR(255) NULL COMMENT '头像相对路径;NULL = 用默认头像' AFTER `nickname`; + +ALTER TABLE `chat_session` + ADD COLUMN `persona_id` BIGINT NULL COMMENT '本会话用的人设ID,新建时确定且不可改;NULL = 内置默认人设' AFTER `title`, + ADD INDEX `idx_persona` (`persona_id`); + +-- 核对:三条 SHOW COLUMNS 都应该能看到新列 +-- SHOW COLUMNS FROM ai_persona LIKE 'avatar'; +-- SHOW COLUMNS FROM user LIKE 'avatar'; +-- SHOW COLUMNS FROM chat_session LIKE 'persona_id'; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 20e04a5..7bb8d9c 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS `user` ( `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', `password` VARCHAR(255) NOT NULL COMMENT '密码(加密)', `nickname` VARCHAR(50) COMMENT '昵称', + `avatar` VARCHAR(255) COMMENT '头像相对路径;NULL = 用默认头像', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), @@ -188,6 +189,7 @@ CREATE TABLE IF NOT EXISTS `chat_session` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话ID', `user_id` BIGINT NOT NULL COMMENT '用户ID', `title` VARCHAR(100) NOT NULL DEFAULT '新对话' COMMENT '标题(首条消息自动生成)', + `persona_id` BIGINT COMMENT '本会话用的人设ID,新建时确定且不可改;NULL = 内置默认人设', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近活跃时间', @@ -348,6 +350,7 @@ CREATE TABLE IF NOT EXISTS `ai_persona` ( `user_id` BIGINT NOT NULL COMMENT '用户ID', `name` VARCHAR(50) NOT NULL COMMENT '显示名,如「花花」「简洁模式」「陪聊模式」', `system_prompt` TEXT NOT NULL COMMENT '人设+运行规则。不含日期等环境信息——那些由代码运行时追加', + `avatar` VARCHAR(255) COMMENT '头像相对路径;NULL = 用默认头像', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', diff --git a/src/main/resources/static/admin/admin.css b/src/main/resources/static/admin/admin.css index 0d677d1..13702ed 100644 --- a/src/main/resources/static/admin/admin.css +++ b/src/main/resources/static/admin/admin.css @@ -734,3 +734,31 @@ mark { background: #FFE9A8; color: inherit; padding: 0 1px; border-radius: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + +/* ---------- 人设头像 ---------- */ +.cfg-avatar-row { + display: flex; align-items: center; gap: 12px; + margin: 2px 0 6px; +} +.cfg-avatar-img { + width: 56px; height: 56px; border-radius: 50%; + object-fit: cover; border: 1px solid var(--border); background: #FBF8F5; +} +.cfg-avatar-actions { display: flex; gap: 8px; } +.cfg-avatar-actions button { + border: 1px solid var(--border); background: #fff; color: var(--text); + padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 12.5px; +} +.cfg-avatar-actions button:hover { border-color: var(--brand); color: var(--brand); } +.cfg-avatar-hint { font-size: 11px; color: #BCAAA4; font-style: normal; display: block; margin-bottom: 12px; } + +/* ---------- 消息头像(微信风格)---------- */ +.ai-msg .ai-avatar { + width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0; + object-fit: cover; background: var(--brand-light); + display: flex; align-items: center; justify-content: center; + color: var(--brand); font-size: 15px; +} +/* 对方头像在左、自己头像在右 —— 和气泡方向一致 */ +.ai-msg.assistant .ai-avatar { margin-right: 10px; } +.ai-msg.user .ai-avatar { margin-left: 10px; } diff --git a/src/main/resources/static/admin/admin.js b/src/main/resources/static/admin/admin.js index 5dce432..b152722 100644 --- a/src/main/resources/static/admin/admin.js +++ b/src/main/resources/static/admin/admin.js @@ -5,6 +5,9 @@ 'use strict'; var API = '/api'; + // 静态资源根。后台和 /uploads 同源,所以是空串; + // 留成常量是为了以后把附件挪到独立域名/CDN 时只改这一处 + var ASSET_URL = ''; var TOKEN_KEY = 'aq_note_admin_token'; var USER_KEY = 'aq_note_admin_user'; @@ -1471,7 +1474,11 @@ // 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项) configId: null, // 当前选中的人设 id;null = 用代码内置的默认人设。与 configId 互相独立 - personaId: null + personaId: null, + // 当前会话绑的人设头像 URL(完整地址,已拼好 host)。气泡左侧用它 + activePersonaAvatar: '', + // 当前登录用户的头像 URL。气泡右侧用它 + userAvatar: '' }; function aiEnter() { @@ -1540,6 +1547,18 @@ function aiOpenSession(id) { ai.sessionId = id; + // 记住这条会话绑的人设头像 —— 气泡左侧要显示它。 + // 注意用的是**会话记录里的**人设,不是当前下拉框选的(会话内人设固定) + ai.activePersonaAvatar = ''; + var target = null; + (ai.sessions || []).forEach(function (s) { if (s.id === id) target = s; }); + if (target) { + ai.personaId = target.personaId == null ? null : target.personaId; + ai.activePersonaAvatar = target.personaAvatar + ? ASSET_URL + '/uploads/' + target.personaAvatar + : ''; + adminSavePersonaId(ai.personaId); + } aiRenderSessions(); var box = $('ai-messages'); box.innerHTML = '
加载中…
'; @@ -1590,12 +1609,43 @@ function aiAppendBubble(role, content) { var wrap = document.createElement('div'); wrap.className = 'ai-msg ' + role; + + // 头像:对方用当前会话绑的人设头像,自己用账号头像。 + // 拿不到图就显示一个首字母圆圈(比裂图好看,也省一次请求) + var avatar = document.createElement('div'); + avatar.className = 'ai-avatar'; + var avatarUrl = role === 'assistant' ? ai.activePersonaAvatar : ai.userAvatar; + if (avatarUrl) { + var img = document.createElement('img'); + img.className = 'ai-avatar'; + img.src = avatarUrl; + img.alt = ''; + // 加载失败就退回字母圆圈(后端文件被删、网络断了都会走到这) + img.onerror = function () { + var fb = document.createElement('div'); + fb.className = 'ai-avatar'; + fb.textContent = role === 'assistant' ? 'AI' : '我'; + img.replaceWith(fb); + }; + avatar = img; + } else { + avatar.textContent = role === 'assistant' ? 'AI' : '我'; + } + var bubble = document.createElement('div'); bubble.className = 'ai-bubble'; var body = document.createElement('div'); body.className = 'ai-bubble-body'; bubble.appendChild(body); - wrap.appendChild(bubble); + + // 对方头像在左、自己的在右 —— 和气泡方向一致 + if (role === 'assistant') { + wrap.appendChild(avatar); + wrap.appendChild(bubble); + } else { + wrap.appendChild(bubble); + wrap.appendChild(avatar); + } $('ai-messages').appendChild(wrap); if (role === 'assistant') { @@ -2192,6 +2242,19 @@ $('persona-edit-cancel').onclick = personaCloseEditor; $('persona-edit-save').onclick = personaSave; $('persona-restore').onclick = personaRestoreDefault; + // 头像:点「选择头像…」触发隐藏的 file input,选完自动上传 + $('persona-avatar-pick').onclick = function () { $('persona-avatar-file').click(); }; + $('persona-avatar-file').onchange = function () { + personaPickAvatar(this.files && this.files[0]); + // 清空 value —— 否则连续选同一个文件不会触发 change + this.value = ''; + }; + $('persona-avatar-clear').onclick = function () { + persona.avatar = ''; + persona.clearAvatar = true; + personaRenderAvatar(); + toast('头像将在保存后移除'); + }; $('persona-cfg').onclick = function (e) { if (e.target === this) personaClose(); }; $('persona-edit').onclick = function (e) { if (e.target === this) personaCloseEditor(); }; // 边打字边检查提示词是否还提到「工具」——只提示,不阻止保存 @@ -2421,7 +2484,7 @@ // · 「默认」是虚拟项(id 为 null),内容是代码内置的,不能改不能删 // · 当前选中存在浏览器本地,不动服务端 —— 免得和手机端互相打架 // · 提示词是用户输入,往 HTML 里插必须走 esc() - var persona = { items: [], editing: null, defaultPrompt: '' }; + var persona = { items: [], editing: null, defaultPrompt: '', avatar: '', clearAvatar: false }; function personaOpen() { $('persona-cfg').classList.add('open'); @@ -2515,11 +2578,73 @@ ? (existing.systemPrompt || '') : (persona.defaultPrompt || ''); $('persona-f-hint').textContent = '人设 + 运行规则都写这里。日期由服务端自动补,不用写。'; + + // 头像:回填。已有头像时显示预览 + 「移除」按钮 + persona.avatar = existing ? (existing.avatar || '') : ''; + persona.clearAvatar = false; + personaRenderAvatar(); + personaCheckPrompt(); $('persona-edit').classList.add('open'); $('persona-f-name').focus(); } + /** 按 persona.avatar 刷新头像预览区和两个按钮的显隐 */ + function personaRenderAvatar() { + var img = $('persona-avatar-preview'); + var clearBtn = $('persona-avatar-clear'); + var hint = $('persona-avatar-hint'); + + if (persona.avatar) { + img.src = ASSET_URL + '/uploads/' + persona.avatar; + img.style.display = ''; + clearBtn.style.display = ''; + hint.textContent = '头像会显示在聊天气泡旁边。'; + } else { + img.removeAttribute('src'); + img.style.display = 'none'; + clearBtn.style.display = 'none'; + hint.textContent = '还没有头像,留空则用默认图标。'; + } + } + + /** 选了文件 → 上传 → 记下返回的相对路径(保存时才写库) */ + function personaPickAvatar(file) { + if (!file) return; + if (!/^image\//.test(file.type)) { + toast('只能选图片', true); + return; + } + if (file.size > 2 * 1024 * 1024) { + toast('头像不能超过 2MB', true); + return; + } + + var form = new FormData(); + form.append('file', file); + + // 上传时不带 Content-Type —— 让浏览器自己加 boundary,手写会缺 boundary + fetch('/api/ai/personas/avatar', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + state.token }, + body: form + }).then(function (res) { + if (res.status === 401 || res.status === 403) { + doLogout(); + throw new Error('登录已过期,请重新登录'); + } + return res.json().then(function (data) { + if (!res.ok) throw new Error(data.message || data.error || '上传失败'); + return data; + }); + }).then(function (data) { + persona.avatar = data.path || ''; + persona.clearAvatar = false; + personaRenderAvatar(); + toast('头像已上传,记得保存'); + }).catch(function (e) { toast(e.message, true); }); + } + function personaCloseEditor() { $('persona-edit').classList.remove('open'); persona.editing = null; @@ -2559,6 +2684,14 @@ if (!prompt) { toast('提示词不能为空', true); return; } var payload = { name: name, systemPrompt: prompt }; + // 头像:传了才带(不改动);点过「移除」就发 clearAvatar。 + // 这两种语义要分清 —— 混淆会让「只改提示词」意外清掉头像 + if (persona.clearAvatar) { + payload.clearAvatar = true; + } else if (persona.avatar) { + payload.avatar = persona.avatar; + } + var isEdit = !!persona.editing; var req = isEdit ? request('/ai/personas/' + persona.editing.id, { method: 'PUT', body: JSON.stringify(payload) }) diff --git a/src/main/resources/static/admin/index.html b/src/main/resources/static/admin/index.html index f0d0897..c8ab7c9 100644 --- a/src/main/resources/static/admin/index.html +++ b/src/main/resources/static/admin/index.html @@ -339,6 +339,15 @@ placeholder="比如:你是一个说话简洁的记账助手…"> 人设 + 运行规则都写这里。日期由服务端自动补,不用写。 +
+ +
+ + +
+ +
+ 头像会显示在聊天气泡旁边,留空则用默认图标。
diff --git a/src/test/java/com/accounting/service/AiPersonaServiceTest.java b/src/test/java/com/accounting/service/AiPersonaServiceTest.java index a9097a8..34041cd 100644 --- a/src/test/java/com/accounting/service/AiPersonaServiceTest.java +++ b/src/test/java/com/accounting/service/AiPersonaServiceTest.java @@ -38,12 +38,15 @@ import static org.mockito.Mockito.when; class AiPersonaServiceTest { private AiPersonaMapper mapper; + private AvatarService avatarService; private AiPersonaService service; @BeforeEach void setUp() { mapper = mock(AiPersonaMapper.class); - service = new AiPersonaService(mapper); + // 头像服务在纯单测里 mock 掉 —— 它要读磁盘,不是这个类的测试目标 + avatarService = mock(AvatarService.class); + service = new AiPersonaService(mapper, avatarService); } // ---------------------------------------------------------------- 默认人设 @@ -118,7 +121,7 @@ class AiPersonaServiceTest { @Test @DisplayName("空人设退到内置默认,而不是让 AI 裸奔") void emptyPersonaFallsBackToDefault() { - EffectivePersona empty = new EffectivePersona(" ", "空的", false); + EffectivePersona empty = new EffectivePersona(" ", "空的", false, null); String finalPrompt = service.buildFinalPrompt(empty); assertTrue(finalPrompt.contains("必须用工具查询"), "空人设应该退到内置默认"); @@ -317,4 +320,148 @@ class AiPersonaServiceTest { assertEquals("花花", captor.getValue().getName()); assertEquals("你是助手。", captor.getValue().getSystemPrompt()); } + + // ---------------------------------------------------------------- 头像 + + @Test + @DisplayName("★ 不传头像时保持原值(只改提示词不该把头像弄没)") + void avatarUnchangedWhenNotProvided() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(1L); + entity.setUserId(1L); + entity.setName("花花"); + entity.setSystemPrompt("旧提示词"); + entity.setAvatar("persona/2026/09/29/old.jpg"); + when(mapper.selectOne(any())).thenReturn(entity); + + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt("新提示词"); + // 不设 avatar,也不设 clearAvatar + + service.update(1L, 1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiPersonaEntity.class); + verify(mapper).updateById(captor.capture()); + assertEquals("persona/2026/09/29/old.jpg", captor.getValue().getAvatar(), + "没传头像就该保持原值"); + // 不该去删旧文件 + verify(avatarService, never()).deleteQuietly(any()); + } + + @Test + @DisplayName("传了新头像路径时替换,并把旧文件删掉") + void avatarReplacedAndOldDeleted() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(1L); + entity.setUserId(1L); + entity.setName("花花"); + entity.setSystemPrompt("提示词"); + entity.setAvatar("persona/2026/09/29/old.jpg"); + when(mapper.selectOne(any())).thenReturn(entity); + + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt("提示词"); + req.setAvatar("persona/2026/09/29/new.jpg"); + + service.update(1L, 1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiPersonaEntity.class); + verify(mapper).updateById(captor.capture()); + assertEquals("persona/2026/09/29/new.jpg", captor.getValue().getAvatar()); + // 旧文件必须删掉,否则磁盘上会堆孤儿 + verify(avatarService).deleteQuietly("persona/2026/09/29/old.jpg"); + } + + @Test + @DisplayName("传同一个路径时不重复删文件(没变就别动)") + void sameAvatarDoesNotTriggerDelete() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(1L); + entity.setUserId(1L); + entity.setName("花花"); + entity.setSystemPrompt("提示词"); + entity.setAvatar("persona/same.jpg"); + when(mapper.selectOne(any())).thenReturn(entity); + + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt("提示词"); + req.setAvatar("persona/same.jpg"); + + service.update(1L, 1L, req); + + // 路径没变,不该把正在用的文件删掉(那会让头像变成裂图) + verify(avatarService, never()).deleteQuietly(any()); + } + + @Test + @DisplayName("★ clearAvatar=true 时清空头像并删文件") + void clearAvatarRemovesFile() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(1L); + entity.setUserId(1L); + entity.setName("花花"); + entity.setSystemPrompt("提示词"); + entity.setAvatar("persona/old.jpg"); + when(mapper.selectOne(any())).thenReturn(entity); + + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt("提示词"); + req.setClearAvatar(true); + + service.update(1L, 1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiPersonaEntity.class); + verify(mapper).updateById(captor.capture()); + assertNull(captor.getValue().getAvatar(), "应该清空"); + verify(avatarService).deleteQuietly("persona/old.jpg"); + } + + @Test + @DisplayName("删除人设时顺手删掉它的头像文件") + void deleteAlsoRemovesAvatarFile() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(1L); + entity.setUserId(1L); + entity.setAvatar("persona/gone.jpg"); + when(mapper.selectOne(any())).thenReturn(entity); + + service.delete(1L, 1L); + + verify(avatarService).deleteQuietly("persona/gone.jpg"); + } + + @Test + @DisplayName("响应里带上头像路径") + void responseCarriesAvatar() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(9L); + entity.setName("我的"); + entity.setSystemPrompt("随便"); + entity.setAvatar("persona/me.jpg"); + when(mapper.selectList(any())).thenReturn(List.of(entity)); + + List list = service.list(1L); + + assertEquals("persona/me.jpg", list.get(1).getAvatar()); + } + + @Test + @DisplayName("avatarOf:人设被删了返回 null,不让会话列表报错") + void avatarOfHandlesMissingPersona() { + when(mapper.selectOne(any())).thenReturn(null); + + // 人设被删了 → 返回 null,前端显示默认头像 + assertNull(service.avatarOf(999L, 1L)); + + // personaId 为 null(内置默认人设)时压根不该查库 + assertNull(service.avatarOf(null, 1L)); + } + + @Test + @DisplayName("avatarOf:默认人设(personaId=null)不查库") + void avatarOfSkipsQueryForDefaultPersona() { + service.avatarOf(null, 1L); + + verify(mapper, never()).selectOne(any()); + } }