AI助手优化
个人信息可以修改 WEB更新
This commit is contained in:
@@ -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<SessionItem> 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)")
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
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 = "用户注册、登录接口")
|
||||
/**
|
||||
* 认证与个人资料。
|
||||
*
|
||||
* <p>头像放在这里而不是单开一个 UserController:App 里只有「我的」页面用得到,
|
||||
* 而且它天然跟登录态绑定 —— 换完头像要返回新的用户信息给前端刷新。</p>
|
||||
*/
|
||||
@Tag(name = "认证管理", description = "用户注册、登录、个人资料接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
@@ -19,6 +30,9 @@ public class AuthController {
|
||||
@Autowired
|
||||
private AuthService authService;
|
||||
|
||||
@Autowired
|
||||
private AvatarService avatarService;
|
||||
|
||||
@Operation(summary = "用户注册")
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
@@ -32,13 +46,26 @@ public class AuthController {
|
||||
AuthResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "拉取当前用户资料(App 冷启动后刷新头像用)")
|
||||
@GetMapping("/profile")
|
||||
public ResponseEntity<AuthResponse> profile(Authentication authentication) {
|
||||
return ResponseEntity.ok(authService.profile(authentication.getName()));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改个人资料(目前只有头像)")
|
||||
@PutMapping("/profile")
|
||||
public ResponseEntity<AuthResponse> updateProfile(@Valid @RequestBody UpdateProfileRequest request,
|
||||
Authentication authentication) {
|
||||
return ResponseEntity.ok(authService.updateProfile(authentication.getName(), request));
|
||||
}
|
||||
|
||||
@Operation(summary = "上传头像(返回路径,再由 PUT /profile 提交)")
|
||||
@PostMapping("/avatar")
|
||||
public ResponseEntity<AvatarUploadResponse> uploadAvatar(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
// 和上传人设头像一样:只落盘不落库。
|
||||
// 用户在弹层里可能传了又取消,那种情况不该留下记录
|
||||
return ResponseEntity.ok(avatarService.save(file, AvatarService.USER_SUB_DIR));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,14 @@ public class AuthResponse {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 上传头像后返回的东西。
|
||||
*
|
||||
* <p>只回**相对路径**,不回完整 URL —— 服务端不知道客户端是从哪个域名访问的
|
||||
* (局域网 / 内网穿透 / 正式域名都可能)。前端自己拼 {@code /uploads/} 前缀。</p>
|
||||
*
|
||||
* <p>给 {@code url} 是为了让前端能立刻预览:那是「拼好 /uploads/ 的相对地址」,
|
||||
* 仍然不含 host。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 修改个人资料。
|
||||
*
|
||||
* <p>目前只有头像。昵称故意不开放修改 —— App 里没有这个入口,
|
||||
* 多一个字段就多一处「改了但界面没反应」的可能。</p>
|
||||
*/
|
||||
@Data
|
||||
public class UpdateProfileRequest {
|
||||
|
||||
/**
|
||||
* 头像相对路径(先调 {@code POST /api/auth/avatar} 拿到)。
|
||||
*
|
||||
* <p>传 null 或空串 = 不改动。要清空头像用 {@code clearAvatar}。</p>
|
||||
*/
|
||||
@Size(max = 255, message = "头像路径过长")
|
||||
private String avatar;
|
||||
|
||||
/** 是否清空头像(回到默认头像) */
|
||||
private Boolean clearAvatar;
|
||||
}
|
||||
@@ -24,4 +24,15 @@ public class AiPersonaRequest {
|
||||
*/
|
||||
@Size(max = 8000, message = "提示词最长 8000 字")
|
||||
private String systemPrompt;
|
||||
|
||||
/**
|
||||
* 头像相对路径。前端先调 {@code POST /api/ai/personas/avatar} 拿到路径,再连配置一起提交。
|
||||
*
|
||||
* <p>传 null 或空串表示**不改动** —— 用户只改提示词时不该被迫重新选头像。</p>
|
||||
*/
|
||||
@Size(max = 255, message = "头像路径过长")
|
||||
private String avatar;
|
||||
|
||||
/** 是否清空头像(回到默认头像)。比传空串更明确,避免「不改动」和「清空」混淆 */
|
||||
private Boolean clearAvatar;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@ public class AiPersonaResponse {
|
||||
/** 提示词字数(前端显示「1234 字」用,省得在客户端数) */
|
||||
private Integer promptLength;
|
||||
|
||||
/**
|
||||
* 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg})。null = 用默认头像。
|
||||
*
|
||||
* <p>是相对路径不是完整 URL —— 前端自己拼 {@code /uploads/} 前缀。</p>
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
/** 是否是「默认」那一项 */
|
||||
private Boolean isDefault;
|
||||
|
||||
|
||||
@@ -21,14 +21,21 @@ public record EffectivePersona(
|
||||
/** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */
|
||||
String label,
|
||||
/** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */
|
||||
boolean isDefault
|
||||
boolean isDefault,
|
||||
/**
|
||||
* 人设 id。**内置默认时为 null**。
|
||||
*
|
||||
* <p>带上它是为了「新建会话时把人设绑到会话上」—— 没有 id 就没法落库,
|
||||
* 而按名字反查会在重名时选错。</p>
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,17 @@ public class SessionItem {
|
||||
|
||||
/** 最近活跃时间,列表按它倒序 */
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/**
|
||||
* 本会话用的人设 id。null = 内置默认人设。
|
||||
*
|
||||
* <p>给前端用来在会话列表里显示「这段对话是谁在陪你」。</p>
|
||||
*/
|
||||
private Long personaId;
|
||||
|
||||
/** 人设显示名。查不到(人设已删)时为「默认人设」—— 历史会话仍要能正常打开 */
|
||||
private String personaName;
|
||||
|
||||
/** 人设头像相对路径。null = 用默认头像 */
|
||||
private String personaAvatar;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ public class AiPersonaEntity {
|
||||
/** 人设 + 运行规则。不含日期等环境信息 */
|
||||
private String systemPrompt;
|
||||
|
||||
/**
|
||||
* 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg}),不是完整 URL。
|
||||
*
|
||||
* <p>存相对路径是为了换域名/换服务器时不用洗数据 —— 前端拿到后自己拼
|
||||
* {@code /uploads/} 前缀。null 表示用默认头像。</p>
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
|
||||
@@ -27,6 +27,17 @@ public class ChatSession {
|
||||
/** 标题,首条消息时自动取前 20 字 */
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 本会话用的人设 ID。**新建会话时确定,之后不再改**。
|
||||
*
|
||||
* <p>存在会话上而不是每条消息上,是因为人设的粒度是「这次对话用谁」,
|
||||
* 不是「这句话用谁」—— 聊到一半换性格会很怪。</p>
|
||||
*
|
||||
* <p>null 表示用代码内置的默认人设。不建外键:人设被删了之后历史会话
|
||||
* 仍要能打开,指向一个不存在的 id 时服务端会静默降级到默认。</p>
|
||||
*/
|
||||
private Long personaId;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ public class User {
|
||||
|
||||
private String nickname;
|
||||
|
||||
/**
|
||||
* 头像相对路径(如 {@code avatar/2026/09/29/xxx.jpg}),不是完整 URL。
|
||||
*
|
||||
* <p>和 App「我的」页面共用同一个字段。null 表示用默认头像。</p>
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@@ -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<Map<String, Object>> llmMessages = buildLlmMessages(session.getId(), persona);
|
||||
// 用**会话上绑定的人设**,而不是请求里带的那套 ——
|
||||
// 人设在建会话时就定了,后续轮次一律以会话为准,聊到一半不会换性格。
|
||||
// 请求里的 personaId 只在「这是个新会话」时才有意义(见 resolveSession)。
|
||||
EffectivePersona sessionPersona =
|
||||
aiPersonaService.resolve(userId, session.getPersonaId());
|
||||
|
||||
List<Map<String, Object>> 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) {
|
||||
/**
|
||||
* 拿到(或新建)会话。
|
||||
*
|
||||
* <p><b>人设只在新建时写入</b>:已有会话直接用会话上存的那个 personaId,
|
||||
* 忽略本次请求带的 —— 这样一次对话从头到尾是同一个人。
|
||||
* 想换人设就开新会话,这是刻意的设计,不是遗漏。</p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
@@ -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 组装
|
||||
|
||||
/**
|
||||
* 取某个人设的头像路径。
|
||||
*
|
||||
* <p>给会话列表用:会话只存 personaId,要显示头像得再查一次。
|
||||
* 人设不存在(被删了)时返回 null,前端显示默认头像 —— 不要让历史会话因此报错。</p>
|
||||
*/
|
||||
public String avatarOf(Long personaId, Long userId) {
|
||||
if (personaId == null) return null;
|
||||
AiPersonaEntity entity = aiPersonaMapper.selectOne(
|
||||
new LambdaQueryWrapper<AiPersonaEntity>()
|
||||
.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理头像。三种情况要分清:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code clearAvatar=true} → 清空(并把旧文件删掉)</li>
|
||||
* <li>传了新路径 → 换头像(旧文件删掉)</li>
|
||||
* <li>什么都没传 → **保持原样**,用户只改提示词时不该被迫重新选头像</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>删旧文件失败只记日志 —— 删不掉一张旧图不该让「保存人设」失败。</p>
|
||||
*/
|
||||
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 —— 它只在默认项上出现
|
||||
|
||||
@@ -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;
|
||||
@@ -30,6 +31,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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉当前用户资料。
|
||||
*
|
||||
* <p>App 冷启动时本地缓存的 userInfo 可能已经过期(在别处换过头像),
|
||||
* 这个接口用来刷一次。</p>
|
||||
*/
|
||||
public AuthResponse profile(String username) {
|
||||
User user = requireUser(username);
|
||||
return new AuthResponse(null, user.getUsername(), user.getNickname(), user.getAvatar());
|
||||
}
|
||||
|
||||
/**
|
||||
* 改个人资料。目前只有头像。
|
||||
*
|
||||
* <p>语义和 {@code AiPersonaRequest} 一致:不传 = 不改动,
|
||||
* {@code clearAvatar=true} = 清空。两种混淆会让「只改昵称」意外清掉头像。</p>
|
||||
*/
|
||||
@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<User>()
|
||||
.eq(User::getUsername, username)
|
||||
);
|
||||
if (user == null) {
|
||||
throw new UsernameNotFoundException("用户不存在");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 头像上传。人设头像和用户头像共用这一套逻辑。
|
||||
*
|
||||
* <p>与照片上传({@link PhotoService})的三点不同,都是刻意的:</p>
|
||||
* <ul>
|
||||
* <li><b>上限 2MB 而不是 20MB</b>:头像是小图,App 侧选图时已经压过一遍;
|
||||
* 给 20MB 只会让用户误传一张原图上来。</li>
|
||||
* <li><b>不落库</b>:只返回路径,由调用方(改人设 / 改资料)自己写进对应字段。
|
||||
* 这样同一张头像可以被复用,也不会出现「传了但没保存」的孤儿记录。</li>
|
||||
* <li><b>顺手压成方形缩略图</b>:头像显示尺寸是 40~60px,原图几百 KB 没必要。
|
||||
* 失败不致命 —— 原图已经在了,大不了多传几 KB。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>路径落在 {@code /uploads/**} 下,那个前缀在 SecurityConfig 里已放行,
|
||||
* 文件名是 UUID 不可枚举,所以头像 URL 不需要鉴权就能显示(列表里也能直接加载)。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把人设头像的旧文件删掉(换头像 / 删除人设时调用)。
|
||||
*
|
||||
* <p>失败只记日志不抛异常 —— 删不掉一张旧图不该让「保存人设」这个动作失败。</p>
|
||||
*/
|
||||
public void deleteQuietly(String relativePath) {
|
||||
if (relativePath == null || relativePath.isBlank()) return;
|
||||
if (!fileUtil.deleteFile(relativePath)) {
|
||||
log.warn("头像文件删除失败(不影响主流程),残留: {}", relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原地把图缩到 400px 以内。
|
||||
*
|
||||
* <p>复用照片那套 {@link ImageThumbUtil}(最长边 400、JPEG 质量 0.82)。
|
||||
* 头像不是正方形也没关系 —— 前端用 {@code BoxFit.cover} 圆形裁切,
|
||||
* 比在这儿做居中裁剪简单,也不会因为裁剪算法选得不好把脸切掉。</p>
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 '更新时间',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 = '<div class="ai-empty">加载中…</div>';
|
||||
@@ -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);
|
||||
|
||||
// 对方头像在左、自己的在右 —— 和气泡方向一致
|
||||
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) })
|
||||
|
||||
@@ -339,6 +339,15 @@
|
||||
placeholder="比如:你是一个说话简洁的记账助手…"></textarea>
|
||||
<em id="persona-f-hint">人设 + 运行规则都写这里。日期由服务端自动补,不用写。</em>
|
||||
</label>
|
||||
<div class="cfg-avatar-row">
|
||||
<img id="persona-avatar-preview" class="cfg-avatar-img" alt="" style="display:none">
|
||||
<div class="cfg-avatar-actions">
|
||||
<button type="button" id="persona-avatar-pick">选择头像…</button>
|
||||
<button type="button" id="persona-avatar-clear" style="display:none">移除</button>
|
||||
</div>
|
||||
<input type="file" id="persona-avatar-file" accept="image/*" style="display:none">
|
||||
</div>
|
||||
<em id="persona-avatar-hint" class="cfg-avatar-hint">头像会显示在聊天气泡旁边,留空则用默认图标。</em>
|
||||
<div class="cfg-warn" id="persona-warn" style="display:none"></div>
|
||||
<div class="dialog-foot">
|
||||
<button id="persona-restore">恢复默认</button>
|
||||
|
||||
@@ -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<AiPersonaEntity> 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<AiPersonaEntity> 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<AiPersonaEntity> 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<AiPersonaResponse> 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user